test(cli): batch 105 (25 files)
This commit is contained in:
@@ -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<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<ReadonlySet<string>> {
|
||||
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<string>()
|
||||
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<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory()
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer"
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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<string, string>
|
||||
}
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -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<InstalledPlugin> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<readonly { name: string; path: string; target: string }[]> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!(await exists(join(cwd, "package.json")))) return
|
||||
await runCommand("npm", args, { cwd })
|
||||
}
|
||||
|
||||
async function maybeRunNpmBuild(cwd: string, runCommand: RunCommand): Promise<void> {
|
||||
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<void> {
|
||||
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<readonly { name: string; target: string }[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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>): 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>): 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>): string {
|
||||
const managedAgentNames = new Set<string>(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<boolean> {
|
||||
try {
|
||||
await readFile(path, "utf8")
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return false
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>([
|
||||
["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<readonly TrustedHookState[]> {
|
||||
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, unknown>): string {
|
||||
const timeout = Math.max(Number(handler.timeout ?? 600), 1)
|
||||
const normalizedHandler: Record<string, unknown> = {
|
||||
type: "command",
|
||||
command: handler.command,
|
||||
timeout,
|
||||
async: false,
|
||||
}
|
||||
if (typeof handler.statusMessage === "string") normalizedHandler.statusMessage = handler.statusMessage
|
||||
|
||||
const identity: Record<string, unknown> = { 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<string, unknown> = {}
|
||||
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<boolean> {
|
||||
try {
|
||||
await readFile(path, "utf8")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -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<readonly MarketplaceSnapshotPlugin[]> {
|
||||
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<void> {
|
||||
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<MarketplaceSnapshotPlugin> {
|
||||
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")
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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<MarketplaceManifest> {
|
||||
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<PluginManifest> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,258 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -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<CodexInstallResult> {
|
||||
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<string, { readonly name: string; readonly configFile: string }>()
|
||||
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<ReadonlyMap<string, string>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<readonly LinkedAgent[]> {
|
||||
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<readonly string[]> {
|
||||
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<void> {
|
||||
await prepareReplacement(linkPath)
|
||||
await symlink(target, linkPath)
|
||||
}
|
||||
|
||||
async function replaceWithCopy(linkPath: string, target: string): Promise<void> {
|
||||
await prepareReplacement(linkPath)
|
||||
await copyFile(target, linkPath)
|
||||
}
|
||||
|
||||
async function prepareReplacement(linkPath: string): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await lstat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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, "\\$&")
|
||||
}
|
||||
@@ -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<void>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<critical>
|
||||
**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" }
|
||||
])
|
||||
\`\`\`
|
||||
</critical>
|
||||
|
||||
---
|
||||
|
||||
## 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-agents>
|
||||
**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
|
||||
\`\`\`
|
||||
</dynamic-agents>
|
||||
|
||||
### 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.**
|
||||
|
||||
<critical>
|
||||
**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.
|
||||
</critical>
|
||||
|
||||
### 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`
|
||||
@@ -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.
|
||||
\`\`\``,
|
||||
}
|
||||
Reference in New Issue
Block a user