diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31bd1c771..ce84223fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,12 +33,20 @@ jobs: fi test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 with: submodules: recursive + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Build lsp-tools-mcp submodule run: npm ci && npm run build working-directory: packages/lsp-tools-mcp @@ -61,12 +69,20 @@ jobs: run: bun test typecheck: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 with: submodules: recursive + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Build lsp-tools-mcp submodule run: npm ci && npm run build working-directory: packages/lsp-tools-mcp @@ -91,9 +107,41 @@ jobs: - name: Type check script tooling run: bun run typecheck:script + codex-compatibility: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.12" + + - uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + run: bun install --frozen-lockfile + env: + BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi" + + - name: Run Codex compatibility tests + run: bun run test:codex + build: runs-on: ubuntu-latest - needs: [test, typecheck] + needs: [test, typecheck, codex-compatibility] permissions: contents: write steps: @@ -159,15 +207,17 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.3.12" - name: Generate release notes id: notes run: | NOTES=$(bun run script/generate-changelog.ts) - echo "notes<> $GITHUB_OUTPUT - echo "$NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + { + echo "notes<> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/cli/install-codex/codex-cache-bundled-mcps.ts b/src/cli/install-codex/codex-cache-bundled-mcps.ts new file mode 100644 index 000000000..73299f32f --- /dev/null +++ b/src/cli/install-codex/codex-cache-bundled-mcps.ts @@ -0,0 +1,90 @@ +import { cp, mkdir, readFile, stat } from "node:fs/promises" +import { dirname, join, resolve } from "node:path" + +interface BundledMcpRuntime { + readonly label: string + readonly sourceArg: string + readonly sourceDistFromPlugin: string + readonly destinationArg: string + readonly destinationDistFromPlugin: string +} + +const BUNDLED_MCP_RUNTIMES = [ + { + label: "ast-grep MCP", + sourceArg: "../../ast-grep-mcp/dist/cli.js", + sourceDistFromPlugin: "../../ast-grep-mcp/dist", + destinationArg: "./components/ast-grep-mcp/dist/cli.js", + destinationDistFromPlugin: "components/ast-grep-mcp/dist", + }, + { + label: "LSP MCP", + sourceArg: "../../lsp-tools-mcp/dist/cli.js", + sourceDistFromPlugin: "../../lsp-tools-mcp/dist", + destinationArg: "./components/lsp-tools-mcp/dist/cli.js", + destinationDistFromPlugin: "components/lsp-tools-mcp/dist", + }, +] as const satisfies readonly BundledMcpRuntime[] + +export async function copyBundledMcpRuntimeDists(input: { + readonly pluginRoot: string + readonly sourceRoot: string +}): Promise { + const sourceArgs = await readSourceMcpArgs(join(input.sourceRoot, ".mcp.json")) + for (const runtime of BUNDLED_MCP_RUNTIMES) { + if (!sourceArgs.has(runtime.sourceArg)) continue + await copyBundledMcpRuntimeDist(input.pluginRoot, input.sourceRoot, runtime) + } +} + +export function resolveBundledMcpRuntimeArg(pluginRoot: string, arg: string): string | null { + const runtime = BUNDLED_MCP_RUNTIMES.find((candidate) => candidate.sourceArg === arg) + return runtime ? join(pluginRoot, runtime.destinationArg) : null +} + +async function copyBundledMcpRuntimeDist( + pluginRoot: string, + sourceRoot: string, + runtime: BundledMcpRuntime, +): Promise { + const sourcePath = resolve(sourceRoot, runtime.sourceDistFromPlugin) + if (!(await isDirectory(sourcePath))) { + throw new Error(`missing built ${runtime.label} dist at ${sourcePath}`) + } + const destinationPath = join(pluginRoot, runtime.destinationDistFromPlugin) + await mkdir(dirname(destinationPath), { recursive: true }) + await cp(sourcePath, destinationPath, { recursive: true }) +} + +async function readSourceMcpArgs(path: string): Promise> { + let parsed: unknown + try { + parsed = JSON.parse(await readFile(path, "utf8")) + } catch (error) { + if (error instanceof Error) return new Set() + return new Set() + } + + const args = new Set() + if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return args + for (const server of Object.values(parsed.mcpServers)) { + if (!isRecord(server) || !Array.isArray(server.args)) continue + for (const arg of server.args) { + if (typeof arg === "string") args.add(arg) + } + } + return args +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch (error) { + if (error instanceof Error) return false + return false + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-cache-command-shim.ts b/src/cli/install-codex/codex-cache-command-shim.ts new file mode 100644 index 000000000..d1159c8b4 --- /dev/null +++ b/src/cli/install-codex/codex-cache-command-shim.ts @@ -0,0 +1 @@ +export const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer" diff --git a/src/cli/install-codex/codex-cache-legacy-bins.ts b/src/cli/install-codex/codex-cache-legacy-bins.ts new file mode 100644 index 000000000..822599f2c --- /dev/null +++ b/src/cli/install-codex/codex-cache-legacy-bins.ts @@ -0,0 +1,64 @@ +import { lstat, readFile, readlink, rm } from "node:fs/promises" +import { join } from "node:path" +import { COMMAND_SHIM_MARKER } from "./codex-cache-command-shim" + +type LinkPlatform = NodeJS.Platform + +const LEGACY_CODEX_COMPONENT_BINS = [ + { name: "codex-comment-checker", component: "comment-checker" }, + { name: "codex-rules", component: "rules" }, + { name: "codex-start-work-continuation", component: "start-work-continuation" }, + { name: "codex-telemetry", component: "telemetry" }, + { name: "codex-ultrawork", component: "ultrawork" }, +] as const + +type LegacyCodexComponent = (typeof LEGACY_CODEX_COMPONENT_BINS)[number]["component"] + +export async function removeLegacyCodexComponentBins(binDir: string, platform: LinkPlatform): Promise { + for (const entry of LEGACY_CODEX_COMPONENT_BINS) { + const linkPath = join(binDir, platform === "win32" ? `${entry.name}.cmd` : entry.name) + await removeLegacyCodexComponentBin(linkPath, entry.component, platform) + } +} + +async function removeLegacyCodexComponentBin(linkPath: string, component: LegacyCodexComponent, platform: LinkPlatform): Promise { + try { + const stat = await lstat(linkPath) + if (platform !== "win32") { + if (!stat.isSymbolicLink()) return + const target = await readlink(linkPath) + if (isManagedLegacyComponentTarget(target, component)) await rm(linkPath, { force: true }) + return + } + if (!stat.isFile()) return + const content = await readFile(linkPath, "utf8") + if (content.includes(COMMAND_SHIM_MARKER)) await rm(linkPath, { force: true }) + } catch (error) { + if (isNodeErrorWithCode(error) && error.code === "ENOENT") return + throw error + } +} + +function isManagedLegacyComponentTarget(target: string, component: LegacyCodexComponent): boolean { + const parts = target.split(/[\\/]+/) + const suffixStart = parts.length - 4 + const suffix = parts.slice(-4) + return ( + suffix[0] === "components" && + suffix[1] === component && + suffix[2] === "dist" && + suffix[3] === "cli.js" && + hasPluginCachePrefix(parts, suffixStart) + ) +} + +function hasPluginCachePrefix(parts: readonly string[], endExclusive: number): boolean { + for (let index = 0; index < endExclusive - 1; index += 1) { + if (parts[index] === "plugins" && parts[index + 1] === "cache") return true + } + return false +} + +function isNodeErrorWithCode(error: unknown): error is NodeJS.ErrnoException { + return typeof error === "object" && error !== null && "code" in error +} diff --git a/src/cli/install-codex/codex-cache-local-dependencies.ts b/src/cli/install-codex/codex-cache-local-dependencies.ts new file mode 100644 index 000000000..ac4a8b1a4 --- /dev/null +++ b/src/cli/install-codex/codex-cache-local-dependencies.ts @@ -0,0 +1,48 @@ +import { readFile, readdir, writeFile } from "node:fs/promises" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { isPathInside } from "./codex-cache-paths" + +export async function rewriteCachedPackageLocalFileDependencies(pluginRoot: string, sourceRoot: string): Promise { + const packageJsonPaths: string[] = [] + await collectPackageJsonPaths(pluginRoot, pluginRoot, packageJsonPaths) + for (const packageJsonPath of packageJsonPaths) { + const raw = await readFile(packageJsonPath, "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) continue + const packageDir = dirname(packageJsonPath) + const sourcePackageDir = join(sourceRoot, relative(pluginRoot, packageDir)) + let changed = false + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"] as const) { + const dependencies = parsed[field] + if (!isRecord(dependencies)) continue + for (const [name, specifier] of Object.entries(dependencies)) { + if (typeof specifier !== "string" || !specifier.startsWith("file:")) continue + const filePath = specifier.slice("file:".length) + if (filePath.length === 0 || isAbsolute(filePath)) continue + const targetPath = resolve(packageDir, filePath) + if (isPathInside(targetPath, pluginRoot)) continue + dependencies[name] = `file:${resolve(sourcePackageDir, filePath)}` + changed = true + } + } + if (changed) await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`) + } +} + +async function collectPackageJsonPaths(directory: string, root: string, paths: string[]): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { + paths.push(join(directory, "package.json")) + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue + const childPath = join(directory, entry.name) + if (!isPathInside(childPath, root)) continue + await collectPackageJsonPaths(childPath, root, paths) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-cache-paths.ts b/src/cli/install-codex/codex-cache-paths.ts new file mode 100644 index 000000000..64eb98d0d --- /dev/null +++ b/src/cli/install-codex/codex-cache-paths.ts @@ -0,0 +1,12 @@ +import { isAbsolute, relative, resolve } from "node:path" + +export function resolveCachedRuntimePath(pluginRoot: string, sourceRoot: string, runtimePath: string): string { + const targetPath = resolve(pluginRoot, runtimePath) + if (isPathInside(targetPath, pluginRoot)) return targetPath + return resolve(sourceRoot, runtimePath) +} + +export function isPathInside(candidatePath: string, rootPath: string): boolean { + const pathFromRoot = relative(rootPath, candidatePath) + return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)) +} diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts new file mode 100644 index 000000000..00a838f1a --- /dev/null +++ b/src/cli/install-codex/codex-cache.test.ts @@ -0,0 +1,279 @@ +/// +/// + +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readFile, readlink, stat, symlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { installCachedPlugin, linkCachedPluginBins, rewriteCachedMcpManifest } from "./codex-cache" + +describe("codex-cache", () => { + test("rewrites cached mcp manifest relative args and cwd", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + await writeFile( + join(root, ".mcp.json"), + JSON.stringify({ mcpServers: { lsp: { cwd: ".", args: ["./components/lsp/dist/cli.js", "mcp"] } } }), + ) + + // when + await rewriteCachedMcpManifest(root) + + // then + const rewritten = JSON.parse(await readFile(join(root, ".mcp.json"), "utf8")) as { + mcpServers: { lsp: { cwd?: string; args: string[] } } + } + expect(rewritten.mcpServers.lsp.cwd).toBeUndefined() + expect(rewritten.mcpServers.lsp.args[0]).toBe(join(root, "./components/lsp/dist/cli.js")) + }) + + test("rewrites bundled mcp manifest args that point outside the plugin cache into bundled cache paths", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const sourceRoot = join(root, "packages", "omo-codex", "plugin") + const cacheRoot = join(root, "cache", "omo") + await mkdir(cacheRoot, { recursive: true }) + await writeFile( + join(cacheRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + ast_grep: { cwd: ".", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"] }, + custom: { args: ["/usr/local/bin/custom-mcp", "--stdio"] }, + lsp: { cwd: ".", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"] }, + }, + }), + ) + + // when + await rewriteCachedMcpManifest(cacheRoot, sourceRoot) + + // then + const rewritten = JSON.parse(await readFile(join(cacheRoot, ".mcp.json"), "utf8")) as { + mcpServers: { + ast_grep: { cwd?: string; args: string[] } + custom: { args: string[] } + lsp: { cwd?: string; args: string[] } + } + } + expect(Object.keys(rewritten.mcpServers).sort()).toEqual(["ast_grep", "custom", "lsp"]) + expect(rewritten.mcpServers.ast_grep.cwd).toBeUndefined() + expect(rewritten.mcpServers.ast_grep.args[0]).toBe(join(cacheRoot, "components", "ast-grep-mcp", "dist", "cli.js")) + expect(rewritten.mcpServers.custom.args).toEqual(["/usr/local/bin/custom-mcp", "--stdio"]) + expect(rewritten.mcpServers.lsp.cwd).toBeUndefined() + expect(rewritten.mcpServers.lsp.args[0]).toBe(join(cacheRoot, "components", "lsp-tools-mcp", "dist", "cli.js")) + }) + + test("rewrites cached package file dependencies that point outside the plugin cache back to the source package", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const codexHome = join(root, "codex-home") + const sourceRoot = join(root, "packages", "omo-codex", "plugin") + await mkdir(sourceRoot, { recursive: true }) + await writeFile( + join(sourceRoot, "package.json"), + JSON.stringify({ + name: "@scope/omo", + version: "0.1.0", + dependencies: { "@scope/lsp-tools": "file:../lsp-tools-mcp" }, + }), + ) + + // when + const installed = await installCachedPlugin({ + codexHome, + marketplaceName: "debug", + name: "omo", + sourcePath: sourceRoot, + version: "0.1.0", + runCommand: async () => undefined, + }) + + // then + const cachedPackageJson = JSON.parse(await readFile(join(installed.path, "package.json"), "utf8")) as { + dependencies: Record + } + expect(cachedPackageJson.dependencies["@scope/lsp-tools"]).toBe(`file:${join(root, "packages", "omo-codex", "lsp-tools-mcp")}`) + }) + + test("#given source plugin has a stale npm lockfile #when caching plugin #then lockfile is regenerated rather than copied", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-lockfile-")) + const codexHome = join(root, "codex-home") + const sourceRoot = join(root, "plugin") + await mkdir(sourceRoot, { recursive: true }) + await writeFile(join(sourceRoot, "package.json"), JSON.stringify({ name: "@scope/omo", version: "0.1.0" })) + await writeFile(join(sourceRoot, "package-lock.json"), '{"packages":{"components/ulw-loop":{}}}\n') + + // when + const installed = await installCachedPlugin({ + codexHome, + marketplaceName: "debug", + name: "omo", + sourcePath: sourceRoot, + version: "0.1.0", + runCommand: async () => undefined, + }) + + // then + await expect(stat(join(installed.path, "package-lock.json"))).rejects.toThrow() + }) + + test("#given source plugin has built component runtimes #when caching plugin #then component dist files are preserved for hooks", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-component-dist-")) + const codexHome = join(root, "codex-home") + const sourceRoot = join(root, "plugin") + const componentRoot = join(sourceRoot, "components", "rules") + await mkdir(join(componentRoot, "dist"), { recursive: true }) + await writeFile(join(sourceRoot, "package.json"), JSON.stringify({ name: "@scope/omo", version: "0.1.0" })) + await writeFile(join(componentRoot, "package.json"), JSON.stringify({ name: "@scope/rules", bin: { "omo-rules": "dist/cli.js" } })) + await writeFile(join(componentRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + + // when + const installed = await installCachedPlugin({ + codexHome, + marketplaceName: "debug", + name: "omo", + sourcePath: sourceRoot, + version: "0.1.0", + runCommand: async () => undefined, + }) + + // then + expect((await stat(join(installed.path, "components", "rules", "dist", "cli.js"))).isFile()).toBe(true) + }) + + test("links cached plugin bins and stays idempotent", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + await mkdir(pluginRoot, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-hook": "dist/cli.js" } })) + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + + // when + const first = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }) + const second = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }) + + // then + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + const linkedTarget = await readlink(join(binDir, "omo-hook")) + expect(linkedTarget).toBe(join(pluginRoot, "dist", "cli.js")) + }) + + test("#given legacy codex-prefixed component symlinks #when linking cached plugin bins #then removes stale managed symlinks without touching user files", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-legacy-bins-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + const oldTarget = join(root, "codex-home", "plugins", "cache", "legacy-market", "omo", "0.0.1", "components", "rules", "dist", "cli.js") + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await mkdir(join(root, "codex-home", "plugins", "cache", "legacy-market", "omo", "0.0.1", "components", "rules", "dist"), { recursive: true }) + await mkdir(binDir, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-rules": "dist/cli.js" } })) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + await writeFile(oldTarget, "#!/usr/bin/env node\n") + await symlink(oldTarget, join(binDir, "codex-rules")) + await writeFile(join(binDir, "codex-comment-checker"), "user managed file\n") + + // when + await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }) + + // then + await expect(readlink(join(binDir, "codex-rules"))).rejects.toThrow() + expect(await readFile(join(binDir, "codex-comment-checker"), "utf8")).toBe("user managed file\n") + expect(await readlink(join(binDir, "omo-rules"))).toBe(join(pluginRoot, "dist", "cli.js")) + }) + + test("#given user-owned codex-prefixed symlink #when linking cached plugin bins #then preserves the user symlink", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-user-symlink-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + const userTarget = join(root, "user-tools", "codex-rules") + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await mkdir(join(root, "user-tools"), { recursive: true }) + await mkdir(binDir, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-rules": "dist/cli.js" } })) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + await writeFile(userTarget, "#!/usr/bin/env node\n") + await symlink(userTarget, join(binDir, "codex-rules")) + + // when + await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }) + + // then + expect(await readlink(join(binDir, "codex-rules"))).toBe(userTarget) + expect(await readlink(join(binDir, "omo-rules"))).toBe(join(pluginRoot, "dist", "cli.js")) + }) + + test("#given user-owned codex symlink with component-like target #when linking cached plugin bins #then preserves the user symlink", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-user-component-symlink-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + const userTarget = join(root, "workspace", "components", "rules", "dist", "cli.js") + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await mkdir(join(root, "workspace", "components", "rules", "dist"), { recursive: true }) + await mkdir(binDir, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-rules": "dist/cli.js" } })) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + await writeFile(userTarget, "#!/usr/bin/env node\n") + await symlink(userTarget, join(binDir, "codex-rules")) + + // when + await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }) + + // then + expect(await readlink(join(binDir, "codex-rules"))).toBe(userTarget) + expect(await readlink(join(binDir, "omo-rules"))).toBe(join(pluginRoot, "dist", "cli.js")) + }) + + test("writes Windows command shims for cached plugin bins", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + await mkdir(pluginRoot, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-hook": "dist/cli.js" } })) + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + + // when + const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }) + + // then + expect(linked).toEqual([{ name: "omo-hook", path: join(binDir, "omo-hook.cmd"), target: join(pluginRoot, "dist", "cli.js") }]) + const commandShim = await readFile(join(binDir, "omo-hook.cmd"), "utf8") + expect(commandShim).toContain("@echo off") + expect(commandShim).toContain(`node "${join(pluginRoot, "dist", "cli.js")}" %*`) + }) + + test("rejects existing non-generated Windows command shims", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + await mkdir(pluginRoot, { recursive: true }) + await mkdir(binDir, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-hook": "dist/cli.js" } })) + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + await writeFile(join(binDir, "omo-hook.cmd"), "@echo off\r\necho custom\r\n") + + // when + let rejected = false + try { + await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }) + } catch (error) { + rejected = error instanceof Error && error.message.includes("already exists and is not a generated command shim") + } + + // then + expect(rejected).toBe(true) + expect(await readFile(join(binDir, "omo-hook.cmd"), "utf8")).toContain("echo custom") + }) +}) diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts new file mode 100644 index 000000000..1a6b20b29 --- /dev/null +++ b/src/cli/install-codex/codex-cache.ts @@ -0,0 +1,243 @@ +import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises" +import { basename, dirname, join, sep } from "node:path" +import { copyBundledMcpRuntimeDists, resolveBundledMcpRuntimeArg } from "./codex-cache-bundled-mcps" +import { COMMAND_SHIM_MARKER } from "./codex-cache-command-shim" +import { removeLegacyCodexComponentBins } from "./codex-cache-legacy-bins" +import { rewriteCachedPackageLocalFileDependencies } from "./codex-cache-local-dependencies" +import { resolveCachedRuntimePath } from "./codex-cache-paths" +import type { InstalledPlugin, RunCommand } from "./types" + +type LinkPlatform = NodeJS.Platform + +export async function installCachedPlugin(input: { + readonly codexHome: string + readonly marketplaceName: string + readonly name: string + readonly sourcePath: string + readonly version: string + readonly runCommand: RunCommand +}): Promise { + await maybeRunNpmInstall(input.sourcePath, input.runCommand) + await maybeRunNpmBuild(input.sourcePath, input.runCommand) + + const targetPath = join(input.codexHome, "plugins", "cache", input.marketplaceName, input.name, input.version) + await replaceDirectory(input.sourcePath, targetPath) + await rewriteCachedPackageLocalFileDependencies(targetPath, input.sourcePath) + await copyBundledMcpRuntimeDists({ pluginRoot: targetPath, sourceRoot: input.sourcePath }) + await maybeRunNpmInstall(targetPath, input.runCommand, ["install", "--omit=dev"]) + await rewriteCachedMcpManifest(targetPath, input.sourcePath) + return { name: input.name, version: input.version, path: targetPath } +} + +export async function pruneMarketplaceCache(input: { + readonly codexHome: string + readonly marketplaceName: string + readonly keepPluginNames: readonly string[] +}): Promise { + const cacheRoot = join(input.codexHome, "plugins", "cache", input.marketplaceName) + if (!(await exists(cacheRoot))) return + const keep = new Set(input.keepPluginNames) + const entries = await readdir(cacheRoot, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || keep.has(entry.name)) continue + await rm(join(cacheRoot, entry.name), { recursive: true, force: true }) + } +} + +export async function pruneMarketplacePluginCaches(input: { + readonly codexHome: string + readonly marketplaceName: string + readonly pluginNames: readonly string[] +}): Promise { + const cacheRoot = join(input.codexHome, "plugins", "cache", input.marketplaceName) + if (!(await exists(cacheRoot))) return + for (const pluginName of input.pluginNames) { + await rm(join(cacheRoot, pluginName), { recursive: true, force: true }) + } + if ((await readdir(cacheRoot)).length === 0) { + await rm(cacheRoot, { recursive: true, force: true }) + } +} + +export async function linkCachedPluginBins(input: { + readonly binDir: string + readonly pluginRoot: string + readonly platform?: LinkPlatform +}): Promise { + const binLinks = await discoverPackageBins(input.pluginRoot) + const platform = input.platform ?? process.platform + await mkdir(input.binDir, { recursive: true }) + await removeLegacyCodexComponentBins(input.binDir, platform) + const linked: Array<{ name: string; path: string; target: string }> = [] + for (const link of binLinks) { + const linkPath = await linkCachedPluginBin(input.binDir, link, platform) + linked.push({ name: link.name, path: linkPath, target: link.target }) + } + return linked +} + +async function linkCachedPluginBin( + binDir: string, + link: { readonly name: string; readonly target: string }, + platform: LinkPlatform, +): Promise { + if (platform === "win32") { + const linkPath = join(binDir, `${link.name}.cmd`) + await replaceCommandShim(linkPath, link.target) + return linkPath + } + + const linkPath = join(binDir, link.name) + await replaceSymlink(linkPath, link.target) + return linkPath +} + +export async function rewriteCachedMcpManifest(pluginRoot: string, sourceRoot = pluginRoot): Promise { + const manifestPath = join(pluginRoot, ".mcp.json") + if (!(await exists(manifestPath))) return + const raw = await readFile(manifestPath, "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return + let changed = false + for (const server of Object.values(parsed.mcpServers)) { + if (!isRecord(server)) continue + if (server.cwd === "." || server.cwd === "./") { + delete server.cwd + changed = true + } + const currentArgs = server.args + if (!Array.isArray(currentArgs)) continue + const nextArgs = currentArgs.map((arg) => { + if (typeof arg !== "string") return arg + const bundledMcpRuntimeArg = resolveBundledMcpRuntimeArg(pluginRoot, arg) + if (bundledMcpRuntimeArg !== null) return bundledMcpRuntimeArg + if (arg.startsWith("./") || arg.startsWith("../")) return resolveCachedRuntimePath(pluginRoot, sourceRoot, arg) + return arg + }) + if (nextArgs.some((value, index) => value !== currentArgs[index])) { + server.args = nextArgs + changed = true + } + } + if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`) +} + +async function maybeRunNpmInstall(cwd: string, runCommand: RunCommand, args: readonly string[] = ["install"]): Promise { + if (!(await exists(join(cwd, "package.json")))) return + await runCommand("npm", args, { cwd }) +} + +async function maybeRunNpmBuild(cwd: string, runCommand: RunCommand): Promise { + if (!(await exists(join(cwd, "package.json")))) return + const packageJson: unknown = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) + if (!isRecord(packageJson)) return + const scripts = packageJson.scripts + if (!isRecord(scripts) || typeof scripts.build !== "string") return + await runCommand("npm", ["run", "build"], { cwd }) +} + +async function replaceDirectory(sourcePath: string, targetPath: string): Promise { + await mkdir(dirname(targetPath), { recursive: true }) + const tempPath = join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`) + await rm(tempPath, { recursive: true, force: true }) + await cp(sourcePath, tempPath, { recursive: true, filter: (source) => shouldCopyPluginPath(source, sourcePath) }) + await rm(targetPath, { recursive: true, force: true }) + await rename(tempPath, targetPath) +} + +async function discoverPackageBins(root: string): Promise { + const links: Array<{ name: string; target: string }> = [] + await collectPackageBins(root, root, links) + return links +} + +async function collectPackageBins(directory: string, root: string, links: Array<{ name: string; target: string }>): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { + await appendPackageBinLinks(join(directory, "package.json"), directory, links) + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue + const childPath = join(directory, entry.name) + if (!childPath.startsWith(root)) continue + await collectPackageBins(childPath, root, links) + } +} + +async function appendPackageBinLinks(packageJsonPath: string, packageRoot: string, links: Array<{ name: string; target: string }>): Promise { + const packageJson: unknown = JSON.parse(await readFile(packageJsonPath, "utf8")) + if (!isRecord(packageJson)) return + const packageName = packageJson.name + const packageBin = packageJson.bin + if (typeof packageBin === "string" && typeof packageName === "string") { + links.push({ name: basename(packageName), target: join(packageRoot, packageBin) }) + return + } + if (!isRecord(packageBin)) return + for (const [name, target] of Object.entries(packageBin)) { + if (typeof target !== "string") continue + links.push({ name, target: join(packageRoot, target) }) + } +} + +async function replaceSymlink(linkPath: string, targetPath: string): Promise { + if (await existingNonSymlink(linkPath)) throw new Error(`${linkPath} already exists and is not a symlink`) + await rm(linkPath, { force: true }) + await symlink(targetPath, linkPath) +} + +async function replaceCommandShim(linkPath: string, targetPath: string): Promise { + if (await existingNonShim(linkPath)) throw new Error(`${linkPath} already exists and is not a command shim`) + await writeFile(linkPath, `@echo off\r\n${COMMAND_SHIM_MARKER}\r\nnode "${targetPath}" %*\r\n`) +} + +async function existingNonShim(path: string): Promise { + try { + const stat = await lstat(path) + if (!stat.isFile()) return true + const content = await readFile(path, "utf8") + if (content.includes(COMMAND_SHIM_MARKER)) return false + throw new Error(`${path} already exists and is not a generated command shim`) + } catch (error) { + if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false + throw error + } +} + +async function existingNonSymlink(path: string): Promise { + try { + const stat = await lstat(path) + if (!stat.isSymbolicLink()) return true + await readlink(path) + return false + } catch (error) { + if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false + throw error + } +} + +function shouldCopyPluginPath(path: string, root: string): boolean { + const relative = path === root ? "" : path.slice(root.length + sep.length) + if (relative === "") return true + const parts = relative.split(sep) + if (parts[parts.length - 1] === "package-lock.json") return false + return !parts.some((part) => part === ".git" || part === "node_modules") +} + +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch { + return false + } +} + +function isNodeErrorWithCode(error: unknown): error is NodeJS.ErrnoException { + return typeof error === "object" && error !== null && "code" in error +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-config-agent-cleanup.test.ts b/src/cli/install-codex/codex-config-agent-cleanup.test.ts new file mode 100644 index 000000000..97822b7da --- /dev/null +++ b/src/cli/install-codex/codex-config-agent-cleanup.test.ts @@ -0,0 +1,53 @@ +/// +/// + +import { describe, expect, test } from "bun:test" +import { mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { updateCodexConfig } from "./codex-config-toml" + +describe("codex config managed agent cleanup", () => { + test("#given stale managed OMO agent sections #when updating with current agent links #then removes missing managed roles", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-stale-agents-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + "[agents.explorer]", + 'config_file = "./agents/old-explorer.toml"', + "", + "[agents.metis]", + 'config_file = "./agents/metis.toml"', + "", + "[agents.user_custom]", + 'config_file = "./agents/user-custom.toml"', + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { + sourceType: "git", + source: "https://github.com/code-yeongyu/lazycodex.git", + ref: "main", + }, + pluginNames: ["omo"], + agentConfigs: [{ name: "explorer", configFile: "./agents/explorer.toml" }], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain("[agents.explorer]") + expect(content).toContain('config_file = "./agents/explorer.toml"') + expect(content).not.toContain("[agents.metis]") + expect(content).not.toContain('config_file = "./agents/metis.toml"') + expect(content).toContain("[agents.user_custom]") + expect(content).toContain('config_file = "./agents/user-custom.toml"') + }) +}) diff --git a/src/cli/install-codex/codex-config-toml.test.ts b/src/cli/install-codex/codex-config-toml.test.ts new file mode 100644 index 000000000..65d792dde --- /dev/null +++ b/src/cli/install-codex/codex-config-toml.test.ts @@ -0,0 +1,267 @@ +/// +/// + +import { describe, expect, test } from "bun:test" +import { mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { updateCodexConfig } from "./codex-config-toml" + +describe("codex-config-toml", () => { + test("#given autonomous permissions requested #when updating config #then enables full Codex autonomy", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-autonomous-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + 'approval_policy = "on-request"', + 'sandbox_mode = "workspace-write"', + "network_access = \"disabled\"", + "", + "[notice]", + "hide_full_access_warning = false", + "hide_world_writable_warning = false", + "hide_rate_limit_model_nudge = true", + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + autonomousPermissions: true, + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain('approval_policy = "never"') + expect(content).toContain('sandbox_mode = "danger-full-access"') + expect(content).toContain('network_access = "enabled"') + expect(content).toContain("[notice]") + expect(content).toContain("hide_full_access_warning = true") + expect(content).toContain("hide_world_writable_warning = true") + expect(content).toContain("hide_rate_limit_model_nudge = true") + expect(content).not.toContain('approval_policy = "on-request"') + expect(content).not.toContain('sandbox_mode = "workspace-write"') + }) + + test("#given empty Codex config #when updating config #then enables MultiAgentV2 with ten thousand session threads", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-multi-agent-")) + const configPath = join(root, "config.toml") + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain("[features.multi_agent_v2]") + expect(content).toContain("enabled = true") + expect(content).toContain("max_concurrent_threads_per_session = 10000") + }) + + test("#given existing MultiAgentV2 table #when updating config #then preserves unrelated tuning while setting ten thousand session threads", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-multi-agent-existing-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + "[features.multi_agent_v2]", + "enabled = false", + "usage_hint_enabled = false", + "max_concurrent_threads_per_session = 4", + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain("[features.multi_agent_v2]") + expect(content).toContain("enabled = true") + expect(content).toContain("usage_hint_enabled = false") + expect(content).toContain("max_concurrent_threads_per_session = 10000") + expect(content).not.toContain("max_concurrent_threads_per_session = 4") + }) + + test("#given legacy boolean MultiAgentV2 flag and table #when updating config #then normalizes to table config", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-multi-agent-legacy-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + "[features]", + "multi_agent_v2 = true", + "plugins = false", + "", + "[features.multi_agent_v2]", + "usage_hint_enabled = false", + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).not.toMatch(/^multi_agent_v2\s*=/m) + expect(content).toContain("[features.multi_agent_v2]") + expect(content).toContain("enabled = true") + expect(content).toContain("usage_hint_enabled = false") + expect(content).toContain("max_concurrent_threads_per_session = 10000") + }) + + test("writes config blocks and stays idempotent", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + "[marketplaces.code-yeongyu-codex-plugins]", + 'last_updated = "2026-05-01T00:00:00Z"', + 'source_type = "git"', + 'source = "https://github.com/code-yeongyu/codex-plugins.git"', + "", + '[plugins."omo@code-yeongyu-codex-plugins"]', + "enabled = true", + "", + '[plugins."omo@code-yeongyu-codex-plugins".mcp_servers.lsp]', + "enabled = true", + "", + '[hooks.state."omo@code-yeongyu-codex-plugins:hooks/hooks.json:post_tool_use:0:0"]', + 'trusted_hash = "sha256:old"', + "", + "[marketplaces.lazycodex]", + 'last_updated = "2026-05-10T00:00:00Z"', + 'source_type = "local"', + 'source = "/tmp/stale-lazycodex-cache"', + "", + '[plugins."omo@lazycodex"]', + "enabled = true", + "", + '[hooks.state."omo@lazycodex:hooks/hooks.json:post_tool_use:0:0"]', + 'trusted_hash = "sha256:stale"', + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { + sourceType: "local", + source: "/repo/packages/omo-codex/cache/sisyphuslabs", + }, + 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, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { + sourceType: "local", + source: "/repo/packages/omo-codex/cache/sisyphuslabs", + }, + 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 + const content = await readFile(configPath, "utf8") + expect(content).toContain("[features]") + expect(content).toContain("plugins = true") + expect(content).toContain("plugin_hooks = true") + expect(content).toContain("[marketplaces.sisyphuslabs]") + expect(content).toContain('source_type = "local"') + expect(content).toContain('source = "/repo/packages/omo-codex/cache/sisyphuslabs"') + expect(content).not.toContain('source = "https://github.com/code-yeongyu/lazycodex.git"') + expect(content).not.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("omo@lazycodex") + expect(content).not.toContain("/tmp/stale-lazycodex-cache") + expect(content).not.toContain("code-yeongyu-codex-plugins") + }) + + 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 new file mode 100644 index 000000000..325fb7b65 --- /dev/null +++ b/src/cli/install-codex/codex-config-toml.ts @@ -0,0 +1,277 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { dirname } from "node:path" +import { ensureCodexMultiAgentV2Config } from "./codex-multi-agent-v2-config" +import { appendBlock, findTomlSection, replaceOrInsertSetting } from "./toml-section-editor" +import type { CodexAgentConfig, CodexMarketplaceSource, TrustedHookState } from "./types" + +const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const +const MANAGED_CODEX_AGENT_NAMES = [ + "codex-ultrawork-reviewer", + "explorer", + "librarian", + "metis", + "momus", + "plan", +] as const + +export async function updateCodexConfig(input: { + readonly configPath: string + readonly repoRoot: string + readonly marketplaceName: string + readonly marketplaceSource: CodexMarketplaceSource + readonly pluginNames: readonly string[] + readonly trustedHookStates?: readonly TrustedHookState[] + readonly agentConfigs?: readonly CodexAgentConfig[] + readonly autonomousPermissions?: boolean +}): Promise { + await mkdir(dirname(input.configPath), { recursive: true }) + let config = "" + if (await exists(input.configPath)) config = await readFile(input.configPath, "utf8") + + const pluginSet = new Set(input.pluginNames) + for (const legacyMarketplaceName of legacyMarketplaceNames(input.marketplaceName)) { + config = removeMarketplaceBlock(config, legacyMarketplaceName) + config = removeStaleMarketplacePluginBlocks(config, legacyMarketplaceName, new Set()) + config = removeStaleMarketplaceHookStateBlocks(config, legacyMarketplaceName, new Set()) + } + config = removeStaleMarketplacePluginBlocks(config, input.marketplaceName, pluginSet) + config = removeStaleMarketplaceHookStateBlocks(config, input.marketplaceName, pluginSet) + config = removeStaleManagedAgentBlocks( + config, + new Set((input.agentConfigs ?? []).map((agentConfig) => agentConfig.name)), + ) + config = ensureFeatureEnabled(config, "plugins") + config = ensureFeatureEnabled(config, "plugin_hooks") + config = ensureCodexMultiAgentV2Config(config) + if (input.autonomousPermissions === true) config = ensureAutonomousPermissions(config) + config = ensureMarketplaceBlock(config, input.marketplaceName, input.marketplaceSource) + for (const pluginName of input.pluginNames) { + config = ensurePluginEnabled(config, `${pluginName}@${input.marketplaceName}`) + } + 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`) +} + +function legacyMarketplaceNames(marketplaceName: string): readonly string[] { + return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_MARKETPLACES : [] +} + +function removeMarketplaceBlock(config: string, marketplaceName: string): string { + return removeTomlSections(config, (header) => header === `marketplaces.${marketplaceName}`) +} + +function removeStaleMarketplacePluginBlocks(config: string, marketplaceName: string, keepPluginNames: Set): string { + return removeTomlSections(config, (header) => { + const pluginKey = parsePluginHeaderKey(header) + if (pluginKey === null) return false + const suffix = `@${marketplaceName}` + if (!pluginKey.endsWith(suffix)) return false + return !keepPluginNames.has(pluginKey.slice(0, -suffix.length)) + }) +} + +function removeStaleMarketplaceHookStateBlocks(config: string, marketplaceName: string, keepPluginNames: Set): string { + return removeTomlSections(config, (header) => { + const prefix = "hooks.state." + if (!header.startsWith(prefix)) return false + const hookKey = parseJsonString(header.slice(prefix.length)) + if (hookKey === null) return false + const separator = hookKey.indexOf(":") + if (separator === -1) return false + const pluginKey = hookKey.slice(0, separator) + const suffix = `@${marketplaceName}` + if (!pluginKey.endsWith(suffix)) return false + return !keepPluginNames.has(pluginKey.slice(0, -suffix.length)) + }) +} + +function removeStaleManagedAgentBlocks(config: string, keepAgentNames: Set): string { + const managedAgentNames = new Set(MANAGED_CODEX_AGENT_NAMES) + return splitTomlSections(config) + .filter((section) => { + if (section.header === null) return true + const agentName = parseAgentHeaderName(section.header) + if (agentName === null || !managedAgentNames.has(agentName) || keepAgentNames.has(agentName)) return true + return !section.text.includes(`config_file = ${JSON.stringify(`./agents/${agentName}.toml`)}`) + }) + .map((section) => section.text) + .join("") + .replace(/\n{3,}/g, "\n\n") +} + +function ensureFeatureEnabled(config: string, featureName: string): string { + const section = findTomlSection(config, "features") + if (!section) return appendBlock(config, `[features]\n${featureName} = true\n`) + return replaceOrInsertSetting(config, section, featureName, "true") +} + +function ensureAutonomousPermissions(config: string): string { + let next = replaceOrInsertRootSetting(config, "approval_policy", JSON.stringify("never")) + next = replaceOrInsertRootSetting(next, "sandbox_mode", JSON.stringify("danger-full-access")) + next = replaceOrInsertRootSetting(next, "network_access", JSON.stringify("enabled")) + next = ensureNoticeEnabled(next, "hide_full_access_warning") + return ensureNoticeEnabled(next, "hide_world_writable_warning") +} + +function ensureNoticeEnabled(config: string, key: string): string { + const section = findTomlSection(config, "notice") + if (!section) return appendBlock(config, `[notice]\n${key} = true\n`) + return replaceOrInsertSetting(config, section, key, "true") +} + +function replaceOrInsertRootSetting(config: string, key: string, value: string): string { + const sectionStart = findFirstTableStart(config) + const root = config.slice(0, sectionStart) + const suffix = config.slice(sectionStart) + const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m") + const replacement = linePattern.test(root) + ? root.replace(linePattern, `${key} = ${value}`) + : `${root.trimEnd()}${root.trimEnd().length > 0 ? "\n" : ""}${key} = ${value}\n` + if (suffix.length === 0) return replacement + return `${replacement.trimEnd()}\n\n${suffix.trimStart()}` +} + +function findFirstTableStart(config: string): number { + const match = config.match(/^[[].*$/m) + return match?.index ?? config.length +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function ensureMarketplaceBlock(config: string, marketplaceName: string, source: CodexMarketplaceSource): string { + const header = `marketplaces.${marketplaceName}` + const lines = [ + `[${header}]`, + `last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`, + `source_type = ${JSON.stringify(source.sourceType)}`, + `source = ${JSON.stringify(source.source)}`, + ] + 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( + config, + block, + ) +} + +function ensurePluginEnabled(config: string, pluginKey: string): string { + const header = `plugins.${JSON.stringify(pluginKey)}` + const section = findTomlSection(config, header) + if (!section) return appendBlock(config, `[${header}]\nenabled = true\n`) + return replaceOrInsertSetting(config, section, "enabled", "true") +} + +function ensureHookTrusted(config: string, key: string, trustedHash: string): string { + const header = `hooks.state.${JSON.stringify(key)}` + const section = findTomlSection(config, header) + if (!section) return appendBlock(config, `[${header}]\ntrusted_hash = ${JSON.stringify(trustedHash)}\n`) + 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)) + .map((section) => section.text) + .join("") + .replace(/\n{3,}/g, "\n\n") +} + +function splitTomlSections(config: string): Array<{ header: string | null; text: string }> { + const lines = config.match(/[^\n]*\n?|$/g) ?? [] + const sections: Array<{ header: string | null; text: string }> = [] + let current: { header: string | null; text: string } = { header: null, text: "" } + for (const line of lines) { + if (line.length === 0) break + const header = parseTomlHeader(line) + if (header !== null) { + if (current.text.length > 0) sections.push(current) + current = { header, text: line } + } else { + current.text += line + } + } + if (current.text.length > 0) sections.push(current) + return sections +} + +function parseTomlHeader(line: string): string | null { + const trimmed = line.trim() + if (!trimmed.startsWith("[") || !trimmed.endsWith("]") || trimmed.startsWith("[[")) return null + return trimmed.slice(1, -1) +} + +function parsePluginHeaderKey(header: string): string | null { + const prefix = "plugins." + if (!header.startsWith(prefix)) return null + return parseLeadingJsonString(header.slice(prefix.length)) +} + +function parseAgentHeaderName(header: string): string | null { + const prefix = "agents." + if (!header.startsWith(prefix)) return null + const key = header.slice(prefix.length) + return key.startsWith('"') ? parseLeadingJsonString(key) : key +} + +function parseLeadingJsonString(value: string): string | null { + if (!value.startsWith('"')) return parseJsonString(value) + let escaped = false + for (let index = 1; index < value.length; index += 1) { + const char = value[index] + if (escaped) { + escaped = false + continue + } + if (char === "\\") { + escaped = true + continue + } + if (char === '"') return parseJsonString(value.slice(0, index + 1)) + } + return null +} + +function parseJsonString(value: string): string | null { + try { + const parsed: unknown = JSON.parse(value) + return typeof parsed === "string" ? parsed : null + } catch (error) { + if (error instanceof Error) return null + return null + } +} + +async function exists(path: string): Promise { + try { + await readFile(path, "utf8") + return true + } catch (error) { + if (error instanceof Error) return false + return false + } +} diff --git a/src/cli/install-codex/codex-hook-trust.test.ts b/src/cli/install-codex/codex-hook-trust.test.ts new file mode 100644 index 000000000..3e9a11f09 --- /dev/null +++ b/src/cli/install-codex/codex-hook-trust.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { join } from "node:path" +import { trustedHookStatesForPlugin } from "./codex-hook-trust" + +describe("codex-hook-trust", () => { + test("computes trusted hook hashes for vendored plugin", async () => { + // given + const pluginRoot = join( + "/Users/yeongyu/local-workspaces/omodex", + "packages", + "omo-codex", + "plugin", + ) + + // when + const states = await trustedHookStatesForPlugin({ + marketplaceName: "sisyphuslabs", + pluginName: "omo", + pluginRoot, + }) + + // then + expect(states.length).toBeGreaterThan(0) + expect(states[0]?.trustedHash.startsWith("sha256:")).toBe(true) + }) +}) diff --git a/src/cli/install-codex/codex-hook-trust.ts b/src/cli/install-codex/codex-hook-trust.ts new file mode 100644 index 000000000..8432de86a --- /dev/null +++ b/src/cli/install-codex/codex-hook-trust.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import type { TrustedHookState } from "./types" + +const EVENT_LABELS = new Map([ + ["PreToolUse", "pre_tool_use"], + ["PermissionRequest", "permission_request"], + ["PostToolUse", "post_tool_use"], + ["PreCompact", "pre_compact"], + ["PostCompact", "post_compact"], + ["SessionStart", "session_start"], + ["UserPromptSubmit", "user_prompt_submit"], + ["SubagentStart", "subagent_start"], + ["SubagentStop", "subagent_stop"], + ["Stop", "stop"], +]) + +export async function trustedHookStatesForPlugin(input: { + readonly marketplaceName: string + readonly pluginName: string + readonly pluginRoot: string +}): Promise { + const manifestPath = join(input.pluginRoot, ".codex-plugin", "plugin.json") + if (!(await exists(manifestPath))) return [] + const manifest: unknown = JSON.parse(await readFile(manifestPath, "utf8")) + if (!isRecord(manifest) || typeof manifest.hooks !== "string") return [] + + const hooksPath = join(input.pluginRoot, manifest.hooks) + if (!(await exists(hooksPath))) return [] + const parsed: unknown = JSON.parse(await readFile(hooksPath, "utf8")) + if (!isRecord(parsed) || !isRecord(parsed.hooks)) return [] + + const keySource = `${input.pluginName}@${input.marketplaceName}:${stripDotSlash(manifest.hooks)}` + const states: TrustedHookState[] = [] + for (const [eventName, groups] of Object.entries(parsed.hooks)) { + if (!Array.isArray(groups)) continue + const eventLabel = EVENT_LABELS.get(eventName) + if (eventLabel === undefined) continue + for (const [groupIndex, group] of groups.entries()) { + if (!isRecord(group) || !Array.isArray(group.hooks)) continue + for (const [handlerIndex, handler] of group.hooks.entries()) { + if (!isRecord(handler) || handler.type !== "command") continue + if (handler.async === true) continue + if (typeof handler.command !== "string" || handler.command.trim() === "") continue + const key = `${keySource}:${eventLabel}:${groupIndex}:${handlerIndex}` + states.push({ key, trustedHash: commandHookHash(eventLabel, group.matcher, handler) }) + } + } + } + return states +} + +function commandHookHash(eventName: string, matcher: unknown, handler: Record): string { + const timeout = Math.max(Number(handler.timeout ?? 600), 1) + const normalizedHandler: Record = { + type: "command", + command: handler.command, + timeout, + async: false, + } + if (typeof handler.statusMessage === "string") normalizedHandler.statusMessage = handler.statusMessage + + const identity: Record = { event_name: eventName, hooks: [normalizedHandler] } + if (typeof matcher === "string") identity.matcher = matcher + const canonical = JSON.stringify(canonicalJson(identity)) + return `sha256:${createHash("sha256").update(canonical).digest("hex")}` +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson) + if (!isRecord(value)) return value + const result: Record = {} + for (const key of Object.keys(value).sort()) { + result[key] = canonicalJson(value[key]) + } + return result +} + +function stripDotSlash(value: string): string { + return value.startsWith("./") ? value.slice(2) : value +} + +async function exists(path: string): Promise { + try { + await readFile(path, "utf8") + return true + } catch { + return false + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-marketplace-snapshot.ts b/src/cli/install-codex/codex-marketplace-snapshot.ts new file mode 100644 index 000000000..b0e0e6d74 --- /dev/null +++ b/src/cli/install-codex/codex-marketplace-snapshot.ts @@ -0,0 +1,73 @@ +import { cp, mkdir, rename, rm, writeFile } from "node:fs/promises" +import { join, sep } from "node:path" +import { copyBundledMcpRuntimeDists } from "./codex-cache-bundled-mcps" +import { rewriteCachedMcpManifest } from "./codex-cache" +import type { MarketplaceManifest } from "./types" + +const INSTALLED_MARKETPLACES_DIR = ".tmp/marketplaces" + +export interface MarketplaceSnapshotPluginSource { + readonly name: string + readonly sourcePath: string +} + +export interface MarketplaceSnapshotPlugin { + readonly name: string + readonly path: string +} + +export async function writeInstalledMarketplaceSnapshot(input: { + readonly codexHome: string + readonly marketplace: MarketplaceManifest + readonly plugins: readonly MarketplaceSnapshotPluginSource[] +}): Promise { + const marketplaceRoot = installedMarketplaceRoot(input.codexHome, input.marketplace.name) + await mkdir(marketplaceRoot, { recursive: true }) + await writeMarketplaceManifest(marketplaceRoot, input.marketplace) + + const snapshotPlugins: MarketplaceSnapshotPlugin[] = [] + for (const plugin of input.plugins) { + snapshotPlugins.push(await writeSnapshotPlugin(marketplaceRoot, plugin)) + } + return snapshotPlugins +} + +export function installedMarketplaceRoot(codexHome: string, marketplaceName: string): string { + return join(codexHome, INSTALLED_MARKETPLACES_DIR, marketplaceName) +} + +async function writeMarketplaceManifest(marketplaceRoot: string, marketplace: MarketplaceManifest): Promise { + const manifestDir = join(marketplaceRoot, ".agents", "plugins") + await mkdir(manifestDir, { recursive: true }) + const tempPath = join(manifestDir, `.marketplace-${process.pid}-${Date.now()}.json.tmp`) + await writeFile(tempPath, `${JSON.stringify(marketplace, null, "\t")}\n`) + await rename(tempPath, join(manifestDir, "marketplace.json")) +} + +async function writeSnapshotPlugin( + marketplaceRoot: string, + plugin: MarketplaceSnapshotPluginSource, +): Promise { + const pluginsDir = join(marketplaceRoot, "plugins") + await mkdir(pluginsDir, { recursive: true }) + const targetPath = join(pluginsDir, plugin.name) + const tempPath = join(pluginsDir, `.tmp-${plugin.name}-${process.pid}-${Date.now()}`) + await rm(tempPath, { recursive: true, force: true }) + await cp(plugin.sourcePath, tempPath, { + recursive: true, + filter: (source) => shouldCopyMarketplaceSourcePath(source, plugin.sourcePath), + }) + await copyBundledMcpRuntimeDists({ pluginRoot: tempPath, sourceRoot: plugin.sourcePath }) + await rm(targetPath, { recursive: true, force: true }) + await rename(tempPath, targetPath) + await rewriteCachedMcpManifest(targetPath, plugin.sourcePath) + return { name: plugin.name, path: targetPath } +} + +function shouldCopyMarketplaceSourcePath(path: string, root: string): boolean { + const relative = path === root ? "" : path.slice(root.length + sep.length) + if (relative === "") return true + const parts = relative.split(sep) + if (parts[parts.length - 1] === "package-lock.json") return false + return !parts.some((part) => part === ".git" || part === "node_modules") +} diff --git a/src/cli/install-codex/codex-marketplace.test.ts b/src/cli/install-codex/codex-marketplace.test.ts new file mode 100644 index 000000000..99effc610 --- /dev/null +++ b/src/cli/install-codex/codex-marketplace.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { readMarketplace, readPluginManifest, resolvePluginSource } from "./codex-marketplace" + +describe("codex-marketplace", () => { + test("reads marketplace and resolves plugin source with override", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-marketplace-")) + const pkgRoot = join(root, "packages", "omo-codex") + const pluginRoot = join(pkgRoot, "plugin", ".codex-plugin") + await mkdir(pluginRoot, { recursive: true }) + await writeFile(join(pkgRoot, "marketplace.json"), JSON.stringify({ name: "sisyphuslabs", plugins: [{ name: "omo", source: "./plugins/omo" }] })) + await writeFile(join(pluginRoot, "plugin.json"), JSON.stringify({ name: "omo", version: "0.1.0" })) + + // when + const marketplace = await readMarketplace(root) + const sourcePath = resolvePluginSource(pkgRoot, marketplace.plugins[0], { pathOverride: "./plugin" }) + const manifest = await readPluginManifest(sourcePath) + + // then + expect(marketplace.name).toBe("sisyphuslabs") + expect(sourcePath).toBe(join(pkgRoot, "plugin")) + expect(manifest.name).toBe("omo") + }) + + test("rejects traversal source path", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-marketplace-")) + const pkgRoot = join(root, "packages", "omo-codex") + await mkdir(pkgRoot, { recursive: true }) + await writeFile(join(pkgRoot, "marketplace.json"), JSON.stringify({ name: "sisyphuslabs", plugins: [{ name: "omo", source: "./../escape" }] })) + + // when + const action = readMarketplace(root) + + // then + await expect(action).rejects.toThrow("local plugin source path must stay within the marketplace root") + }) +}) diff --git a/src/cli/install-codex/codex-marketplace.ts b/src/cli/install-codex/codex-marketplace.ts new file mode 100644 index 000000000..e5a791912 --- /dev/null +++ b/src/cli/install-codex/codex-marketplace.ts @@ -0,0 +1,110 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import type { + MarketplaceManifest, + MarketplacePluginEntry, + MarketplacePluginSourceLocal, + PluginManifest, +} from "./types" + +const DEFAULT_MARKETPLACE_PATH = "packages/omo-codex/marketplace.json" + +export async function readMarketplace( + repoRoot: string, + options?: { readonly marketplacePath?: string }, +): Promise { + const marketplacePath = options?.marketplacePath ?? join(repoRoot, DEFAULT_MARKETPLACE_PATH) + const raw = await readFile(marketplacePath, "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) throw new Error("marketplace.json must be an object") + if (typeof parsed.name !== "string" || parsed.name.trim() === "") { + throw new Error("marketplace.json name must be a non-empty string") + } + validatePathSegment(parsed.name, "marketplace name") + if (!Array.isArray(parsed.plugins)) throw new Error("marketplace.json plugins must be an array") + return { + name: parsed.name, + plugins: parsed.plugins.map((plugin, index) => normalizeMarketplacePlugin(plugin, index)), + } +} + +export function resolvePluginSource( + repoRoot: string, + plugin: MarketplacePluginEntry, + options?: { readonly pathOverride?: string }, +): string { + const sourcePath = localSourcePath(options?.pathOverride ?? plugin.source) + const relativePath = sourcePath.slice(2) + return join(repoRoot, ...relativePath.split(/[\\/]/)) +} + +export async function readPluginManifest(pluginRoot: string): Promise { + const raw = await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8") + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) throw new Error(`${pluginRoot} plugin.json must be an object`) + if (typeof parsed.name !== "string" || parsed.name.trim() === "") { + throw new Error(`${pluginRoot} plugin.json name must be a non-empty string`) + } + if (parsed.version !== undefined && (typeof parsed.version !== "string" || parsed.version.trim() === "")) { + throw new Error(`${pluginRoot} plugin.json version must be a non-empty string`) + } + if (parsed.hooks !== undefined && (typeof parsed.hooks !== "string" || parsed.hooks.trim() === "")) { + throw new Error(`${pluginRoot} plugin.json hooks must be a non-empty string`) + } + return { + name: parsed.name, + version: typeof parsed.version === "string" ? parsed.version.trim() : undefined, + hooks: typeof parsed.hooks === "string" ? parsed.hooks.trim() : undefined, + } +} + +export function validatePathSegment(value: string, label: string): void { + if (!/^[A-Za-z0-9._+-]+$/.test(value)) { + throw new Error(`${label} contains unsupported characters: ${value}`) + } + if (value === "." || value === "..") { + throw new Error(`${label} must not be a path traversal segment`) + } +} + +function normalizeMarketplacePlugin(plugin: unknown, index: number): MarketplacePluginEntry { + if (!isRecord(plugin)) throw new Error(`marketplace plugin ${index} must be an object`) + if (typeof plugin.name !== "string" || plugin.name.trim() === "") { + throw new Error(`marketplace plugin ${index} name must be a non-empty string`) + } + validatePathSegment(plugin.name, "plugin name") + if (plugin.source === undefined || typeof plugin.source === "string") { + if (typeof plugin.source === "string") { + validateLocalSourcePath(plugin.source) + } + return { name: plugin.name, source: plugin.source } + } + if (isRecord(plugin.source) && plugin.source.source === "local" && typeof plugin.source.path === "string") { + validateLocalSourcePath(plugin.source.path) + const local: MarketplacePluginSourceLocal = { source: "local", path: plugin.source.path } + return { name: plugin.name, source: local } + } + throw new Error("local plugin source must be a string path or { source: \"local\", path } object") +} + +function localSourcePath(source: string | MarketplacePluginSourceLocal | undefined): string { + if (typeof source === "string") return validateLocalSourcePath(source) + if (source?.source === "local") return validateLocalSourcePath(source.path) + throw new Error("local plugin source path is required") +} + +function validateLocalSourcePath(path: string): string { + if (!path.startsWith("./")) throw new Error("local plugin source path must start with ./") + const relative = path.slice(2) + if (relative.length === 0) throw new Error("local plugin source path must not be empty") + for (const part of relative.split(/[\\/]/)) { + if (part === "" || part === "." || part === "..") { + throw new Error("local plugin source path must stay within the marketplace root") + } + } + return path +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/cli/install-codex/codex-multi-agent-v2-config.ts b/src/cli/install-codex/codex-multi-agent-v2-config.ts new file mode 100644 index 000000000..76b1b65eb --- /dev/null +++ b/src/cli/install-codex/codex-multi-agent-v2-config.ts @@ -0,0 +1,32 @@ +import { appendBlock, findTomlSection, removeSetting, replaceOrInsertSetting } from "./toml-section-editor" + +const CODEX_MULTI_AGENT_V2_HEADER = "features.multi_agent_v2" +const CODEX_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION = 10000 + +export function ensureCodexMultiAgentV2Config(config: string): string { + const normalizedConfig = removeFeatureFlagSetting(config, "multi_agent_v2") + const section = findTomlSection(normalizedConfig, CODEX_MULTI_AGENT_V2_HEADER) + const maxThreadsValue = CODEX_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION.toString() + if (!section) { + return appendBlock( + normalizedConfig, + `[${CODEX_MULTI_AGENT_V2_HEADER}]\nenabled = true\nmax_concurrent_threads_per_session = ${maxThreadsValue}\n`, + ) + } + + const enabledConfig = replaceOrInsertSetting(normalizedConfig, section, "enabled", "true") + const updatedSection = findTomlSection(enabledConfig, CODEX_MULTI_AGENT_V2_HEADER) + if (!updatedSection) { + return appendBlock( + enabledConfig, + `[${CODEX_MULTI_AGENT_V2_HEADER}]\nenabled = true\nmax_concurrent_threads_per_session = ${maxThreadsValue}\n`, + ) + } + return replaceOrInsertSetting(enabledConfig, updatedSection, "max_concurrent_threads_per_session", maxThreadsValue) +} + +function removeFeatureFlagSetting(config: string, featureName: string): string { + const section = findTomlSection(config, "features") + if (!section) return config + return removeSetting(config, section, featureName) +} diff --git a/src/cli/install-codex/codex-process.ts b/src/cli/install-codex/codex-process.ts new file mode 100644 index 000000000..4e6fbe2ec --- /dev/null +++ b/src/cli/install-codex/codex-process.ts @@ -0,0 +1,17 @@ +import { spawn } from "../../shared/bun-spawn-shim" +import type { RunCommand } from "./types" + +export const defaultRunCommand: RunCommand = async (command, args, options) => { + const proc = spawn({ + cmd: [command, ...args], + cwd: options.cwd, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }) + + const code = await proc.exited + if (code !== 0) { + throw new Error(`${command} ${args.join(" ")} failed in ${options.cwd} with exit code ${code}`) + } +} diff --git a/src/cli/install-codex/index.ts b/src/cli/install-codex/index.ts new file mode 100644 index 000000000..20c3d353d --- /dev/null +++ b/src/cli/install-codex/index.ts @@ -0,0 +1,14 @@ +export type { + CodexInstallOptions, + CodexInstallResult, + InstalledPlugin, + MarketplaceManifest, + PluginManifest, + TrustedHookState, +} from "./types" +export { runCodexInstaller } from "./install-codex" +export { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace" +export { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, rewriteCachedMcpManifest } from "./codex-cache" +export { updateCodexConfig } from "./codex-config-toml" +export { trustedHookStatesForPlugin } from "./codex-hook-trust" +export { defaultRunCommand } from "./codex-process" diff --git a/src/cli/install-codex/install-codex.test.ts b/src/cli/install-codex/install-codex.test.ts new file mode 100644 index 000000000..4da7f3f78 --- /dev/null +++ b/src/cli/install-codex/install-codex.test.ts @@ -0,0 +1,258 @@ +/// +/// + +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { resolveCodexInstallerBinDir, runCodexInstaller } from "./install-codex" + +const EXPECTED_OMO_COMPONENT_BINS = [ + { name: "omo", target: join("components", "ulw-loop", "dist", "cli.js") }, + { name: "omo-comment-checker", target: join("components", "comment-checker", "dist", "cli.js") }, + { name: "omo-lsp", target: join("components", "lsp", "dist", "cli.js") }, + { name: "omo-rules", target: join("components", "rules", "dist", "cli.js") }, + { name: "omo-start-work-continuation", target: join("components", "start-work-continuation", "dist", "cli.js") }, + { name: "omo-telemetry", target: join("components", "telemetry", "dist", "cli.js") }, + { name: "omo-ultrawork", target: join("components", "ultrawork", "dist", "cli.js") }, +] as const + +const STALE_CODEX_COMPONENT_BINS = [ + "codex-comment-checker", + "codex-rules", + "codex-start-work-continuation", + "codex-telemetry", + "codex-ultrawork", +] as const + +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("#given codex installer #when installing omo #then registers local marketplace and cached plugin", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-")) + const repoRoot = process.cwd() + const legacyCacheRoot = join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo", "0.1.0") + await mkdir(legacyCacheRoot, { recursive: true }) + await writeFile(join(legacyCacheRoot, ".mcp.json"), JSON.stringify({ mcpServers: { lsp: { args: ["old-lsp"] } } })) + + // when + const first = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined }) + const second = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined }) + + // then + expect(first.marketplaceName).toBe("sisyphuslabs") + expect(second.installed.length).toBe(1) + const configContent = await readFile(join(codexHome, "config.toml"), "utf8") + expect(configContent).toContain("[features]") + expect(configContent).toContain("[marketplaces.sisyphuslabs]") + expect(configContent).toContain('source_type = "local"') + expect(configContent).toContain(`source = "${join(codexHome, "plugins", "cache", "sisyphuslabs")}"`) + expect(configContent).not.toContain('source = "https://github.com/code-yeongyu/lazycodex.git"') + expect(configContent).not.toContain('ref = "main"') + expect(configContent).toContain("[plugins.\"omo@sisyphuslabs\"]") + expect(configContent).toContain("[hooks.state.") + for (const agentName of ["codex-ultrawork-reviewer", "explorer", "librarian", "metis", "momus", "plan"]) { + expect(configContent).toContain(`[agents.${agentName}]`) + expect(configContent).toContain(`config_file = "./agents/${agentName}.toml"`) + } + expect(configContent).not.toContain("code-yeongyu-codex-plugins") + expect(configContent).not.toContain("[marketplaces.lazycodex]") + + const pluginPath = first.installed[0]?.path + expect(pluginPath).toBeDefined() + expect(pluginPath).toContain(join("plugins", "cache", "sisyphuslabs", "omo")) + const stats = await stat(pluginPath ?? "") + expect(stats.isDirectory()).toBe(true) + const skillNames = (await readdir(join(pluginPath ?? "", "skills"), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + expect(skillNames).toContain("ulw-plan") + expect(skillNames).toContain("ulw-loop") + expect(skillNames).not.toContain("planing-prometheustic") + const mcpManifest = JSON.parse(await readFile(join(pluginPath ?? "", ".mcp.json"), "utf8")) as { + mcpServers: { ast_grep: { args: string[] }; lsp: { args: string[] } } + } + expect(mcpManifest.mcpServers.ast_grep.args[0]).toBe(join(pluginPath ?? "", "components", "ast-grep-mcp", "dist", "cli.js")) + expect((await stat(mcpManifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true) + expect(mcpManifest.mcpServers.lsp.args[0]).toBe(join(pluginPath ?? "", "components", "lsp-tools-mcp", "dist", "cli.js")) + expect(mcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages") + expect(mcpManifest.mcpServers.lsp.args[0]?.startsWith(pluginPath ?? "")).toBe(true) + expect((await stat(mcpManifest.mcpServers.lsp.args[0] ?? "")).isFile()).toBe(true) + for (const agentName of ["codex-ultrawork-reviewer", "explorer", "librarian", "metis", "momus", "plan"]) { + expect((await stat(join(codexHome, "agents", `${agentName}.toml`))).isFile()).toBe(true) + } + const marketplace = JSON.parse( + await readFile(join(codexHome, "plugins", "cache", "sisyphuslabs", ".agents", "plugins", "marketplace.json"), "utf8"), + ) as { plugins: Array<{ name: string; source: { source: string; path: string } }> } + expect(marketplace.plugins).toEqual([{ name: "omo", source: { source: "local", path: "./omo/0.1.0" } }]) + let legacyCacheMissing = false + try { + await stat(join(codexHome, "plugins", "cache", "code-yeongyu-codex-plugins", "omo")) + } catch (error) { + legacyCacheMissing = error instanceof Error + } + expect(legacyCacheMissing).toBe(true) + }) + + test("#given codex installer #when installing omo #then links omo-prefixed component CLIs to existing cached runtimes", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-bins-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-bins-")) + const repoRoot = process.cwd() + + // when + const result = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined }) + + // then + const pluginPath = result.installed[0]?.path ?? "" + const linkedNames = (await readdir(binDir)).sort() + expect(linkedNames).toEqual(EXPECTED_OMO_COMPONENT_BINS.map((entry) => entry.name).sort()) + for (const entry of EXPECTED_OMO_COMPONENT_BINS) { + const linkPath = join(binDir, entry.name) + const expectedTarget = join(pluginPath, entry.target) + expect(await readlink(linkPath)).toBe(expectedTarget) + expect((await stat(expectedTarget)).isFile()).toBe(true) + } + for (const staleName of STALE_CODEX_COMPONENT_BINS) { + expect(linkedNames).not.toContain(staleName) + } + }) + + test("#given installation guide #when component binaries are documented #then docs use omo-prefixed names only", async () => { + // given + const installationGuide = await readFile(join(process.cwd(), "docs", "guide", "installation.md"), "utf8") + + // when + const expectedNames = EXPECTED_OMO_COMPONENT_BINS.map((entry) => entry.name) + + // then + for (const name of expectedNames) { + expect(installationGuide).toContain(name) + } + for (const staleName of STALE_CODEX_COMPONENT_BINS) { + expect(installationGuide).not.toContain(`~/.local/bin/${staleName}`) + expect(installationGuide).not.toContain(`command not found: ${staleName}`) + } + }) + + test("#given Codex prunes an old plugin cache version #when agent role files were installed #then roles still resolve through the marketplace snapshot", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-autoupdate-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-autoupdate-")) + const repoRoot = process.cwd() + const marketplaceRoot = join(codexHome, ".tmp", "marketplaces", "sisyphuslabs") + await mkdir(join(marketplaceRoot, ".git"), { recursive: true }) + await writeFile(join(marketplaceRoot, ".git", "config"), "[remote \"origin\"]\n") + await writeFile(join(marketplaceRoot, ".codex-marketplace-install.json"), '{"source_type":"git"}\n') + + // when + const result = await runCodexInstaller({ codexHome, binDir, repoRoot, runCommand: async () => undefined }) + const pluginPath = result.installed[0]?.path ?? "" + await rm(pluginPath, { recursive: true, force: true }) + + // then + const explorerAgentPath = join(codexHome, "agents", "explorer.toml") + expect(await readlink(explorerAgentPath)).toBe( + join( + codexHome, + ".tmp", + "marketplaces", + "sisyphuslabs", + "plugins", + "omo", + "components", + "ultrawork", + "agents", + "explorer.toml", + ), + ) + expect(await readFile(explorerAgentPath, "utf8")).toContain('name = "explorer"') + expect(await readFile(join(marketplaceRoot, ".git", "config"), "utf8")).toBe("[remote \"origin\"]\n") + expect(await readFile(join(marketplaceRoot, ".codex-marketplace-install.json"), "utf8")).toBe( + '{"source_type":"git"}\n', + ) + const snapshotPluginPath = join(marketplaceRoot, "plugins", "omo") + const snapshotMcpManifest: { + readonly mcpServers: { + readonly ast_grep: { readonly args: readonly string[] } + readonly lsp: { readonly args: readonly string[] } + } + } = JSON.parse(await readFile(join(snapshotPluginPath, ".mcp.json"), "utf8")) + expect(snapshotMcpManifest.mcpServers.ast_grep.args[0]).toBe( + join(snapshotPluginPath, "components", "ast-grep-mcp", "dist", "cli.js"), + ) + expect((await stat(snapshotMcpManifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true) + expect(snapshotMcpManifest.mcpServers.lsp.args[0]).toBe( + join(snapshotPluginPath, "components", "lsp-tools-mcp", "dist", "cli.js"), + ) + expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("../../lsp-tools-mcp") + expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages") + expect((await stat(snapshotMcpManifest.mcpServers.lsp.args[0] ?? "")).isFile()).toBe(true) + }) + + test("#given autonomous permissions requested #when installing omo #then writes Codex autonomy settings", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-autonomous-home-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-autonomous-bin-")) + const repoRoot = process.cwd() + + // when + await runCodexInstaller({ + codexHome, + binDir, + repoRoot, + runCommand: async () => undefined, + autonomousPermissions: true, + }) + + // then + const configContent = await readFile(join(codexHome, "config.toml"), "utf8") + expect(configContent).toContain('approval_policy = "never"') + expect(configContent).toContain('sandbox_mode = "danger-full-access"') + expect(configContent).toContain('network_access = "enabled"') + expect(configContent).toContain("hide_full_access_warning = true") + expect(configContent).toContain("hide_world_writable_warning = true") + }) +}) diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts new file mode 100644 index 000000000..715d3be1b --- /dev/null +++ b/src/cli/install-codex/install-codex.ts @@ -0,0 +1,225 @@ +import { homedir } from "node:os" +import { join, resolve } from "node:path" +import { existsSync } from "node:fs" +import { mkdir, writeFile } from "node:fs/promises" +import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } 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 { writeInstalledMarketplaceSnapshot, type MarketplaceSnapshotPluginSource } from "./codex-marketplace-snapshot" +import { defaultRunCommand } from "./codex-process" +import type { CodexInstallOptions, CodexInstallResult, CodexMarketplaceSource, InstalledPlugin, MarketplaceManifest } from "./types" + +const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const + +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 = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env: process.env }) + const runCommand = options.runCommand ?? defaultRunCommand + const log = options.log ?? (() => undefined) + + const codexPackageRoot = join(repoRoot, "packages", "omo-codex") + const marketplace = await readMarketplace(repoRoot, { + marketplacePath: join(codexPackageRoot, "marketplace.json"), + }) + + const installed: InstalledPlugin[] = [] + const pluginSources: MarketplaceSnapshotPluginSource[] = [] + const agentConfigs = new Map() + for (const entry of marketplace.plugins) { + const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" }) + const manifest = await readPluginManifest(sourcePath) + if (manifest.name !== entry.name) { + throw new Error( + `plugin manifest name ${JSON.stringify(manifest.name)} does not match marketplace name ${JSON.stringify(entry.name)}`, + ) + } + + const version = manifest.version ?? "local" + validatePathSegment(version, "plugin version") + log(`Building ${entry.name}@${version}`) + + const plugin = await installCachedPlugin({ + codexHome, + marketplaceName: marketplace.name, + name: entry.name, + runCommand, + sourcePath, + version, + }) + + const links = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path }) + for (const link of links) { + log(`Linked ${link.name} -> ${link.target}`) + } + pluginSources.push({ name: entry.name, sourcePath }) + installed.push(plugin) + } + + const agentSourceRoots = await agentSourceRootsForInstall({ + codexHome, + marketplace, + installed, + pluginSources, + }) + for (const plugin of installed) { + const pluginRoot = agentSourceRoots.get(plugin.name) ?? plugin.path + const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot }) + 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}` }) + } + } + + const trustedHookStates = ( + await Promise.all( + installed.map((plugin) => + trustedHookStatesForPlugin({ + marketplaceName: marketplace.name, + pluginName: plugin.name, + pluginRoot: plugin.path, + }), + ), + ) + ).flat() + + await pruneMarketplaceCache({ + codexHome, + marketplaceName: marketplace.name, + keepPluginNames: marketplace.plugins.map((plugin) => plugin.name), + }) + for (const legacyMarketplaceName of legacyCacheMarketplaces(marketplace.name)) { + await pruneMarketplacePluginCaches({ + codexHome, + marketplaceName: legacyMarketplaceName, + pluginNames: marketplace.plugins.map((plugin) => plugin.name), + }) + } + + const marketplaceRoot = join(codexHome, "plugins", "cache", marketplace.name) + await writeCachedMarketplaceManifest({ + marketplaceName: marketplace.name, + marketplaceRoot, + plugins: installed, + }) + + const configPath = join(codexHome, "config.toml") + await updateCodexConfig({ + configPath, + repoRoot: codexPackageRoot, + marketplaceName: marketplace.name, + marketplaceSource: codexMarketplaceSource(marketplaceRoot), + pluginNames: marketplace.plugins.map((plugin) => plugin.name), + trustedHookStates, + agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), + autonomousPermissions: options.autonomousPermissions === true, + }) + + await trackCodexInstallTelemetry() + + return { + marketplaceName: marketplace.name, + installed, + configPath, + codexHome, + } +} + +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 +} + +async function agentSourceRootsForInstall(input: { + readonly codexHome: string + readonly marketplace: MarketplaceManifest + readonly installed: readonly InstalledPlugin[] + readonly pluginSources: readonly MarketplaceSnapshotPluginSource[] +}): Promise> { + if (input.marketplace.name !== "sisyphuslabs") { + return new Map(input.installed.map((plugin) => [plugin.name, plugin.path])) + } + const snapshotPlugins = await writeInstalledMarketplaceSnapshot({ + codexHome: input.codexHome, + marketplace: input.marketplace, + plugins: input.pluginSources, + }) + return new Map(snapshotPlugins.map((plugin) => [plugin.name, plugin.path])) +} + +async function writeCachedMarketplaceManifest(input: { + readonly marketplaceName: string + readonly marketplaceRoot: string + readonly plugins: readonly InstalledPlugin[] +}): Promise { + const marketplaceDir = join(input.marketplaceRoot, ".agents", "plugins") + await mkdir(marketplaceDir, { recursive: true }) + await writeFile( + join(marketplaceDir, "marketplace.json"), + `${JSON.stringify( + { + name: input.marketplaceName, + plugins: input.plugins.map((plugin) => ({ + name: plugin.name, + source: { source: "local", path: `./${plugin.name}/${plugin.version}` }, + })), + }, + null, + "\t", + )}\n`, + ) +} + +function legacyCacheMarketplaces(marketplaceName: string): readonly string[] { + return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : [] +} + +function codexMarketplaceSource(marketplaceRoot: string): CodexMarketplaceSource { + return { sourceType: "local", source: marketplaceRoot } +} + +function findRepoRootFromImporter(importerDir: string): string { + let current = importerDir + for (let depth = 0; depth <= 5; depth += 1) { + const pluginManifestPath = join(current, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json") + if (existsSyncLike(pluginManifestPath)) return current + current = resolve(current, "..") + } + throw new Error( + "Unable to locate vendored Codex plugin: expected packages/omo-codex/plugin/.codex-plugin/plugin.json within 5 parent levels", + ) +} + +function existsSyncLike(path: string): boolean { + return existsSync(path) +} + +async function trackCodexInstallTelemetry(): Promise { + try { + const { createInstallPostHog, getPostHogDistinctId } = await import("@oh-my-opencode/omo-codex/telemetry") + const posthog = createInstallPostHog() + posthog.trackActive(getPostHogDistinctId(), "install_completed") + await posthog.shutdown() + } catch (error) { + if (error instanceof Error) return + return + } +} diff --git a/src/cli/install-codex/lazycodex-routing.test.ts b/src/cli/install-codex/lazycodex-routing.test.ts new file mode 100644 index 000000000..221dc8224 --- /dev/null +++ b/src/cli/install-codex/lazycodex-routing.test.ts @@ -0,0 +1,127 @@ +/// + +import { afterEach, describe, expect, test } from "bun:test" +import process from "node:process" +import { resolveInstallArgs } from "../cli-program" +import { argsToConfig } from "../install-validators" + +describe("lazycodex install routing", () => { + const originalInvocationName = process.env.OMO_INVOCATION_NAME + const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX + + afterEach(() => { + if (originalInvocationName === undefined) { + delete process.env.OMO_INVOCATION_NAME + } else { + process.env.OMO_INVOCATION_NAME = originalInvocationName + } + + if (originalPublishLazycodex === undefined) { + delete process.env.OMO_PUBLISH_LAZYCODEX + } else { + process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex + } + }) + + test("leaves lazycodex invocation unresolved when lazycodex publishing is disabled", () => { + // given + process.env.OMO_INVOCATION_NAME = "lazycodex" + delete process.env.OMO_PUBLISH_LAZYCODEX + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }) + const config = argsToConfig(args) + + // then + expect(args.platform).toBeUndefined() + expect(config.hasCodex).toBe(false) + expect(config.hasOpenCode).toBe(true) + }) + + test("defaults platform to codex when invoked as lazycodex with lazycodex publishing enabled", () => { + // given + process.env.OMO_INVOCATION_NAME = "lazycodex" + process.env.OMO_PUBLISH_LAZYCODEX = "true" + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }) + const config = argsToConfig(args) + + // then + expect(args.platform).toBe("codex") + expect(config.hasCodex).toBe(true) + expect(config.hasOpenCode).toBe(false) + }) + + test("respects explicit --platform=both when lazycodex publishing is enabled", () => { + // given + process.env.OMO_INVOCATION_NAME = "lazycodex" + process.env.OMO_PUBLISH_LAZYCODEX = "true" + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + platform: "both", + }) + const config = argsToConfig(args) + + // then + expect(args.platform).toBe("both") + expect(config.hasCodex).toBe(true) + expect(config.hasOpenCode).toBe(true) + }) + + test("leaves omo install unresolved so argsToConfig applies opencode default", () => { + // given + process.env.OMO_INVOCATION_NAME = "oh-my-opencode" + + // when + const args = resolveInstallArgs({ + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }) + const config = argsToConfig(args) + + // then + expect(args.platform).toBeUndefined() + expect(config.hasCodex).toBe(false) + expect(config.hasOpenCode).toBe(true) + }) + + test("leaves unset invocation unresolved so argsToConfig applies opencode default", () => { + // given + delete process.env.OMO_INVOCATION_NAME + + // when + const args = resolveInstallArgs( + { + tui: false, + claude: "no", + gemini: "no", + copilot: "no", + }, + undefined, + ) + const config = argsToConfig(args) + + // then + expect(args.platform).toBeUndefined() + expect(config.hasCodex).toBe(false) + expect(config.hasOpenCode).toBe(true) + }) +}) 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..eabac8790 --- /dev/null +++ b/src/cli/install-codex/link-cached-plugin-agents.test.ts @@ -0,0 +1,205 @@ +/// +/// + +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", "ulw-loop", "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", "ulw-loop", "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", "ulw-loop", "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([]) + }) + + test("auto-detects host platform when platform parameter is omitted", async () => { + // given - no `platform` argument, so process.platform decides + const { codexHome, pluginRoot } = await makeFixture() + + // when + const linked = await linkCachedPluginAgents({ codexHome, pluginRoot }) + + // then - on Unix expect symlinks; on Windows expect file copies + expect(linked).toHaveLength(3) + for (const entry of linked) { + const linkStat = await lstat(entry.path) + if (process.platform === "win32") { + 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$/, "")}"`) + } else { + expect(linkStat.isSymbolicLink()).toBe(true) + } + } + }) +}) 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 + } +} diff --git a/src/cli/install-codex/toml-section-editor.ts b/src/cli/install-codex/toml-section-editor.ts new file mode 100644 index 000000000..d30abc2e9 --- /dev/null +++ b/src/cli/install-codex/toml-section-editor.ts @@ -0,0 +1,53 @@ +export interface TomlSection { + readonly start: number + readonly end: number + readonly text: string +} + +export function findTomlSection(config: string, header: string): TomlSection | null { + const headerLine = `[${header}]` + const lines = config.match(/[^\n]*\n?|$/g) ?? [] + let offset = 0 + let start = -1 + for (const line of lines) { + if (line.length === 0) break + const trimmed = line.trim() + if (start === -1) { + if (trimmed === headerLine) start = offset + } else if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return { start, end: offset, text: config.slice(start, offset) } + } + offset += line.length + } + if (start === -1) return null + return { start, end: config.length, text: config.slice(start) } +} + +export function replaceOrInsertSetting(config: string, section: TomlSection, key: string, value: string): string { + const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m") + const replacement = linePattern.test(section.text) + ? section.text.replace(linePattern, `${key} = ${value}`) + : insertSetting(section.text, key, value) + return config.slice(0, section.start) + replacement + config.slice(section.end) +} + +export function removeSetting(config: string, section: TomlSection, key: string): string { + const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*(?:\\n|$)`, "m") + const replacement = section.text.replace(linePattern, "") + return config.slice(0, section.start) + replacement + config.slice(section.end) +} + +export function appendBlock(config: string, block: string): string { + const prefix = config.trimEnd() + return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n` +} + +function insertSetting(sectionText: string, key: string, value: string): string { + const lines = sectionText.split("\n") + lines.splice(1, 0, `${key} = ${value}`) + return lines.join("\n") +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/src/cli/install-codex/types.ts b/src/cli/install-codex/types.ts new file mode 100644 index 000000000..5254a2b43 --- /dev/null +++ b/src/cli/install-codex/types.ts @@ -0,0 +1,73 @@ +export interface MarketplacePluginSourceLocal { + readonly source: "local" + readonly path: string +} + +export interface MarketplacePluginEntry { + readonly name: string + readonly source?: string | MarketplacePluginSourceLocal +} + +export interface MarketplaceManifest { + readonly name: string + readonly plugins: readonly MarketplacePluginEntry[] +} + +export interface PluginManifest { + readonly name: string + readonly version?: string + readonly hooks?: string +} + +export interface InstalledPlugin { + readonly name: string + readonly version: string + readonly path: string +} + +export interface TrustedHookState { + readonly key: string + readonly trustedHash: 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 { + readonly cwd: string +} + +export type RunCommand = ( + command: string, + args: readonly string[], + options: CommandRunOptions, +) => Promise + +export interface CodexInstallOptions { + readonly codexHome?: string + readonly binDir?: string + readonly repoRoot?: string + readonly autonomousPermissions?: boolean + readonly runCommand?: RunCommand + readonly log?: (message: string) => void +} + +export interface CodexInstallResult { + readonly marketplaceName: string + readonly installed: readonly InstalledPlugin[] + readonly configPath: string + readonly codexHome: string +} diff --git a/src/features/builtin-commands/templates/init-deep.ts b/src/features/builtin-commands/templates/init-deep.ts deleted file mode 100644 index cebca0b20..000000000 --- a/src/features/builtin-commands/templates/init-deep.ts +++ /dev/null @@ -1,305 +0,0 @@ -export const INIT_DEEP_TEMPLATE = `# /init-deep - -Generate hierarchical AGENTS.md files. Root + complexity-scored subdirectories. - -## Usage - -\`\`\` -/init-deep # Update mode: modify existing + create new where warranted -/init-deep --create-new # Read existing → remove all → regenerate from scratch -/init-deep --max-depth=2 # Limit directory depth (default: 3) -\`\`\` - ---- - -## Workflow (High-Level) - -1. **Discovery + Analysis** (concurrent) - - Fire background explore agents immediately - - Main session: bash structure + LSP codemap + read existing AGENTS.md -2. **Score & Decide** - Determine AGENTS.md locations from merged findings -3. **Generate** - Root first, then subdirs in parallel -4. **Review** - Deduplicate, trim, validate - - -**TodoWrite ALL phases. Mark in_progress → completed in real-time.** -\`\`\` -TodoWrite([ - { id: "discovery", content: "Fire explore agents + LSP codemap + read existing", status: "pending", priority: "high" }, - { id: "scoring", content: "Score directories, determine locations", status: "pending", priority: "high" }, - { id: "generate", content: "Generate AGENTS.md files (root + subdirs)", status: "pending", priority: "high" }, - { id: "review", content: "Deduplicate, validate, trim", status: "pending", priority: "medium" } -]) -\`\`\` - - ---- - -## Phase 1: Discovery + Analysis (Concurrent) - -**Mark "discovery" as in_progress.** - -### Fire Background Explore Agents IMMEDIATELY - -Don't wait-these run async while main session works. - -\`\`\` -// Fire all at once, collect results later -task(subagent_type="explore", load_skills=[], description="Explore project structure", run_in_background=true, prompt="Project structure: PREDICT standard patterns for detected language → REPORT deviations only") -task(subagent_type="explore", load_skills=[], description="Find entry points", run_in_background=true, prompt="Entry points: FIND main files → REPORT non-standard organization") -task(subagent_type="explore", load_skills=[], description="Find conventions", run_in_background=true, prompt="Conventions: FIND config files (.eslintrc, pyproject.toml, .editorconfig) → REPORT project-specific rules") -task(subagent_type="explore", load_skills=[], description="Find anti-patterns", run_in_background=true, prompt="Anti-patterns: FIND 'DO NOT', 'NEVER', 'ALWAYS', 'DEPRECATED' comments → LIST forbidden patterns") -task(subagent_type="explore", load_skills=[], description="Explore build/CI", run_in_background=true, prompt="Build/CI: FIND .github/workflows, Makefile → REPORT non-standard patterns") -task(subagent_type="explore", load_skills=[], description="Find test patterns", run_in_background=true, prompt="Test patterns: FIND test configs, test structure → REPORT unique conventions") -\`\`\` - - -**DYNAMIC AGENT SPAWNING**: After bash analysis, spawn ADDITIONAL explore agents based on project scale: - -| Factor | Threshold | Additional Agents | -|--------|-----------|-------------------| -| **Total files** | >100 | +1 per 100 files | -| **Total lines** | >10k | +1 per 10k lines | -| **Directory depth** | ≥4 | +2 for deep exploration | -| **Large files (>500 lines)** | >10 files | +1 for complexity hotspots | -| **Monorepo** | detected | +1 per package/workspace | -| **Multiple languages** | >1 | +1 per language | - -\`\`\`bash -# Measure project scale first -total_files=$(find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | wc -l) -total_lines=$(find . -type f \\( -name "*.ts" -o -name "*.py" -o -name "*.go" \\) -not -path '*/node_modules/*' -exec wc -l {} + 2>/dev/null | tail -1 | awk '{print $1}') -large_files=$(find . -type f \\( -name "*.ts" -o -name "*.py" \\) -not -path '*/node_modules/*' -exec wc -l {} + 2>/dev/null | awk '$1 > 500 {count++} END {print count+0}') -max_depth=$(find . -type d -not -path '*/node_modules/*' -not -path '*/.git/*' | awk -F/ '{print NF}' | sort -rn | head -1) -\`\`\` - -Example spawning: -\`\`\` -// 500 files, 50k lines, depth 6, 15 large files → spawn 5+5+2+1 = 13 additional agents -task(subagent_type="explore", load_skills=[], description="Analyze large files", run_in_background=true, prompt="Large file analysis: FIND files >500 lines, REPORT complexity hotspots") -task(subagent_type="explore", load_skills=[], description="Explore deep modules", run_in_background=true, prompt="Deep modules at depth 4+: FIND hidden patterns, internal conventions") -task(subagent_type="explore", load_skills=[], description="Find shared utilities", run_in_background=true, prompt="Cross-cutting concerns: FIND shared utilities across directories") -// ... more based on calculation -\`\`\` - - -### Main Session: Concurrent Analysis - -**While background agents run**, main session does: - -#### 1. Bash Structural Analysis -\`\`\`bash -# Directory depth + file counts -find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c - -# Files per directory (top 30) -find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30 - -# Code concentration by extension -find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.go" -o -name "*.rs" \\) -not -path '*/node_modules/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20 - -# Existing AGENTS.md / CLAUDE.md -find . -type f \\( -name "AGENTS.md" -o -name "CLAUDE.md" \\) -not -path '*/node_modules/*' 2>/dev/null -\`\`\` - -#### 2. Read Existing AGENTS.md -\`\`\` -For each existing file found: - Read(filePath=file) - Extract: key insights, conventions, anti-patterns - Store in EXISTING_AGENTS map -\`\`\` - -If \`--create-new\`: Read all existing first (preserve context) → then delete all → regenerate. - -#### 3. LSP Codemap (if available) -\`\`\` -LspServers() # Check availability - -# Entry points (parallel) -LspDocumentSymbols(filePath="src/index.ts") -LspDocumentSymbols(filePath="main.py") - -# Key symbols (parallel) -LspWorkspaceSymbols(filePath=".", query="class") -LspWorkspaceSymbols(filePath=".", query="interface") -LspWorkspaceSymbols(filePath=".", query="function") - -# Centrality for top exports -LspFindReferences(filePath="...", line=X, character=Y) -\`\`\` - -**LSP Fallback**: If unavailable, rely on explore agents + AST-grep. - -### Collect Background Results - -\`\`\` -// After main session analysis done, collect all task results -for each background task ID (\`bg_...\`): background_output(task_id="bg_...") -\`\`\` - -**Merge: bash + LSP + existing + explore findings. Mark "discovery" as completed.** - ---- - -## Phase 2: Scoring & Location Decision - -**Mark "scoring" as in_progress.** - -### Scoring Matrix - -| Factor | Weight | High Threshold | Source | -|--------|--------|----------------|--------| -| File count | 3x | >20 | bash | -| Subdir count | 2x | >5 | bash | -| Code ratio | 2x | >70% | bash | -| Unique patterns | 1x | Has own config | explore | -| Module boundary | 2x | Has index.ts/__init__.py | bash | -| Symbol density | 2x | >30 symbols | LSP | -| Export count | 2x | >10 exports | LSP | -| Reference centrality | 3x | >20 refs | LSP | - -### Decision Rules - -| Score | Action | -|-------|--------| -| **Root (.)** | ALWAYS create | -| **>15** | Create AGENTS.md | -| **8-15** | Create if distinct domain | -| **<8** | Skip (parent covers) | - -### Output -\`\`\` -AGENTS_LOCATIONS = [ - { path: ".", type: "root" }, - { path: "src/hooks", score: 18, reason: "high complexity" }, - { path: "src/api", score: 12, reason: "distinct domain" } -] -\`\`\` - -**Mark "scoring" as completed.** - ---- - -## Phase 3: Generate AGENTS.md - -**Mark "generate" as in_progress.** - - -**File Writing Rule**: If AGENTS.md already exists at the target path → use \`Edit\` tool. If it does NOT exist → use \`Write\` tool. -NEVER use Write to overwrite an existing file. ALWAYS check existence first via \`Read\` or discovery results. - - -### Root AGENTS.md (Full Treatment) - -\`\`\`markdown -# PROJECT KNOWLEDGE BASE - -**Generated:** {TIMESTAMP} -**Commit:** {SHORT_SHA} -**Branch:** {BRANCH} - -## OVERVIEW -{1-2 sentences: what + core stack} - -## STRUCTURE -\\\`\\\`\\\` -{root}/ -├── {dir}/ # {non-obvious purpose only} -└── {entry} -\\\`\\\`\\\` - -## WHERE TO LOOK -| Task | Location | Notes | -|------|----------|-------| - -## CODE MAP -{From LSP - skip if unavailable or project <10 files} - -| Symbol | Type | Location | Refs | Role | -|--------|------|----------|------|------| - -## CONVENTIONS -{ONLY deviations from standard} - -## ANTI-PATTERNS (THIS PROJECT) -{Explicitly forbidden here} - -## UNIQUE STYLES -{Project-specific} - -## COMMANDS -\\\`\\\`\\\`bash -{dev/test/build} -\\\`\\\`\\\` - -## NOTES -{Gotchas} -\`\`\` - -**Quality gates**: 50-150 lines, no generic advice, no obvious info. - -### Subdirectory AGENTS.md (Parallel) - -Launch writing tasks for each location: - -\`\`\` -for loc in AGENTS_LOCATIONS (except root): - task(category="writing", load_skills=[], run_in_background=false, description="Generate AGENTS.md", prompt=\\\` - Generate AGENTS.md for: \${loc.path} - - Reason: \${loc.reason} - - 30-80 lines max - - NEVER repeat parent content - - Sections: OVERVIEW (1 line), STRUCTURE (if >5 subdirs), WHERE TO LOOK, CONVENTIONS (if different), ANTI-PATTERNS - \\\`) -\`\`\` - -**Wait for all. Mark "generate" as completed.** - ---- - -## Phase 4: Review & Deduplicate - -**Mark "review" as in_progress.** - -For each generated file: -- Remove generic advice -- Remove parent duplicates -- Trim to size limits -- Verify telegraphic style - -**Mark "review" as completed.** - ---- - -## Final Report - -\`\`\` -=== init-deep Complete === - -Mode: {update | create-new} - -Files: - [OK] ./AGENTS.md (root, {N} lines) - [OK] ./src/hooks/AGENTS.md ({N} lines) - -Dirs Analyzed: {N} -AGENTS.md Created: {N} -AGENTS.md Updated: {N} - -Hierarchy: - ./AGENTS.md - └── src/hooks/AGENTS.md -\`\`\` - ---- - -## Anti-Patterns - -- **Static agent count**: MUST vary agents based on project size/depth -- **Sequential execution**: MUST parallel (explore + LSP concurrent) -- **Ignoring existing**: ALWAYS read existing first, even with --create-new -- **Over-documenting**: Not every dir needs AGENTS.md -- **Redundancy**: Child never repeats parent -- **Generic content**: Remove anything that applies to ALL projects -- **Verbose style**: Telegraphic or die` diff --git a/src/features/builtin-skills/skills/ai-slop-remover.ts b/src/features/builtin-skills/skills/ai-slop-remover.ts deleted file mode 100644 index 33660c500..000000000 --- a/src/features/builtin-skills/skills/ai-slop-remover.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { BuiltinSkill } from "../types" - -export const aiSlopRemoverSkill: BuiltinSkill = { - name: "ai-slop-remover", - description: - "Removes AI-generated code smells from a SINGLE file while preserving functionality. For multiple files, call in PARALLEL per file.", - template: `You are an expert code refactorer specializing in removing AI-generated "slop" patterns while STRICTLY preserving functionality. - -**INPUT**: Exactly ONE file path. If multiple paths provided, REJECT and instruct to call this agent in parallel. - ---- - -## DETECTION CRITERIA (Specific) - -### 1. Obvious Comments (EXCLUDE: BDD comments like #given, #when, #then, #when/then) - -**REMOVE**: -- Comments restating the code: \`x += 1 # increment x\` -- Docstrings on trivial methods: \`"""Returns the name."""\` for \`def get_name(): return self.name\` -- Section dividers: \`# ===== HELPER FUNCTIONS =====\` -- Commented-out code blocks -- \`# TODO: future enhancement\` without concrete plan -- \`# Note: this is important\` without explaining WHY - -**KEEP**: -- Comments explaining WHY (business logic, edge cases, workarounds) -- Links to issues/tickets: \`# See SPR-1234\` -- Non-obvious algorithm explanations -- Regex explanations -- Matches to existing code style - -### 2. Over-Defensive Code - -**REMOVE**: -- Null checks for values that CANNOT be None (e.g., Django request in view) -- \`if x is not None and x.attr is not None:\` when x is guaranteed -- Try-except around code that can't raise (e.g., dict literal access) -- \`isinstance()\` checks for statically typed parameters -- Default values for required parameters: \`def foo(x: str = "")\` when empty string is invalid -- Backward-compat shims: \`_old_name = new_name # deprecated\` -- \`# removed\` or \`# deleted\` comments for removed code -- Re-exports of unused items -- Verbose, duplicated, or redundant code / test cases - -**KEEP**: -- Validation at system boundaries (user input, external API responses) -- Error handling for I/O operations -- Null checks for nullable DB fields -- assertions in test code to matching type expectations - -### 3. Spaghetti Nesting (2+ levels deep) - -**REFACTOR**: -- Nested if-else chains -> early returns / guard clauses -- \`if x: if y: if z:\` -> \`if not x: return\` / \`if not y: return\` -- Nested loops with conditionals -> extract to helper OR use comprehensions -- Complex ternary \`a if b else (c if d else e)\` -> explicit if-else - ---- - -## PROCESS - -### Step 1: Read & Analyze -Read the file. Identify ALL slop instances with line numbers. - -### Step 2: Deep Consideration (CRITICAL) -For EACH identified issue, think: -- **Functionality Impact**: Will removing this change behavior? If ANY doubt, SKIP. -- **Test Coverage**: Are there tests that might break? If uncertain, SKIP. -- **Context Dependency**: Is this "slop" actually necessary for this specific codebase? (e.g., defensive code for known flaky external API) -- **Readability Trade-off**: Will removal make code LESS readable? If yes, SKIP. - -**RULE**: When in doubt, DO NOT CHANGE. False negatives are better than breaking code. - -### Step 3: Execute Changes -Make changes using Edit tool. One logical change at a time. - -### Step 4: Detailed Report - -**OUTPUT FORMAT**: - -\`\`\` -## AI Slop Removed: {filename} - -### Analysis Summary -- Total issues found: N -- Issues fixed: M -- Issues skipped (safety): K - -### Changes Made - -#### Change 1: [Category] Line X-Y -**Before**: [original code snippet] -**After**: [modified code snippet] -**Why this is slop**: [Explain why this pattern is problematic] -**Why safe to remove**: [Explain why functionality is preserved] -**Impact**: None - purely cosmetic improvement - ---- - -### Skipped Issues (Preserved for Safety) - -#### Skipped 1: Line X -**Reason**: [Why you chose not to change this] - -### Summary -- Removed N obvious comments -- Simplified M defensive patterns -- Flattened K nested structures -- Preserved L patterns that looked like slop but serve purpose -\`\`\` - ---- - -## SAFETY RULES - -1. **NEVER remove error handling for I/O, network, or file operations** -2. **NEVER simplify validation for user input or external data** -3. **NEVER change public API signatures** -4. **NEVER remove type hints (even redundant-looking ones)** -5. **If a pattern appears in multiple places, it might be intentional - ASK before bulk removal** -6. **Preserve all BDD test comments (#given, #when, #then)** - -When finished, your report should be detailed enough that a reviewer can understand EXACTLY what changed and feel confident the changes are safe. - ---- - -## WHEN NO SLOP FOUND - -If the file is clean, report: - -\`\`\` -## AI Slop Analysis: {filename} - -### Result: No AI Slop Detected - -This file is clean. Here's why: - -**Comments**: N comments found, all explain WHY not WHAT -**Defensive Code**: Null checks present are appropriate (e.g., checks external API response) -**Code Structure**: Maximum nesting depth acceptable, early returns used appropriately - -**Conclusion**: This code appears to be human-written or well-reviewed AI code. No changes needed. -\`\`\``, -}