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 + } +}