From a49acecc3a1533cb057af73ba6642865ec3cbbb6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 11:18:01 +0900 Subject: [PATCH] feat(omo-codex): link bundled component agents into codex home Discover component .toml agent files and symlink (or copy on Windows) them into the Codex agents dir during install, recording an installed agent manifest and wiring agent config_file entries into config.toml. Add CodexAgentConfig type and support local marketplace source. --- package.json | 2 +- .../scripts/install-agent-links.test.mjs | 60 +++++++++++++ packages/omo-codex/scripts/install-local.mjs | 37 +++++++- .../omo-codex/scripts/install-local.test.mjs | 61 +++++++++++++- .../scripts/install-test-fixtures.mjs | 4 + packages/omo-codex/scripts/install/agents.mjs | 84 +++++++++++++++++++ packages/omo-codex/scripts/install/config.mjs | 16 ++++ .../install-codex/codex-config-toml.test.ts | 49 +++++++++++ src/cli/install-codex/codex-config-toml.ts | 29 +++++-- src/cli/install-codex/install-codex.test.ts | 52 +++++++++++- src/cli/install-codex/install-codex.ts | 26 +++++- src/cli/install-codex/types.ts | 18 +++- 12 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 packages/omo-codex/scripts/install-agent-links.test.mjs create mode 100644 packages/omo-codex/scripts/install/agents.mjs 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 {