From 0d7c16a042b055360ec1a87a7642f0c9834dd039 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 16:10:46 +0900 Subject: [PATCH] refactor(omo-codex): install agent TOMLs via symlinks at install time, drop sync-agents.py The Python SessionStart hook (sync-agents.py) was a runtime side-effect that copied agent TOMLs into CODEX_HOME/agents on every session start. That design had three problems: 1. It was a Python script invoked via 'python3 ${PLUGIN_ROOT}/...' which is fragile on Windows where the binary may be 'python', and is the wrong layer for a one-shot install task. 2. Agent TOMLs landed as regular file copies, with no provenance link to the plugin cache and no tracking for clean uninstall. 3. An older release shipped TOMLs without the required 'name' field; because the current bundle no longer ships them, the hook never had a chance to overwrite the broken copies on disk, leaving Codex permanently warning at session start. Replace the runtime hook with an install-time linker: linkCachedPluginAgents() (src/cli/install-codex/link-cached-plugin-agents.ts). The omo-codex CLI now calls it right after linkCachedPluginBins(). For each 'components/*/agents/*.toml' in the plugin cache, it: - Linux / macOS: creates a symlink at ${CODEX_HOME}/agents/ pointing at the cache TOML. The cache directory is the single source of truth; removing the cache cleanly breaks the link. - Windows: copies the file (symlinks require admin or Developer Mode). - Both platforms: writes a '.installed-agents.json' manifest under the plugin cache listing the installed absolute paths, so a future 'omo uninstall --platform=codex' can remove them deterministically. Stale regular-file copies (from the old sync-agents.py) are removed and replaced on Unix. On Windows the existing copy is overwritten. Tests (src/cli/install-codex/link-cached-plugin-agents.test.ts): 9 cross-platform tests that mock the 'platform' parameter to exercise the Linux, macOS, and Windows code paths in a single 'bun test' run, matching the existing pattern from linkCachedPluginBins. Covers symlink creation, Windows copy, stale-file replacement, manifest writing, idempotency, multi-component discovery, and the empty-bundle edge case. Removed: - packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py - packages/omo-codex/plugin/test/bundled-agents.test.mjs (it tested the Python hook; behaviour is now covered by the TS tests) - SessionStart hook entry in both ultrawork and aggregate hooks.json - 2 sync-agents tests + 1 manifest assertion in ultrawork-hooks.test.mjs - 'hooks/sync-agents.py' in ultrawork/package.json files list - sync-agents.py reference from aggregate.test.mjs component markers Updated: - components/ultrawork/README.md, AGENTS.md: describe the install-time linker as the source of truth, no more SessionStart agent sync. Verified end-to-end: bun run src/cli/index.ts install --no-tui --platform=codex ls -la ~/.codex/agents/ # all 4 TOMLs are symlinks pointing to cache cat ~/.codex/plugins/cache/.../omo/0.1.0/.installed-agents.json # manifest present --- .../plugin/components/ultrawork/AGENTS.md | 5 +- .../plugin/components/ultrawork/README.md | 23 +-- .../components/ultrawork/hooks/hooks.json | 11 -- .../components/ultrawork/hooks/sync-agents.py | 85 -------- .../ultrawork/hooks/ultrawork-hooks.test.mjs | 61 +----- .../plugin/components/ultrawork/package.json | 1 - packages/omo-codex/plugin/hooks/hooks.json | 9 - .../omo-codex/plugin/test/aggregate.test.mjs | 1 - .../plugin/test/bundled-agents.test.mjs | 129 ------------ src/cli/install-codex/install-codex.ts | 5 + .../link-cached-plugin-agents.test.ts | 183 ++++++++++++++++++ .../link-cached-plugin-agents.ts | 95 +++++++++ 12 files changed, 295 insertions(+), 313 deletions(-) delete mode 100644 packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py delete mode 100644 packages/omo-codex/plugin/test/bundled-agents.test.mjs create mode 100644 src/cli/install-codex/link-cached-plugin-agents.test.ts create mode 100644 src/cli/install-codex/link-cached-plugin-agents.ts diff --git a/packages/omo-codex/plugin/components/ultrawork/AGENTS.md b/packages/omo-codex/plugin/components/ultrawork/AGENTS.md index b46c9128d..9bc71b7f4 100644 --- a/packages/omo-codex/plugin/components/ultrawork/AGENTS.md +++ b/packages/omo-codex/plugin/components/ultrawork/AGENTS.md @@ -12,9 +12,8 @@ Conventions for human contributors and AI agents working on this repository. ## Layout - `hooks/ultrawork-detector.py` — pure stdlib `UserPromptSubmit` hook. Reads JSON on stdin, writes the directive to stdout when the keyword matches, exits 0 otherwise. -- `hooks/sync-agents.py` — pure stdlib `SessionStart` hook. Copies bundled `agents/*.toml` into `CODEX_HOME/agents`, exits 0. -- `agents/*.toml` — bundled Codex agent role files. -- `hooks/hooks.json` — registers hook scripts. +- `agents/*.toml` — bundled Codex agent role files. Installed into `CODEX_HOME/agents/` by `src/cli/install-codex/link-cached-plugin-agents.ts` at install time (symlink on Unix, copy on Windows). No runtime `SessionStart` hook is involved. +- `hooks/hooks.json` — registers the prompt-detector hook only. - `.codex-plugin/plugin.json` — Codex plugin manifest. Marketplace metadata lives here, not in `package.json`. ## Constraints diff --git a/packages/omo-codex/plugin/components/ultrawork/README.md b/packages/omo-codex/plugin/components/ultrawork/README.md index 5c13cce0e..b44b599ba 100644 --- a/packages/omo-codex/plugin/components/ultrawork/README.md +++ b/packages/omo-codex/plugin/components/ultrawork/README.md @@ -1,6 +1,8 @@ # codex-ultrawork -Codex plugin that injects a compact orchestration directive (the **ultrawork** prompt) when the user prompt contains `ultrawork` or `ulw` (word-bounded, case-insensitive). It also syncs the bundled `codex-ultrawork-reviewer` agent role into `CODEX_HOME/agents` on `SessionStart`. +Codex plugin that injects a compact orchestration directive (the **ultrawork** prompt) when the user prompt contains `ultrawork` or `ulw` (word-bounded, case-insensitive). + +Bundled Codex agent role TOMLs in `agents/` are installed into `CODEX_HOME/agents/` by the omo-codex installer (`linkCachedPluginAgents`, in `src/cli/install-codex/link-cached-plugin-agents.ts`). Install-time linking uses symlinks on Linux / macOS and file copies on Windows. There is no Python `SessionStart` hook anymore. ## What the injected directive enforces @@ -22,7 +24,7 @@ codex plugin marketplace add /path/to/codex-plugins node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins ``` -The installer copies the plugin into `~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0`, enables it in `~/.codex/config.toml`, and registers the `UserPromptSubmit` and `SessionStart` hooks. +The installer copies the plugin into `~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0`, enables it in `~/.codex/config.toml`, registers the `UserPromptSubmit` hook, and installs the bundled agent TOMLs into `~/.codex/agents/` (symlinks on Unix, copies on Windows). A manifest at `/.installed-agents.json` records the installed paths for clean uninstall. ## How it works @@ -34,13 +36,7 @@ python3 ${PLUGIN_ROOT}/hooks/ultrawork-detector.py Codex passes the prompt payload on stdin. When the pattern `\b(?:ultrawork|ulw)\b` (case-insensitive) matches, the hook writes the directive to stdout — Codex injects non-JSON stdout as `additional_context` for the next turn. Otherwise the hook writes nothing and exits 0. Malformed input also exits 0 to never block the turn. -It also registers a `SessionStart` hook running: - -``` -python3 ${PLUGIN_ROOT}/hooks/sync-agents.py -``` - -That hook copies bundled `agents/*.toml` files into `CODEX_HOME/agents`. It writes nothing on success and exits 0 even on malformed input. +Bundled agent role TOMLs in `agents/` ship to `CODEX_HOME/agents/` at install time, not via a runtime hook. The installer creates a symlink on Linux / macOS (so the cache directory is the single source of truth and removal of the cache cleanly breaks the link) and a file copy on Windows (because symlinks require admin privileges or Developer Mode). Both code paths overwrite stale files and write a `.installed-agents.json` manifest under the plugin cache for clean uninstall tracking. ## Smoke test @@ -53,12 +49,7 @@ Expect `` ... directive body. ## Agent role smoke test -```bash -CODEX_HOME="$(mktemp -d)" -echo '{"hook_event_name":"SessionStart"}' | CODEX_HOME="$CODEX_HOME" python3 hooks/sync-agents.py -``` - -Expect `CODEX_HOME/agents/codex-ultrawork-reviewer.toml` to exist. +Run `bunx omo install --platform=codex`, then inspect `~/.codex/agents/`. On Linux / macOS you should see symlinks; on Windows you should see file copies. Each TOML should declare a non-empty `name`, `description`, and `developer_instructions`. ## License @@ -66,4 +57,4 @@ MIT. See `LICENSE`. ## Privacy -This plugin only reads local hook payloads, emits the bundled directive text on keyword match, and syncs bundled agent TOML files locally. It does not perform network requests or telemetry. +This plugin only reads local hook payloads and emits the bundled directive text on keyword match. Bundled agent TOML files ship to `CODEX_HOME/agents/` at install time. No network calls and no telemetry from this component. diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json b/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json index 6c8f00133..af24adb7c 100644 --- a/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +++ b/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json @@ -1,16 +1,5 @@ { "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 \"${PLUGIN_ROOT}/hooks/sync-agents.py\"", - "timeout": 5 - } - ] - } - ], "UserPromptSubmit": [ { "hooks": [ diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py b/packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py deleted file mode 100644 index 55398e20d..000000000 --- a/packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import os -import sys -from pathlib import Path -from typing import Final, cast - - -AGENTS_DIR: Final = "agents" -SESSION_START_EVENT: Final = "SessionStart" - - -def _load_payload() -> dict[str, object] | None: - try: - raw = sys.stdin.read() - except (OSError, ValueError): - return None - if not raw.strip(): - return None - try: - parsed = cast(object, json.loads(raw)) - except json.JSONDecodeError: - return None - if not isinstance(parsed, dict): - return None - values = cast(dict[object, object], parsed) - return {str(k): v for k, v in values.items()} - - -def _should_sync(payload: dict[str, object]) -> bool: - return payload.get("hook_event_name") == SESSION_START_EVENT - - -def _plugin_root() -> Path: - env_root = os.environ.get("PLUGIN_ROOT") - if env_root: - root = Path(env_root).expanduser().resolve() - if root.joinpath(AGENTS_DIR).is_dir(): - return root - return Path(__file__).resolve().parents[1] - - -def _codex_home() -> Path: - env_home = os.environ.get("CODEX_HOME") - if env_home: - return Path(env_home).expanduser().resolve() - return Path.home().joinpath(".codex") - - -def _copy_agent_file(source: Path, target: Path) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - if target.is_symlink() or target.is_file(): - target.unlink() - elif target.exists(): - raise IsADirectoryError(target) - _ = target.write_bytes(source.read_bytes()) - - -def _sync_agents(plugin_root: Path, codex_home: Path) -> None: - source_dir = plugin_root / AGENTS_DIR - if not source_dir.is_dir(): - return - - target_dir = codex_home / AGENTS_DIR - for source in sorted(source_dir.rglob("*.toml")): - if not source.is_file(): - continue - target = target_dir / source.relative_to(source_dir) - _copy_agent_file(source, target) - - -def main() -> None: - try: - payload = _load_payload() - if payload is not None and _should_sync(payload): - _sync_agents(_plugin_root(), _codex_home()) - except Exception as err: # noqa: BLE001 - hook boundary must never block turns. - _ = sys.stderr.write(f"codex-ultrawork agent sync failed: {err}\n") - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs b/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs index 04180ecf6..dc3328915 100644 --- a/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs +++ b/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { lstat, mkdtemp, readFile, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -9,11 +8,6 @@ import test from "node:test"; const hookDir = dirname(fileURLToPath(import.meta.url)); const pluginRoot = dirname(hookDir); const detectorPath = join(hookDir, "ultrawork-detector.py"); -const syncAgentsPath = join(hookDir, "sync-agents.py"); - -async function makeTempDir() { - return mkdtemp(join(tmpdir(), "codex-ultrawork-")); -} async function runPython(scriptPath, input, env = {}) { return new Promise((resolve, reject) => { @@ -41,52 +35,6 @@ async function runPython(scriptPath, input, env = {}) { }); } -test("#given session start #when sync hook runs #then installs bundled reviewer agent", async () => { - const codexHome = await makeTempDir(); - try { - const result = await runPython( - syncAgentsPath, - '{"hook_event_name":"SessionStart"}', - { CODEX_HOME: codexHome }, - ); - - assert.equal(result.code, 0); - assert.equal(result.signal, null); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - - const targetPath = join(codexHome, "agents", "codex-ultrawork-reviewer.toml"); - const targetStat = await lstat(targetPath); - assert.equal(targetStat.isFile(), true); - assert.equal(targetStat.isSymbolicLink(), false); - const syncedAgent = await readFile(targetPath, "utf8"); - assert.match(syncedAgent, /^name = "codex-ultrawork-reviewer"$/m); - assert.match(syncedAgent, /^model = "gpt-5.2"$/m); - assert.match(syncedAgent, /^model_reasoning_effort = "xhigh"$/m); - assert.match(syncedAgent, /^developer_instructions = """/m); - } finally { - await rm(codexHome, { recursive: true, force: true }); - } -}); - -test("#given malformed session payload #when sync hook runs #then exits zero without output", async () => { - const codexHome = await makeTempDir(); - try { - const result = await runPython(syncAgentsPath, "{", { CODEX_HOME: codexHome }); - - assert.equal(result.code, 0); - assert.equal(result.signal, null); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - await assert.rejects( - stat(join(codexHome, "agents", "codex-ultrawork-reviewer.toml")), - /code: 'ENOENT'|ENOENT/, - ); - } finally { - await rm(codexHome, { recursive: true, force: true }); - } -}); - test("#given ultrawork prompt #when detector runs #then emits directive", async () => { const payload = JSON.stringify({ hook_event_name: "UserPromptSubmit", @@ -199,17 +147,14 @@ test("#given identifier-like ulw #when detector runs #then does not emit directi assert.equal(result.stderr, ""); }); -test("#given hook manifest #when read #then registers prompt and session hooks", async () => { +test("#given hook manifest #when read #then registers only the prompt detector hook", async () => { const manifest = JSON.parse(await readFile(join(hookDir, "hooks.json"), "utf8")); assert.match( manifest.hooks.UserPromptSubmit[0].hooks[0].command, /ultrawork-detector\.py/, ); - assert.match( - manifest.hooks.SessionStart[0].hooks[0].command, - /sync-agents\.py/, - ); + assert.equal(manifest.hooks.SessionStart, undefined); assert.equal(pluginRoot.endsWith("components/ultrawork"), true); }); diff --git a/packages/omo-codex/plugin/components/ultrawork/package.json b/packages/omo-codex/plugin/components/ultrawork/package.json index 24d484330..501548f86 100644 --- a/packages/omo-codex/plugin/components/ultrawork/package.json +++ b/packages/omo-codex/plugin/components/ultrawork/package.json @@ -26,7 +26,6 @@ "files": [ "agents", "hooks/hooks.json", - "hooks/sync-agents.py", "hooks/ultrawork-detector.py", "README.md", "LICENSE", diff --git a/packages/omo-codex/plugin/hooks/hooks.json b/packages/omo-codex/plugin/hooks/hooks.json index e2ac12cc1..d93a5d634 100644 --- a/packages/omo-codex/plugin/hooks/hooks.json +++ b/packages/omo-codex/plugin/hooks/hooks.json @@ -11,15 +11,6 @@ } ] }, - { - "hooks": [ - { - "type": "command", - "command": "python3 \"${PLUGIN_ROOT}/components/ultrawork/hooks/sync-agents.py\"", - "timeout": 5 - } - ] - }, { "hooks": [ { diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index 1d78273bd..8adc56363 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -47,7 +47,6 @@ test("#given isolated components #when hooks are inspected #then commands stay i "components/rules/dist/cli.js", "components/telemetry/dist/cli.js", "components/ultragoal/dist/cli.js", - "components/ultrawork/hooks/sync-agents.py", "components/ultrawork/hooks/ultrawork-detector.py", ]; diff --git a/packages/omo-codex/plugin/test/bundled-agents.test.mjs b/packages/omo-codex/plugin/test/bundled-agents.test.mjs deleted file mode 100644 index 6ecc2872a..000000000 --- a/packages/omo-codex/plugin/test/bundled-agents.test.mjs +++ /dev/null @@ -1,129 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { lstat, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const testDir = dirname(fileURLToPath(import.meta.url)); -const pluginRoot = dirname(testDir); -const componentRoot = join(pluginRoot, "components", "ultrawork"); -const syncAgentsPath = join(componentRoot, "hooks", "sync-agents.py"); - -async function makeTempDir() { - return mkdtemp(join(tmpdir(), "codex-bundled-agents-")); -} - -async function runSyncHook(codexHome) { - return new Promise((resolve, reject) => { - const child = spawn("python3", [syncAgentsPath], { - env: { ...process.env, CODEX_HOME: codexHome }, - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.once("error", reject); - child.once("close", (code) => resolve({ code, stdout, stderr })); - child.stdin.end('{"hook_event_name":"SessionStart"}'); - }); -} - -test("#given session start #when sync hook runs #then bundles explorer agent", async () => { - const codexHome = await makeTempDir(); - try { - const result = await runSyncHook(codexHome); - assert.equal(result.code, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - - const target = join(codexHome, "agents", "explorer.toml"); - const targetStat = await lstat(target); - assert.equal(targetStat.isFile(), true); - assert.equal(targetStat.isSymbolicLink(), false); - - const content = await readFile(target, "utf8"); - assert.match(content, /^name = "explorer"$/m); - assert.match(content, /^model = /m); - assert.match(content, /^model_reasoning_effort = /m); - assert.match(content, /^developer_instructions = """/m); - assert.match(content, /codebase search specialist/i); - } finally { - await rm(codexHome, { recursive: true, force: true }); - } -}); - -test("#given session start #when sync hook runs #then bundles librarian agent", async () => { - const codexHome = await makeTempDir(); - try { - const result = await runSyncHook(codexHome); - assert.equal(result.code, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - - const target = join(codexHome, "agents", "librarian.toml"); - const targetStat = await lstat(target); - assert.equal(targetStat.isFile(), true); - assert.equal(targetStat.isSymbolicLink(), false); - - const content = await readFile(target, "utf8"); - assert.match(content, /^name = "librarian"$/m); - assert.match(content, /^model = /m); - assert.match(content, /^model_reasoning_effort = /m); - assert.match(content, /^developer_instructions = """/m); - assert.match(content, /THE LIBRARIAN/); - } finally { - await rm(codexHome, { recursive: true, force: true }); - } -}); - -test("#given session start #when sync hook runs #then bundles plan agent into CODEX_HOME/agents", async () => { - const codexHome = await makeTempDir(); - try { - const result = await runSyncHook(codexHome); - assert.equal(result.code, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - - const target = join(codexHome, "agents", "plan.toml"); - const targetStat = await lstat(target); - assert.equal(targetStat.isFile(), true); - assert.equal(targetStat.isSymbolicLink(), false); - - const content = await readFile(target, "utf8"); - assert.match(content, /^name = "plan"$/m); - assert.match(content, /^model = /m); - assert.match(content, /^model_reasoning_effort = /m); - assert.match(content, /^developer_instructions = """/m); - assert.match(content, /strategic planning consultant/i); - } finally { - await rm(codexHome, { recursive: true, force: true }); - } -}); - -test("#given session start #when sync hook runs #then installs exactly the expected bundled set", async () => { - const codexHome = await makeTempDir(); - try { - await runSyncHook(codexHome); - const entries = await readdir(join(codexHome, "agents"), { withFileTypes: true }); - const names = entries - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .sort(); - assert.deepEqual(names, [ - "codex-ultrawork-reviewer.toml", - "explorer.toml", - "librarian.toml", - "plan.toml", - ]); - } finally { - await rm(codexHome, { recursive: true, force: true }); - } -}); diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index 3a7019d25..bddab476e 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -4,6 +4,7 @@ import { existsSync } from "node:fs" import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./codex-cache" import { updateCodexConfig } from "./codex-config-toml" import { trustedHookStatesForPlugin } from "./codex-hook-trust" +import { linkCachedPluginAgents } from "./link-cached-plugin-agents" import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace" import { defaultRunCommand } from "./codex-process" import type { CodexInstallOptions, CodexInstallResult, InstalledPlugin } from "./types" @@ -47,6 +48,10 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom for (const link of links) { log(`Linked ${link.name} -> ${link.target}`) } + const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot: plugin.path }) + for (const link of agentLinks) { + log(`Linked agent ${link.name} -> ${link.target}`) + } installed.push(plugin) } diff --git a/src/cli/install-codex/link-cached-plugin-agents.test.ts b/src/cli/install-codex/link-cached-plugin-agents.test.ts new file mode 100644 index 000000000..6b1c2e693 --- /dev/null +++ b/src/cli/install-codex/link-cached-plugin-agents.test.ts @@ -0,0 +1,183 @@ +/// +/// + +import { describe, expect, test } from "bun:test" +import { lstat, mkdir, mkdtemp, readdir, readFile, readlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { linkCachedPluginAgents } from "./link-cached-plugin-agents" + +async function makeFixture(): Promise<{ codexHome: string; pluginRoot: string }> { + const root = await mkdtemp(join(tmpdir(), "omo-codex-agents-")) + const codexHome = join(root, "codex") + const pluginRoot = join(root, "plugin") + await mkdir(join(pluginRoot, "components", "ultrawork", "agents"), { recursive: true }) + await mkdir(join(pluginRoot, "components", "ultragoal", "agents"), { recursive: true }) + 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", "ultragoal", "agents", "planner.toml"), + 'name = "planner"\n', + ) + return { codexHome, pluginRoot } +} + +describe("linkCachedPluginAgents", () => { + test("creates symlinks on linux that point at the bundled TOMLs", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + + // when + const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + + // then + expect(linked.map((entry) => entry.name).sort()).toEqual([ + "explorer.toml", + "librarian.toml", + "planner.toml", + ]) + for (const entry of linked) { + const linkStat = await lstat(entry.path) + expect(linkStat.isSymbolicLink()).toBe(true) + expect(await readlink(entry.path)).toBe(entry.target) + } + }) + + test("creates symlinks on darwin (macOS)", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + + // when + const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "darwin" }) + + // then + expect(linked).toHaveLength(3) + for (const entry of linked) { + expect((await lstat(entry.path)).isSymbolicLink()).toBe(true) + } + }) + + test("creates regular file copies on Windows (no symlinks)", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + + // when + const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "win32" }) + + // then + expect(linked).toHaveLength(3) + for (const entry of linked) { + const linkStat = await lstat(entry.path) + expect(linkStat.isSymbolicLink()).toBe(false) + expect(linkStat.isFile()).toBe(true) + const content = await readFile(entry.path, "utf8") + expect(content).toContain(`name = "${entry.name.replace(/\.toml$/, "")}"`) + } + }) + + test("replaces stale regular files (legacy sync-agents.py copies) with symlinks on unix", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + const agentsDir = join(codexHome, "agents") + await mkdir(agentsDir, { recursive: true }) + await writeFile( + join(agentsDir, "explorer.toml"), + "# stale broken copy with no `name` field, from old sync-agents.py\nmodel = \"old\"\n", + ) + + // when + await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + + // then + const linkStat = await lstat(join(agentsDir, "explorer.toml")) + expect(linkStat.isSymbolicLink()).toBe(true) + expect(await readlink(join(agentsDir, "explorer.toml"))).toBe( + join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"), + ) + }) + + test("overwrites stale copies on Windows", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + const agentsDir = join(codexHome, "agents") + await mkdir(agentsDir, { recursive: true }) + await writeFile(join(agentsDir, "explorer.toml"), "# stale broken copy\n") + + // when + await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "win32" }) + + // then + const content = await readFile(join(agentsDir, "explorer.toml"), "utf8") + expect(content).toContain('name = "explorer"') + expect(content).not.toContain("stale broken copy") + }) + + test("writes a manifest under the plugin cache listing installed agent paths for clean uninstall", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + + // when + await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + + // then + const manifestContent = await readFile(join(pluginRoot, ".installed-agents.json"), "utf8") + const manifest = JSON.parse(manifestContent) as { agents: string[] } + expect(manifest.agents.sort()).toEqual([ + join(codexHome, "agents", "explorer.toml"), + join(codexHome, "agents", "librarian.toml"), + join(codexHome, "agents", "planner.toml"), + ]) + }) + + test("is idempotent across re-runs", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + + // when + await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + + // then + expect(linked).toHaveLength(3) + const entries = (await readdir(join(codexHome, "agents"))).sort() + expect(entries).toEqual(["explorer.toml", "librarian.toml", "planner.toml"]) + }) + + test("discovers TOMLs across multiple component agent directories", async () => { + // given + const { codexHome, pluginRoot } = await makeFixture() + + // when + const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + + // then + const targets = linked.map((entry) => entry.target).sort() + expect(targets).toContain(join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml")) + expect(targets).toContain(join(pluginRoot, "components", "ultragoal", "agents", "planner.toml")) + }) + + test("returns empty list when plugin has no bundled agents", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-agents-empty-")) + const codexHome = join(root, "codex") + const pluginRoot = join(root, "plugin") + await mkdir(pluginRoot, { recursive: true }) + + // when + const linked = await linkCachedPluginAgents({ codexHome, pluginRoot, platform: "linux" }) + + // then + expect(linked).toEqual([]) + const manifest = JSON.parse( + await readFile(join(pluginRoot, ".installed-agents.json"), "utf8"), + ) as { agents: string[] } + expect(manifest.agents).toEqual([]) + }) +}) diff --git a/src/cli/install-codex/link-cached-plugin-agents.ts b/src/cli/install-codex/link-cached-plugin-agents.ts new file mode 100644 index 000000000..2cc3557e2 --- /dev/null +++ b/src/cli/install-codex/link-cached-plugin-agents.ts @@ -0,0 +1,95 @@ +import { copyFile, lstat, mkdir, readdir, rm, symlink, writeFile } from "node:fs/promises" +import { basename, join } from "node:path" + +const MANIFEST_FILE = ".installed-agents.json" + +export interface LinkedAgent { + readonly name: string + readonly path: string + readonly target: string +} + +type LinkPlatform = NodeJS.Platform + +export async function linkCachedPluginAgents(input: { + readonly codexHome: string + readonly pluginRoot: string + readonly platform?: LinkPlatform +}): Promise { + const platform = input.platform ?? process.platform + const bundledAgents = await discoverBundledAgents(input.pluginRoot) + if (bundledAgents.length === 0) { + await writeManifest(input.pluginRoot, []) + return [] + } + const agentsDir = join(input.codexHome, "agents") + await mkdir(agentsDir, { recursive: true }) + const linked: LinkedAgent[] = [] + 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( + input.pluginRoot, + linked.map((entry) => entry.path), + ) + return linked +} + +async function discoverBundledAgents(pluginRoot: string): Promise { + const componentsRoot = join(pluginRoot, "components") + if (!(await exists(componentsRoot))) return [] + const componentEntries = await readdir(componentsRoot, { withFileTypes: true }) + const agents: string[] = [] + 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: string, target: string): Promise { + await prepareReplacement(linkPath) + await symlink(target, linkPath) +} + +async function replaceWithCopy(linkPath: string, target: string): Promise { + await prepareReplacement(linkPath) + await copyFile(target, linkPath) +} + +async function prepareReplacement(linkPath: string): Promise { + if (!(await exists(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: string, agentPaths: readonly string[]): Promise { + const manifestPath = join(pluginRoot, MANIFEST_FILE) + const payload = { agents: [...agentPaths].sort() } + await writeFile(manifestPath, `${JSON.stringify(payload, null, "\t")}\n`) +} + +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch { + return false + } +}