diff --git a/package.json b/package.json index b7e191e4a..1a6fce927 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json", "typecheck:script": "tsgo --noEmit -p script/tsconfig.json", "test": "bun test", - "test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs", + "test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-agent-links.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs", "test:windows-codex": "bun run test:codex", "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" }, diff --git a/packages/omo-codex/scripts/install-agent-links.test.mjs b/packages/omo-codex/scripts/install-agent-links.test.mjs new file mode 100644 index 000000000..28a1e3a5f --- /dev/null +++ b/packages/omo-codex/scripts/install-agent-links.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { lstat, mkdir, readFile, readlink, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +import { installMarketplaceLocally } from "./install-local.mjs"; +import { makeTempDir, writeJson, writePluginAt } from "./install-test-fixtures.mjs"; + +const legacyCodexPluginMarketplace = ["code", "yeongyu", "codex", "plugins"].join("-"); + +test( + "#given bundled agent roles and stale legacy links #when installing locally #then relinks Codex agents to current cache", + { skip: process.platform === "win32" ? "Windows copies agent files instead of symlinking them" : false }, + async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const pluginRoot = join(codexPackageRoot, "plugin"); + const agentsRoot = join(pluginRoot, "components", "ultrawork", "agents"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }); + await writePluginAt(pluginRoot, "omo", "0.1.0"); + await mkdir(agentsRoot, { recursive: true }); + for (const agentName of ["explorer", "librarian", "plan"]) { + await writeFile(join(agentsRoot, `${agentName}.toml`), `name = "${agentName}"\n`); + } + await mkdir(join(codexHome, "agents"), { recursive: true }); + await symlink( + join(codexHome, "plugins", "cache", legacyCodexPluginMarketplace, "omo", "0.1.0", "components", "ultrawork", "agents", "explorer.toml"), + join(codexHome, "agents", "explorer.toml"), + ); + + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + platform: "linux", + runCommand: async () => {}, + log: () => {}, + }); + + assert.equal(result.installed.length, 1); + const pluginPath = result.installed[0].path; + for (const agentName of ["explorer", "librarian", "plan"]) { + const agentPath = join(codexHome, "agents", `${agentName}.toml`); + assert.equal((await lstat(agentPath)).isSymbolicLink(), true); + assert.equal(await readlink(agentPath), join(pluginPath, "components", "ultrawork", "agents", `${agentName}.toml`)); + assert.equal(await readFile(agentPath, "utf8"), `name = "${agentName}"\n`); + } + + const installedAgents = JSON.parse(await readFile(join(pluginPath, ".installed-agents.json"), "utf8")); + assert.deepEqual(installedAgents.agents.sort(), [ + join(codexHome, "agents", "explorer.toml"), + join(codexHome, "agents", "librarian.toml"), + join(codexHome, "agents", "plan.toml"), + ]); + }, +); diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs index 3926932ae..2671f5971 100644 --- a/packages/omo-codex/scripts/install-local.mjs +++ b/packages/omo-codex/scripts/install-local.mjs @@ -9,6 +9,7 @@ import { pruneMarketplaceCache, pruneMarketplacePluginCaches, } from "./install/cache.mjs"; +import { linkCachedPluginAgents } from "./install/agents.mjs"; import { updateCodexConfig } from "./install/config.mjs"; import { trustedHookStatesForPlugin } from "./install/hook-trust.mjs"; import { defaultRunCommand } from "./install/process.mjs"; @@ -22,10 +23,23 @@ import { const LEGACY_CODEX_PLUGIN_MARKETPLACE = ["code", "yeongyu", "codex", "plugins"].join("-"); const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", LEGACY_CODEX_PLUGIN_MARKETPLACE]; +export function resolveCodexInstallerBinDir(options = {}) { + const homeDir = resolve(options.homeDir ?? homedir()); + const env = options.env ?? process.env; + const explicitBinDir = nonEmptyEnvValue(env, "CODEX_LOCAL_BIN_DIR"); + if (explicitBinDir !== undefined) return explicitBinDir; + + const codexHome = resolve(options.codexHome ?? nonEmptyEnvValue(env, "CODEX_HOME") ?? join(homeDir, ".codex")); + const defaultCodexHome = resolve(join(homeDir, ".codex")); + return codexHome === defaultCodexHome ? join(homeDir, ".local", "bin") : join(codexHome, "bin"); +} + export async function installMarketplaceLocally(options = {}) { const repoRoot = resolve(options.repoRoot ?? process.cwd()); - const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex")); - const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin")); + const env = options.env ?? process.env; + const homeDir = resolve(options.homeDir ?? homedir()); + const codexHome = resolve(options.codexHome ?? nonEmptyEnvValue(env, "CODEX_HOME") ?? join(homeDir, ".codex")); + const binDir = resolve(options.binDir ?? resolveCodexInstallerBinDir({ codexHome, env, homeDir })); const platform = options.platform ?? process.platform; const runCommand = options.runCommand ?? defaultRunCommand; const log = options.log ?? console.log; @@ -34,6 +48,7 @@ export async function installMarketplaceLocally(options = {}) { marketplacePath: join(codexPackageRoot, "marketplace.json"), }); const installed = []; + const agentConfigs = new Map(); for (const entry of marketplace.plugins) { const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" }); @@ -59,6 +74,12 @@ export async function installMarketplaceLocally(options = {}) { for (const link of binLinks) { log(`Linked ${link.name} -> ${link.target}`); } + const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot: plugin.path, platform }); + for (const link of agentLinks) { + log(`Linked agent ${link.name} -> ${link.target}`); + const agentName = agentNameFromToml(link.name); + agentConfigs.set(agentName, { name: agentName, configFile: `./agents/${link.name}` }); + } installed.push(plugin); } @@ -84,6 +105,7 @@ export async function installMarketplaceLocally(options = {}) { marketplaceName: marketplace.name, pluginNames, trustedHookStates, + agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), }); for (const plugin of installed) { @@ -93,6 +115,17 @@ export async function installMarketplaceLocally(options = {}) { return { marketplaceName: marketplace.name, installed }; } +function agentNameFromToml(fileName) { + return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName; +} + +function nonEmptyEnvValue(env, key) { + const value = env[key]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : value; +} + function legacyCacheMarketplaces(marketplaceName) { return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : []; } diff --git a/packages/omo-codex/scripts/install-local.test.mjs b/packages/omo-codex/scripts/install-local.test.mjs index aa7ffd60a..310297754 100644 --- a/packages/omo-codex/scripts/install-local.test.mjs +++ b/packages/omo-codex/scripts/install-local.test.mjs @@ -4,12 +4,68 @@ import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { installMarketplaceLocally } from "./install-local.mjs"; +import { installMarketplaceLocally, resolveCodexInstallerBinDir } from "./install-local.mjs"; import { makeTempDir, writeJson, writePluginAt } from "./install-test-fixtures.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const legacyCodexPluginMarketplace = ["code", "yeongyu", "codex", "plugins"].join("-"); +test("#given default CODEX_HOME #when resolving local installer bin dir without override #then preserves user local bin precedence", () => { + const homeDir = join("/tmp", "omo-codex-home-default"); + const codexHome = join(homeDir, ".codex"); + + assert.equal(resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir }), join(homeDir, ".local", "bin")); +}); + +test("#given custom CODEX_HOME #when resolving local installer bin dir without override #then keeps generated omo inside that Codex home", () => { + const homeDir = join("/tmp", "omo-codex-home-custom"); + const codexHome = join("/tmp", "omo-codex-install-custom"); + + assert.equal(resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir }), join(codexHome, "bin")); +}); + +test("#given custom CODEX_HOME and PATH without omo #when installing locally without bin override #then bootstraps via local CLI when omo is absent", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const homeDir = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const pluginRoot = join(codexPackageRoot, "plugin"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }); + await writePluginAt(pluginRoot, "omo", "0.1.0"); + + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + env: { PATH: "/usr/bin:/bin" }, + homeDir, + platform: "linux", + runCommand: async () => {}, + log: () => {}, + }); + + assert.equal(result.installed.length, 1); + assert.equal(await readlink(join(codexHome, "bin", "omo")), join(result.installed[0].path, "dist", "cli.js")); +}); + +test("#given explicit CODEX_LOCAL_BIN_DIR #when resolving local installer bin dir #then preserves installed omo precedence", () => { + const homeDir = join("/tmp", "omo-codex-home-explicit"); + const codexHome = join("/tmp", "omo-codex-install-explicit"); + const explicitBinDir = join("/tmp", "omo-codex-explicit-bin"); + + assert.equal( + resolveCodexInstallerBinDir({ + codexHome, + env: { CODEX_LOCAL_BIN_DIR: explicitBinDir }, + homeDir, + }), + explicitBinDir, + ); +}); + test("#given omo plugin source #when inspecting identity #then uses sisyphuslabs omo metadata", async () => { const pluginRoot = join(scriptDir, "..", "plugin"); @@ -144,6 +200,9 @@ test("#given local marketplace #when installing #then copies versioned plugins a assert.match(config, /\[marketplaces\.debug-marketplace\]/); assert.match(config, /source_type = "local"/); assert.match(config, /\[plugins\."alpha@debug-marketplace"\]\nenabled = true/); + assert.match(config, /\[agents\.explorer\]\nconfig_file = "\.\/agents\/explorer\.toml"/); + assert.match(config, /\[agents\.librarian\]\nconfig_file = "\.\/agents\/librarian\.toml"/); + assert.match(config, /\[agents\.plan\]\nconfig_file = "\.\/agents\/plan\.toml"/); assert.doesNotMatch(config, /stale@debug-marketplace/); }); diff --git a/packages/omo-codex/scripts/install-test-fixtures.mjs b/packages/omo-codex/scripts/install-test-fixtures.mjs index 8f45293fa..8e684dcaf 100644 --- a/packages/omo-codex/scripts/install-test-fixtures.mjs +++ b/packages/omo-codex/scripts/install-test-fixtures.mjs @@ -21,6 +21,7 @@ export async function writePluginAt(pluginRoot, name, version) { await mkdir(join(pluginRoot, "dist"), { recursive: true }); await mkdir(join(pluginRoot, "hooks"), { recursive: true }); await mkdir(join(pluginRoot, "skills", name), { recursive: true }); + await mkdir(join(pluginRoot, "components", "ultrawork", "agents"), { recursive: true }); await writeJson(join(pluginRoot, ".codex-plugin", "plugin.json"), { name, version, @@ -40,6 +41,9 @@ export async function writePluginAt(pluginRoot, name, version) { }); await writeJson(join(pluginRoot, "hooks", "hooks.json"), { hooks: {} }); await writeFile(join(pluginRoot, "skills", name, "SKILL.md"), "---\nname: test\n---\n"); + await writeFile(join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"), 'name = "explorer"\n'); + await writeFile(join(pluginRoot, "components", "ultrawork", "agents", "librarian.toml"), 'name = "librarian"\n'); + await writeFile(join(pluginRoot, "components", "ultrawork", "agents", "plan.toml"), 'name = "plan"\n'); await writeJson(join(pluginRoot, "package.json"), { name: `@example/${name}`, version, diff --git a/packages/omo-codex/scripts/install/agents.mjs b/packages/omo-codex/scripts/install/agents.mjs new file mode 100644 index 000000000..5754286c9 --- /dev/null +++ b/packages/omo-codex/scripts/install/agents.mjs @@ -0,0 +1,84 @@ +import { basename, join } from "node:path"; +import { copyFile, lstat, mkdir, readdir, rm, symlink, writeFile } from "node:fs/promises"; + +import { exists } from "./utils.mjs"; + +const MANIFEST_FILE = ".installed-agents.json"; + +export async function linkCachedPluginAgents({ codexHome, pluginRoot, platform = process.platform }) { + const bundledAgents = await discoverBundledAgents(pluginRoot); + if (bundledAgents.length === 0) { + await writeManifest(pluginRoot, []); + return []; + } + + const agentsDir = join(codexHome, "agents"); + await mkdir(agentsDir, { recursive: true }); + const linked = []; + for (const agentPath of bundledAgents) { + const linkPath = join(agentsDir, basename(agentPath)); + if (platform === "win32") { + await replaceWithCopy(linkPath, agentPath); + } else { + await replaceWithSymlink(linkPath, agentPath); + } + linked.push({ name: basename(agentPath), path: linkPath, target: agentPath }); + } + await writeManifest(pluginRoot, linked.map((entry) => entry.path)); + return linked; +} + +async function discoverBundledAgents(pluginRoot) { + const componentsRoot = join(pluginRoot, "components"); + if (!(await exists(componentsRoot))) return []; + + const componentEntries = await readdir(componentsRoot, { withFileTypes: true }); + const agents = []; + for (const entry of componentEntries) { + if (!entry.isDirectory()) continue; + const agentsRoot = join(componentsRoot, entry.name, "agents"); + if (!(await exists(agentsRoot))) continue; + const agentEntries = await readdir(agentsRoot, { withFileTypes: true }); + for (const file of agentEntries) { + if (!file.isFile() || !file.name.endsWith(".toml")) continue; + agents.push(join(agentsRoot, file.name)); + } + } + agents.sort(); + return agents; +} + +async function replaceWithSymlink(linkPath, target) { + await prepareReplacement(linkPath); + await symlink(target, linkPath); +} + +async function replaceWithCopy(linkPath, target) { + await prepareReplacement(linkPath); + await copyFile(target, linkPath); +} + +async function prepareReplacement(linkPath) { + if (!(await lstatExists(linkPath))) return; + const entryStat = await lstat(linkPath); + if (entryStat.isDirectory() && !entryStat.isSymbolicLink()) { + throw new Error(`${linkPath} already exists and is a directory; refusing to replace`); + } + await rm(linkPath, { force: true }); +} + +async function writeManifest(pluginRoot, agentPaths) { + const manifestPath = join(pluginRoot, MANIFEST_FILE); + const payload = { agents: [...agentPaths].sort() }; + await writeFile(manifestPath, `${JSON.stringify(payload, null, "\t")}\n`); +} + +async function lstatExists(path) { + try { + await lstat(path); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} diff --git a/packages/omo-codex/scripts/install/config.mjs b/packages/omo-codex/scripts/install/config.mjs index feff470a7..e8de50106 100644 --- a/packages/omo-codex/scripts/install/config.mjs +++ b/packages/omo-codex/scripts/install/config.mjs @@ -18,6 +18,7 @@ export async function updateCodexConfig({ marketplaceSource = defaultMarketplaceSource(marketplaceName, repoRoot), pluginNames, trustedHookStates = [], + agentConfigs = [], }) { await mkdir(dirname(configPath), { recursive: true }); let config = ""; @@ -39,6 +40,9 @@ export async function updateCodexConfig({ for (const state of trustedHookStates) { config = ensureHookTrusted(config, state.key, state.trustedHash); } + for (const agentConfig of agentConfigs) { + config = ensureAgentConfig(config, agentConfig); + } await writeFile(configPath, config.trimEnd() + "\n"); } @@ -119,6 +123,18 @@ function ensureHookTrusted(config, key, trustedHash) { return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash)); } +function ensureAgentConfig(config, agentConfig) { + const header = `agents.${tomlKeySegment(agentConfig.name)}`; + const section = findTomlSection(config, header); + const configFile = JSON.stringify(agentConfig.configFile); + if (!section) return appendBlock(config, `[${header}]\nconfig_file = ${configFile}\n`); + return replaceOrInsertSetting(config, section, "config_file", configFile); +} + +function tomlKeySegment(value) { + return /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value); +} + function removeTomlSections(config, shouldRemove) { return splitTomlSections(config) .filter((section) => section.header === null || !shouldRemove(section.header)) diff --git a/src/cli/install-codex/codex-config-toml.test.ts b/src/cli/install-codex/codex-config-toml.test.ts index 856453753..9cd56c9f3 100644 --- a/src/cli/install-codex/codex-config-toml.test.ts +++ b/src/cli/install-codex/codex-config-toml.test.ts @@ -41,6 +41,11 @@ describe("codex-config-toml", () => { }, pluginNames: ["omo"], trustedHookStates: [{ key: "omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }], + agentConfigs: [ + { name: "explorer", configFile: "./agents/explorer.toml" }, + { name: "librarian", configFile: "./agents/librarian.toml" }, + { name: "plan", configFile: "./agents/plan.toml" }, + ], }) await updateCodexConfig({ configPath, @@ -53,6 +58,11 @@ describe("codex-config-toml", () => { }, pluginNames: ["omo"], trustedHookStates: [{ key: "omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:abc" }], + agentConfigs: [ + { name: "explorer", configFile: "./agents/explorer.toml" }, + { name: "librarian", configFile: "./agents/librarian.toml" }, + { name: "plan", configFile: "./agents/plan.toml" }, + ], }) // then @@ -66,8 +76,47 @@ describe("codex-config-toml", () => { expect(content).toContain('ref = "main"') expect(content).toContain("[plugins.\"omo@sisyphuslabs\"]") expect(content).toContain("[hooks.state.\"omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0\"]") + expect(content).toContain("[agents.explorer]") + expect(content).toContain('config_file = "./agents/explorer.toml"') + expect(content).toContain("[agents.librarian]") + expect(content).toContain('config_file = "./agents/librarian.toml"') + expect(content).toContain("[agents.plan]") + expect(content).toContain('config_file = "./agents/plan.toml"') expect(content).not.toContain("[marketplaces.lazycodex]") expect(content).not.toContain("code-yeongyu-codex-plugins") expect(content).not.toContain('source_type = "local"') }) + + test("repairs existing agent config_file entries without dropping descriptions", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-agents-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + "[agents.explorer]", + 'description = "existing description"', + 'config_file = "./agents/stale-explorer.toml"', + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + agentConfigs: [{ name: "explorer", configFile: "./agents/explorer.toml" }], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain("[agents.explorer]") + expect(content).toContain('description = "existing description"') + expect(content).toContain('config_file = "./agents/explorer.toml"') + expect(content).not.toContain("stale-explorer") + expect(content).not.toContain("ref = undefined") + }) }) diff --git a/src/cli/install-codex/codex-config-toml.ts b/src/cli/install-codex/codex-config-toml.ts index d3b7aa168..c2a09d5da 100644 --- a/src/cli/install-codex/codex-config-toml.ts +++ b/src/cli/install-codex/codex-config-toml.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import { dirname } from "node:path" -import type { CodexMarketplaceSource, TrustedHookState } from "./types" +import type { CodexAgentConfig, CodexMarketplaceSource, TrustedHookState } from "./types" const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const @@ -11,6 +11,7 @@ export async function updateCodexConfig(input: { readonly marketplaceSource: CodexMarketplaceSource readonly pluginNames: readonly string[] readonly trustedHookStates?: readonly TrustedHookState[] + readonly agentConfigs?: readonly CodexAgentConfig[] }): Promise { await mkdir(dirname(input.configPath), { recursive: true }) let config = "" @@ -33,6 +34,9 @@ export async function updateCodexConfig(input: { for (const state of input.trustedHookStates ?? []) { config = ensureHookTrusted(config, state.key, state.trustedHash) } + for (const agentConfig of input.agentConfigs ?? []) { + config = ensureAgentConfig(config, agentConfig) + } await writeFile(input.configPath, `${config.trimEnd()}\n`) } @@ -78,14 +82,17 @@ function ensureFeatureEnabled(config: string, featureName: string): string { function ensureMarketplaceBlock(config: string, marketplaceName: string, source: CodexMarketplaceSource): string { const header = `marketplaces.${marketplaceName}` - const block = [ + const lines = [ `[${header}]`, `last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`, `source_type = ${JSON.stringify(source.sourceType)}`, `source = ${JSON.stringify(source.source)}`, - `ref = ${JSON.stringify(source.ref)}`, - "", - ].join("\n") + ] + if (source.sourceType === "git") { + lines.push(`ref = ${JSON.stringify(source.ref)}`) + } + lines.push("") + const block = lines.join("\n") const section = findTomlSection(config, header) if (section) return config.slice(0, section.start) + block + config.slice(section.end) return appendBlock( @@ -108,6 +115,18 @@ function ensureHookTrusted(config: string, key: string, trustedHash: string): st return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash)) } +function ensureAgentConfig(config: string, agentConfig: CodexAgentConfig): string { + const header = `agents.${tomlKeySegment(agentConfig.name)}` + const section = findTomlSection(config, header) + const configFile = JSON.stringify(agentConfig.configFile) + if (!section) return appendBlock(config, `[${header}]\nconfig_file = ${configFile}\n`) + return replaceOrInsertSetting(config, section, "config_file", configFile) +} + +function tomlKeySegment(value: string): string { + return /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value) +} + function removeTomlSections(config: string, shouldRemove: (header: string) => boolean): string { return splitTomlSections(config) .filter((section) => section.header === null || !shouldRemove(section.header)) diff --git a/src/cli/install-codex/install-codex.test.ts b/src/cli/install-codex/install-codex.test.ts index 03f719425..da5e4c818 100644 --- a/src/cli/install-codex/install-codex.test.ts +++ b/src/cli/install-codex/install-codex.test.ts @@ -5,9 +5,50 @@ import { describe, expect, test } from "bun:test" import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { runCodexInstaller } from "./install-codex" +import { resolveCodexInstallerBinDir, runCodexInstaller } from "./install-codex" describe("install-codex", () => { + test("#given default CODEX_HOME #when resolving installer bin dir without override #then preserves user local bin precedence", () => { + // given + const homeDir = join(tmpdir(), "omo-codex-home-default") + const codexHome = join(homeDir, ".codex") + + // when + const binDir = resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir }) + + // then + expect(binDir).toBe(join(homeDir, ".local", "bin")) + }) + + test("#given custom CODEX_HOME #when resolving installer bin dir without override #then keeps generated omo inside that Codex home", () => { + // given + const homeDir = join(tmpdir(), "omo-codex-home-custom") + const codexHome = join(tmpdir(), "omo-codex-install-custom") + + // when + const binDir = resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir }) + + // then + expect(binDir).toBe(join(codexHome, "bin")) + }) + + test("#given explicit CODEX_LOCAL_BIN_DIR #when resolving installer bin dir #then preserves installed omo precedence", () => { + // given + const homeDir = join(tmpdir(), "omo-codex-home-explicit") + const codexHome = join(tmpdir(), "omo-codex-install-explicit") + const explicitBinDir = join(tmpdir(), "omo-codex-explicit-bin") + + // when + const binDir = resolveCodexInstallerBinDir({ + codexHome, + env: { CODEX_LOCAL_BIN_DIR: explicitBinDir }, + homeDir, + }) + + // then + expect(binDir).toBe(explicitBinDir) + }) + test("installs vendored plugin into codex home and stays idempotent", async () => { // given const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-")) @@ -32,6 +73,12 @@ describe("install-codex", () => { expect(configContent).toContain('ref = "main"') expect(configContent).toContain("[plugins.\"omo@sisyphuslabs\"]") expect(configContent).toContain("[hooks.state.") + expect(configContent).toContain("[agents.explorer]") + expect(configContent).toContain('config_file = "./agents/explorer.toml"') + expect(configContent).toContain("[agents.librarian]") + expect(configContent).toContain('config_file = "./agents/librarian.toml"') + expect(configContent).toContain("[agents.plan]") + expect(configContent).toContain('config_file = "./agents/plan.toml"') expect(configContent).not.toContain("code-yeongyu-codex-plugins") expect(configContent).not.toContain("[marketplaces.lazycodex]") @@ -40,6 +87,9 @@ describe("install-codex", () => { expect(pluginPath).toContain(join("plugins", "cache", "sisyphuslabs", "omo")) const stats = await stat(pluginPath ?? "") expect(stats.isDirectory()).toBe(true) + expect((await stat(join(codexHome, "agents", "explorer.toml"))).isFile()).toBe(true) + expect((await stat(join(codexHome, "agents", "librarian.toml"))).isFile()).toBe(true) + expect((await stat(join(codexHome, "agents", "plan.toml"))).isFile()).toBe(true) await expect(stat(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo"))).rejects.toThrow() }) }) diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index 31e143e40..ec9d341c3 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -19,7 +19,7 @@ const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plu export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise { const repoRoot = resolve(options.repoRoot ?? findRepoRootFromImporter(import.meta.dir)) const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex")) - const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin")) + const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env: process.env }) const runCommand = options.runCommand ?? defaultRunCommand const log = options.log ?? (() => undefined) @@ -29,6 +29,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom }) const installed: InstalledPlugin[] = [] + const agentConfigs = new Map() for (const entry of marketplace.plugins) { const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" }) const manifest = await readPluginManifest(sourcePath) @@ -58,6 +59,8 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot: plugin.path }) for (const link of agentLinks) { log(`Linked agent ${link.name} -> ${link.target}`) + const agentName = agentNameFromToml(link.name) + agentConfigs.set(agentName, { name: agentName, configFile: `./agents/${link.name}` }) } installed.push(plugin) } @@ -95,6 +98,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom marketplaceSource: LAZYCODEX_MARKETPLACE_SOURCE, pluginNames: marketplace.plugins.map((plugin) => plugin.name), trustedHookStates, + agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), }) await trackCodexInstallTelemetry() @@ -107,6 +111,26 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom } } +export function resolveCodexInstallerBinDir(input: { + readonly binDir?: string + readonly codexHome: string + readonly env?: { readonly [key: string]: string | undefined } + readonly homeDir?: string +}): string { + const explicitBinDir = input.binDir ?? input.env?.CODEX_LOCAL_BIN_DIR + if (explicitBinDir !== undefined && explicitBinDir.trim().length > 0) return resolve(explicitBinDir) + + const homeDir = input.homeDir ?? homedir() + const defaultCodexHome = resolve(homeDir, ".codex") + const resolvedCodexHome = resolve(input.codexHome) + if (resolvedCodexHome !== defaultCodexHome) return join(resolvedCodexHome, "bin") + return resolve(homeDir, ".local", "bin") +} + +function agentNameFromToml(fileName: string): string { + return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName +} + function legacyCacheMarketplaces(marketplaceName: string): readonly string[] { return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : [] } diff --git a/src/cli/install-codex/types.ts b/src/cli/install-codex/types.ts index f4e4e5510..10a84c050 100644 --- a/src/cli/install-codex/types.ts +++ b/src/cli/install-codex/types.ts @@ -30,10 +30,20 @@ export interface TrustedHookState { readonly trustedHash: string } -export interface CodexMarketplaceSource { - readonly sourceType: "git" - readonly source: string - readonly ref: string +export type CodexMarketplaceSource = + | { + readonly sourceType: "git" + readonly source: string + readonly ref: string + } + | { + readonly sourceType: "local" + readonly source: string + } + +export interface CodexAgentConfig { + readonly name: string + readonly configFile: string } export interface CommandRunOptions {