diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b21873e..006b858 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -8,6 +8,15 @@ "homepage": "https://github.com/Team-Volt/agent-lcm", "license": "MIT", "skills": "./skills/", - "mcpServers": "./mcp.json", + "interface": { + "displayName": "Agent LCM", + "shortDescription": "Use shared local context memory in Codex.", + "longDescription": "Agent LCM captures and recalls coding-agent sessions from one local store.", + "developerName": "Team Volt", + "category": "Developer Tools", + "capabilities": [], + "defaultPrompt": "Recall relevant work from earlier coding sessions." + }, + "mcpServers": "./.mcp.json", "hooks": "./hooks/codex.json" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feadc39..931821e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,5 +45,5 @@ jobs: cache: npm cache-dependency-path: package-lock.json - run: npm ci - - run: node --test tests/distribution.test.ts tests/setup.test.ts + - run: node --test tests/distribution.test.ts tests/setup-adapters.test.ts tests/setup.test.ts - run: node --test --test-name-pattern="raw-log workers with the same PID|hook recovers after its lock-owning worker terminates|concurrent single ingest writers append" tests/storage.test.ts tests/hook-cli.test.ts diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..de2e47d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "agent-lcm": { + "type": "stdio", + "command": "node", + "args": ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index c3a91c4..75d5db9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,36 @@ docs/ architecture and troubleshooting provenance in every result. - Do not add `lcm_record_note`; Agent LCM has no note-writing MCP tool. +## Harness setup and removal + +- `agent-lcm setup ` uses native lifecycle commands only for Codex and + the shared Copilot/VS Code store; Cursor Marketplace and Kiro Powers remain + manual. `agent-lcm remove ` removes only exact Agent LCM-owned hook + entries. +- Setup reports `complete` with exit `0`; `manual-required` and + `shared-retained` use exit `2`; command errors use exit `1`. +- Copilot and VS Code share the native plugin store. Single-harness removal + must retain that plugin and leave any legacy fallback hook file unchanged. +- The repository root manifest declares Agent Plugins 1.0 for Kiro and portable + skills/MCP clients. The npm artifact must omit it so Codex and Cursor select + their native hook manifests. Copilot/VS Code setup installs the generated + native package whose hook and MCP commands use the absolute Agent LCM + executable. +- Successful native Codex setup must not create `~/.codex/hooks.json`; it may + remove only exact Agent LCM fallback entries from an existing file. +- Setup-file mutation runs through the directory-anchored helper. Do not + replace it with path checks followed by later path-based writes. +- Validate existing setup JSON before native work. Preserve unrelated and + near-matching hooks, reject symlinked or non-regular targets, and publish + changes under an atomic `.lock` directory through a unique fsynced + temporary file and rename. +- Keep native CLI argv shell-free. On Windows, resolve npm `.cmd` or `.bat` + shims from `PATH`, reject command-shell metacharacters, and invoke only that + resolved shim through `cmd.exe`. +- If a hook file changes during native work, preserve the new bytes and report + whether the native action completed or setup stopped; never hide it behind a + generic file error. + See `src/AGENTS.md` and `tests/AGENTS.md` for more specific rules. ## Commands diff --git a/README.md b/README.md index 60ee15c..2e6c50b 100644 --- a/README.md +++ b/README.md @@ -71,30 +71,36 @@ you never need to find or reference a harness cache path. ## Install in each harness -Agent Plugins 1.0 defines the package, not one shared installer. Use the native -flow for each harness: - -| Harness | Install | -| --- | --- | -| Codex | `codex plugin marketplace add Team-Volt/agent-lcm`, then `codex plugin add agent-lcm@agent-lcm` | -| GitHub Copilot CLI | `copilot plugin install Team-Volt/agent-lcm` | -| VS Code | Run `Chat: Install Plugin From Source` and enter `https://github.com/Team-Volt/agent-lcm`; VS Code also discovers the Copilot CLI install | -| Cursor | Use `/add-plugin` after Agent LCM is listed in the Cursor Marketplace, or ask an admin to add the repository to your Team Marketplace | -| Kiro IDE | Open Powers, choose the GitHub import option, and enter `https://github.com/Team-Volt/agent-lcm` | - -These flows follow the current [Codex plugin](https://help.openai.com/en/articles/20001256-plugins-in-codex/), -[Copilot CLI plugin](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing), +Each harness selects its own native package format. Run the setup command for +each harness you use, then follow its guide for native installation, trust, and +removal: + +| Harness | Setup command | Guide | +| --- | --- | --- | +| Codex | `agent-lcm setup codex` | [Codex guide](docs/install/codex.md) | +| Cursor | `agent-lcm setup cursor` | [Cursor guide](docs/install/cursor.md) | +| VS Code | `agent-lcm setup vscode` | [VS Code guide](docs/install/vscode.md) | +| GitHub Copilot CLI | `agent-lcm setup copilot` | [Copilot guide](docs/install/copilot.md) | +| Kiro IDE | `agent-lcm setup kiro` | [Kiro guide](docs/install/kiro.md) | + +The guides follow the current [Codex plugin](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md), +[Copilot CLI plugin](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference), [VS Code agent plugin](https://code.visualstudio.com/docs/agent-customization/agent-plugins), -[Cursor marketplace](https://cursor.com/blog/marketplace), and -[Kiro Powers](https://kiro.dev/docs/powers/) documentation. +[Cursor Marketplace](https://cursor.com/marketplace), and +[Kiro Powers](https://kiro.dev/docs/powers/) documentation. If setup cannot run a +supported native command, it reports the guide and uses the manual hook path +when that harness needs one. -Compatible clients discover the same portable components: +The repository root remains an Agent Plugins package for Kiro and other +skills/MCP-only clients: - `skills/lcm-recall/SKILL.md` - the `agent-lcm` stdio server in `mcp.json` -Codex and Cursor compatibility manifests are included for their native plugin -layouts. If a client cannot install the plugin, add this stdio MCP server: +The published npm package omits that root manifest so Codex and Cursor select +their native compatibility manifests, which include hooks. Copilot and VS Code +use the native package generated by setup. If a client cannot install the +plugin, add this stdio MCP server: ```json { @@ -105,14 +111,16 @@ layouts. If a client cannot install the plugin, add this stdio MCP server: The harness must inherit a `PATH` that contains the npm global binary. Native plugin installation is more reliable for GUI apps because it uses the bundled -command. Restart the harness after installation. +command. Use the relevant guide's trust or refresh note after installation; a +restart is not a general requirement documented by every harness. ## Enable automatic capture `agent-lcm setup all` detects the harnesses installed under your home directory -and installs or repairs hooks only for those harnesses. It does not create -configuration directories for clients you do not use. To configure a harness -that setup cannot detect, run its command directly: +and completes native setup where supported, with manual hook wiring only where +that harness needs it. It does not create configuration directories for clients +you do not use. To configure a harness that setup cannot detect, run its +command directly: ```sh agent-lcm setup codex @@ -122,14 +130,15 @@ agent-lcm setup copilot agent-lcm setup kiro ``` -Run only the commands for the harnesses you use. VS Code and GitHub Copilot -share `~/.copilot/hooks/agent-lcm.json`; either setup command installs the same -auto-detecting hooks. Setup preserves unrelated hook entries, is safe to run -again, and writes private files containing the absolute Agent LCM command. If a -target file already exists and needs changes, setup first saves a timestamped -`-pre-agent-lcm-` backup beside it. +Run only the commands for the harnesses you use. A legacy VS Code and GitHub +Copilot fallback may share `~/.copilot/hooks/agent-lcm.json`; native plugin hooks +are loaded from the plugin store instead of being duplicated there. Setup +preserves unrelated hook entries, is safe to run again, and writes private +files containing the absolute Agent LCM command when manual wiring is needed. +If a target file already exists and needs changes, setup first saves a +timestamped `-pre-agent-lcm-` backup beside it. -The user hook locations are: +Legacy or setup-managed user hook locations are: | Harness | Hook file | | --- | --- | @@ -139,13 +148,60 @@ The user hook locations are: | GitHub Copilot | `~/.copilot/hooks/agent-lcm.json` | | Kiro | `~/.kiro/hooks/agent-lcm.json` | -Check the result, then restart each harness: +Codex, Cursor, Copilot, and VS Code native plugins carry their own hooks. Setup +does not add a second user-level copy after native installation. The Codex path +above exists only for older fallback entries, which setup removes after native +installation succeeds. + +Check setup-managed and legacy hook files, then run the broader doctor checks: ```sh agent-lcm setup status agent-lcm doctor --json ``` +Setup and removal print one report per harness. Exit status `0` means the +requested native work and hook work completed. Exit status `2` means a manual +native step remains (`manual-required`) or a shared Copilot resource was +deliberately retained (`shared-retained`). Exit status `1` means the command +failed; inspect stderr before retrying. If stderr says the native action +completed but the hook file could not be updated safely, repair that file and +rerun the same command. A reported concurrent change is left untouched; for +other file errors, inspect the file because publication may have completed. +Add `--json` when a script needs the report fields. + +Native lifecycle support is limited to the commands that each client documents: + +- Codex probes with `codex plugin list`, adds the installed npm package as a + local marketplace, then runs `codex plugin add agent-lcm@agent-lcm`. The npm + artifact omits the portable root manifest so Codex loads the native manifest, + including hooks. Removal runs `codex plugin remove agent-lcm@agent-lcm`. +- GitHub Copilot CLI and VS Code share the Copilot plugin store. Setup probes + with `copilot plugin list`, builds a private native package with absolute + Agent LCM hook and MCP commands, and installs it with `copilot plugin + install`. `agent-lcm remove copilot` and `agent-lcm remove vscode` + return `shared-retained` without uninstalling that shared plugin; use the + documented Copilot uninstall command only after reviewing both clients. +- Cursor and Kiro are probed with `cursor-agent --version` and `kiro-cli + --version`. Neither CLI documents a noninteractive plugin install or removal + command. Cursor must load the native npm package, not the repository-root + Agent Plugin, to get hooks. Kiro uses the repository-root Power and the + separate Kiro hook file. + +Setup validates an existing hook file before invoking a native CLI, preserves +unrelated entries, and changes only exact Agent LCM-owned registrations. It +backs up a changed file as `*-pre-agent-lcm-*.json` and holds an atomic lock +directory at `.lock` for at most ten seconds. A helper process anchors +its working directory to the checked target directory before it reads, backs +up, or publishes through a unique `wx` temporary file, `fsync`, and rename. +Symlinked directory components, lock paths, targets, and non-regular files are +refused. Hook commands must be absolute paths without shell metacharacters. These rules make +repeated setup and removal safe while avoiding a second user-level hook copy +after native installation. A native client and a hook file cannot share one +transaction. If another process changes the hook file during native work, +Agent LCM reports whether the native action completed or setup stopped, then +leaves the changed bytes untouched. + Hooks start the daemon on demand. You can also manage it directly: ```sh diff --git a/dist/cli.js b/dist/cli.js index 43995fb..1c0f496 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -9,7 +9,7 @@ import { runCapture, runHook } from "./hook.js"; import { readStatus } from "./installer.js"; import { startMcpServer } from "./mcp.js"; import { packageVersion } from "./release.js"; -import { setupHarness, setupStatus } from "./setup.js"; +import { removeHarness, setupHarness, setupStatus } from "./setup.js"; import { detectedHarnesses } from "./setup-targets.js"; export async function main(argv) { const [command, ...rest] = argv; @@ -46,9 +46,20 @@ export async function main(argv) { return; } const harness = captureHarness(rest[0]); + const home = optionValue(rest, "--home"); printSetupReports(setupHarness(harness, { - home: optionValue(rest, "--home"), + home, command: commandPath, + ...(home ? { env: lifecycleEnvironment(home) } : {}), + }), rest.includes("--json")); + return; + } + if (command === "remove") { + const harness = captureHarness(rest[0], "remove"); + const home = optionValue(rest, "--home"); + printSetupReports(removeHarness(harness, { + home, + ...(home ? { env: lifecycleEnvironment(home) } : {}), }), rest.includes("--json")); return; } @@ -258,6 +269,7 @@ Commands: agent-lcm setup all agent-lcm setup [--home PATH] agent-lcm setup status + agent-lcm remove [--home PATH] agent-lcm status [--codex-home PATH] [--json] agent-lcm doctor [--codex-home PATH] [--json] Diagnose install, storage, and capture state agent-lcm health [--json] @@ -273,10 +285,10 @@ Commands: agent-lcm import-codex-sessions [--from PATH] [--dry-run] [--progress] [--json] `); } -function captureHarness(value) { +function captureHarness(value, action = "setup") { if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") return value; - throw new Error("Usage: agent-lcm setup [--home PATH]"); + throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); } function importHarness(value) { if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") @@ -307,15 +319,36 @@ function printObjectOrText(value) { function printSetupReports(value, json) { if (json) { printObjectOrText(value); - return; - } - const reports = Array.isArray(value) ? value : [value]; - if (reports.length === 0) { - process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); - return; } - for (const report of reports) { - const state = report.changed ? "have been configured" : "are already configured"; - process.stdout.write(`${report.harness} hooks ${state}: ${report.path}\n`); + else { + const reports = Array.isArray(value) ? value : [value]; + if (reports.length === 0) { + process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); + return; + } + for (const report of reports) { + process.stdout.write(`${report.harness} ${report.action}: ${report.status}\n`); + process.stdout.write(`Hooks ${report.hooks.changed ? "changed" : "unchanged"}: ${report.hooks.path}\n`); + if (report.status === "manual-required") { + process.stdout.write(report.nativeCli === null + ? "Native CLI unavailable.\n" + : `${report.nativeCli} is installed, but it has no supported noninteractive plugin ${report.action} command.\n`); + } + if (report.status !== "complete") + process.stdout.write(`Manual steps: ${report.guide}\n`); + } } + const reports = Array.isArray(value) ? value : [value]; + if (reports.some((report) => report.status !== "complete")) + process.exitCode = 2; +} +function lifecycleEnvironment(home) { + return { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + COPILOT_HOME: home, + AGENT_LCM_HOME: path.join(home, "agent-lcm"), + }; } diff --git a/dist/copilot-plugin.js b/dist/copilot-plugin.js new file mode 100644 index 0000000..cdde464 --- /dev/null +++ b/dist/copilot-plugin.js @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { assertSafeSetupCommand } from "./setup-hook-status.js"; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export function withCopilotPluginSource(command, callback) { + assertSafeSetupCommand(command); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-copilot-plugin-")); + const source = path.join(temporary, "agent-lcm"); + try { + fs.mkdirSync(source, { mode: 0o700 }); + const packageJson = readPackageJson(); + writeJson(path.join(source, "plugin.json"), { + name: "agent-lcm", + version: packageJson.version, + description: packageJson.description, + author: { name: "Team Volt" }, + homepage: "https://github.com/Team-Volt/agent-lcm", + license: "MIT", + skills: "skills/", + hooks: "hooks.json", + mcpServers: ".mcp.json", + }); + writeJson(path.join(source, "hooks.json"), copilotHooks(command)); + writeJson(path.join(source, ".mcp.json"), { + mcpServers: { + "agent-lcm": { type: "stdio", command: "node", args: [command, "mcp"] }, + }, + }); + fs.cpSync(path.join(PACKAGE_ROOT, "skills"), path.join(source, "skills"), { recursive: true }); + return callback(source); + } + finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} +function copilotHooks(command) { + const capture = `node "${command}" capture --harness auto`; + return { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: capture }], + userPromptSubmitted: [{ type: "command", command: capture }], + postToolUse: [{ type: "command", command: capture }], + sessionEnd: [{ type: "command", command: capture }], + }, + }; +} +function readPackageJson() { + const value = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")); + if (!isRecord(value) || typeof value.version !== "string" || typeof value.description !== "string") { + throw new Error("Agent LCM package metadata is invalid."); + } + return { version: value.version, description: value.description }; +} +function writeJson(target, value) { + fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/dist/doctor.js b/dist/doctor.js index 7b878d1..b9121c6 100644 --- a/dist/doctor.js +++ b/dist/doctor.js @@ -35,10 +35,19 @@ function adapterStatus(status) { detail: codexConfigured ? "Codex MCP and hooks are configured." : "Codex MCP or hooks are not configured.", ...(codexConfigured ? {} : { setup_gap: "Install the Agent LCM plugin and restart Codex." }), }, - cursor: setupAdapter("cursor", setups.cursor.configured), - vscode: setupAdapter("vscode", setups.vscode.configured), - copilot: setupAdapter("copilot", setups.copilot.configured), - kiro: setupAdapter("kiro", setups.kiro.configured), + cursor: setupAdapter("cursor", setups.cursor.hooksConfigured), + vscode: nativePluginAdapter("VS Code", setups.vscode.hooksConfigured), + copilot: nativePluginAdapter("Copilot", setups.copilot.hooksConfigured), + kiro: setupAdapter("kiro", setups.kiro.hooksConfigured), + }; +} +function nativePluginAdapter(harness, legacyHooksConfigured) { + if (legacyHooksConfigured) + return setupAdapter(harness.toLowerCase(), true); + return { + configured: null, + state: "unknown", + detail: `${harness} native plugin health is not checked by doctor. Run \`copilot plugin list\` or use the client's installed-plugin view.`, }; } function setupAdapter(harness, configured) { diff --git a/dist/installer.js b/dist/installer.js index 263a4cf..0155d16 100644 --- a/dist/installer.js +++ b/dist/installer.js @@ -9,8 +9,9 @@ export function readStatus(options = {}) { const configText = readOptional(configPath); const hooksText = readOptional(hooksPath); const pluginManifestText = readOptional(path.join(root, ".codex-plugin", "plugin.json")); + const portableManifestText = readOptional(path.join(root, "plugin.json")); const pluginManifestAvailable = pluginManifestText !== undefined; - const mcpManifestAvailable = fs.existsSync(path.join(root, "mcp.json")); + const mcpManifestAvailable = fs.existsSync(path.join(root, ".mcp.json")); const hookManifestAvailable = fs.existsSync(path.join(root, "hooks", "codex.json")); const pluginDeclaresMcp = pluginManifestText !== undefined && /"mcpServers"\s*:/u.test(pluginManifestText); const pluginDeclaresHooks = pluginManifestText !== undefined && /"hooks"\s*:/u.test(pluginManifestText); @@ -19,6 +20,8 @@ export function readStatus(options = {}) { const manualMcpConfigured = configText !== undefined && /mcp_servers\.(?:"agent-lcm"|agent-lcm)|command\s*=\s*".*agent-lcm/u.test(configText); const manualHooksConfigured = hooksText !== undefined && hooksText.includes("agent-lcm"); const pluginOwnedWiringAvailable = pluginConfigured && pluginManifestAvailable; + const nativeHooksSelected = portableManifestText === undefined + || !/"\$schema"\s*:\s*"https:\/\/agent-plugins\.org\/schemas\//u.test(portableManifestText); return { codex_home: home, config_exists: configText !== undefined, @@ -33,7 +36,8 @@ export function readStatus(options = {}) { manual_mcp_configured: manualMcpConfigured, manual_hooks_configured: manualHooksConfigured, mcp_configured: manualMcpConfigured || (pluginOwnedWiringAvailable && pluginDeclaresMcp && mcpManifestAvailable), - hooks_configured: manualHooksConfigured || (pluginOwnedWiringAvailable && pluginDeclaresHooks && hookManifestAvailable), + hooks_configured: manualHooksConfigured + || (pluginOwnedWiringAvailable && nativeHooksSelected && pluginDeclaresHooks && hookManifestAvailable), recall_skill_available: fs.existsSync(path.join(root, "skills", "lcm-recall", "SKILL.md")), }; } diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js new file mode 100644 index 0000000..be250ba --- /dev/null +++ b/dist/setup-adapters.js @@ -0,0 +1,161 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { withCopilotPluginSource } from "./copilot-plugin.js"; +export class NativeLifecycleCommandError extends Error { + name = "NativeLifecycleCommandError"; + executable; + argv; + status; + stderr; + constructor(executable, argv, status, stderr) { + super(`Native lifecycle command failed: executable=${executable} argv=${argv.join(" ")} status=${String(status)} stderr=${stderr}`); + this.executable = executable; + this.argv = argv; + this.status = status; + this.stderr = stderr; + } +} +const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SUPPRESSED_STDERR = "suppressed"; +export const HARNESS_LIFECYCLE_ADAPTERS = { + codex: { + kind: "codex", + executable: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + probeArgv: ["plugin", "list"], + setupArgv: [ + ["plugin", "marketplace", "add", PACKAGE_ROOT], + ["plugin", "add", "agent-lcm@agent-lcm"], + ], + removeArgv: ["plugin", "remove", "agent-lcm@agent-lcm"], + }, + cursor: { kind: "manual", executable: "cursor-agent", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/cursor.md` }, + vscode: { + kind: "copilot", + executable: "copilot", + guide: `${GUIDE_ROOT}/vscode.md`, + probeArgv: ["plugin", "list"], + }, + copilot: { + kind: "copilot", + executable: "copilot", + guide: `${GUIDE_ROOT}/copilot.md`, + probeArgv: ["plugin", "list"], + }, + kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, +}; +export function runHarnessLifecycle(harness, action, options = {}) { + const adapter = HARNESS_LIFECYCLE_ADAPTERS[harness]; + switch (adapter.kind) { + case "manual": + return manualOutcome(harness, action, adapter, options.env); + case "copilot": + if (action === "remove") + return outcome(harness, action, "shared-retained", null, adapter.guide); + return runNative(harness, action, adapter, options.env, options.command); + case "codex": + return runNative(harness, action, adapter, options.env); + default: + return assertNever(adapter); + } +} +function runNative(harness, action, adapter, env, command) { + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); + if (isEnoent(probe.error)) { + return outcome(harness, action, "manual-required", null, adapter.guide); + } + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } + if (action === "setup" && adapter.kind === "copilot") { + withCopilotPluginSource(command ?? path.join(PACKAGE_ROOT, "bin", "agent-lcm"), (source) => { + runNativeCommand(adapter.executable, ["plugin", "install", source], env); + }); + } + else if (adapter.kind === "codex") { + const commands = action === "setup" ? adapter.setupArgv : [adapter.removeArgv]; + for (const argv of commands) + runNativeCommand(adapter.executable, argv, env); + } + else { + throw new Error("Copilot removal must retain the shared plugin."); + } + return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); +} +function manualOutcome(harness, action, adapter, env) { + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); + if (isEnoent(probe.error)) + return outcome(harness, action, "manual-required", null, adapter.guide); + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } + return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); +} +function runNativeCommand(executable, argv, env) { + const result = spawnLifecycleCommand(executable, argv, env); + if (result.status === 0) + return; + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); +} +function spawnLifecycleCommand(executable, argv, env) { + const options = { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }; + const direct = spawnSync(executable, argv, options); + if (process.platform !== "win32" || !needsWindowsShim(direct.error)) + return direct; + const shim = resolveWindowsShim(executable, argv, env); + if (shim === null) + return direct; + assertSafeWindowsCommand([shim, ...argv], executable, argv); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", shim, ...argv], options); + if (isEnoent(result.error)) { + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); + } + return result; +} +function resolveWindowsShim(executable, argv, env) { + const commandEnv = env ?? process.env; + const searchPath = Object.entries(commandEnv).find(([key]) => key.toUpperCase() === "PATH")?.[1]; + if (searchPath === undefined) + return null; + for (const entry of searchPath.split(path.delimiter)) { + const directory = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry; + if (directory.length === 0) + continue; + for (const extension of [".cmd", ".bat"]) { + const candidate = path.join(directory, `${executable}${extension}`); + try { + if (fs.statSync(candidate).isFile()) + return candidate; + } + catch (error) { + if (isMissingPathError(error)) + continue; + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } + } + } + return null; +} +function assertSafeWindowsCommand(values, executable, argv) { + if (values.some((value) => /["&|<>^%!\r\n]/u.test(value))) { + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } +} +function outcome(harness, action, status, nativeCli, guide) { + return { harness, action, status, nativeCli, guide }; +} +function isEnoent(error) { + return error !== undefined && "code" in error && error.code === "ENOENT"; +} +function needsWindowsShim(error) { + return error !== undefined && "code" in error && (error.code === "ENOENT" || error.code === "EINVAL"); +} +function isMissingPathError(error) { + return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"); +} +function assertNever(value) { + throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); +} diff --git a/dist/setup-file-worker.js b/dist/setup-file-worker.js new file mode 100644 index 0000000..80ce171 --- /dev/null +++ b/dist/setup-file-worker.js @@ -0,0 +1,200 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +const BUSY_EXIT = 75; +const MISSING_EXIT = 66; +try { + process.exitCode = main(); +} +catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} +function main() { + const operation = requiredArgument(2); + const name = requiredArgument(3); + const expectedDevice = requiredArgument(4); + const expectedInode = requiredArgument(5); + if (path.basename(name) !== name || name === "." || name === "..") + throw new Error("Invalid setup file name."); + assertDirectoryIdentity(expectedDevice, expectedInode); + switch (operation) { + case "lock": return acquireLock(name); + case "unlock": + fs.rmdirSync(`${name}.lock`); + return 0; + case "read": return writeCurrentFile(name); + case "write": + writeChangedFile(name, requiredArgument(6), requiredArgument(7), fs.readFileSync(0)); + return 0; + default: throw new Error(`Unknown setup file operation: ${operation}`); + } +} +function acquireLock(name) { + const lock = `${name}.lock`; + try { + fs.mkdirSync(lock, { mode: 0o700 }); + return 0; + } + catch (error) { + if (!hasCode(error, "EEXIST")) + throw error; + } + let status; + try { + status = fs.lstatSync(lock); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return BUSY_EXIT; + throw error; + } + if (status.isSymbolicLink()) + throw new Error(`Refusing setup lock symlink: ${path.resolve(lock)}`); + if (!status.isDirectory()) + throw new Error(`Cannot use setup lock that is not a directory: ${path.resolve(lock)}`); + return BUSY_EXIT; +} +function writeCurrentFile(name) { + const bytes = readAnchoredFile(name); + if (bytes === undefined) + return MISSING_EXIT; + process.stdout.write(bytes); + return 0; +} +function writeChangedFile(name, expectedHash, timestamp, next) { + const current = readAnchoredFile(name); + const actualHash = current === undefined ? "missing" : hash(current); + if (actualHash !== expectedHash) + throw new Error(`Setup configuration changed while updating: ${path.resolve(name)}`); + if (current !== undefined) + backupAnchoredFile(name, current, timestamp); + writeAnchoredFile(name, next); +} +function readAnchoredFile(name) { + let pathStatus; + try { + pathStatus = fs.lstatSync(name); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return undefined; + throw error; + } + if (pathStatus.isSymbolicLink()) + throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + if (!pathStatus.isFile()) + throw new Error(`Cannot update setup configuration that is not a regular file: ${path.resolve(name)}`); + let descriptor; + try { + descriptor = fs.openSync(name, fs.constants.O_RDONLY | noFollowFlag()); + } + catch (error) { + if (hasCode(error, "ELOOP")) + throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + throw error; + } + try { + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(name); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${path.resolve(name)}`); + } + return fs.readFileSync(descriptor); + } + finally { + fs.closeSync(descriptor); + } +} +function writeAnchoredFile(name, bytes) { + const temporary = `${name}.${randomUUID()}.tmp`; + let descriptor; + try { + descriptor = fs.openSync(temporary, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, name); + if (process.platform !== "win32") + fsyncDirectory(); + } + catch (error) { + if (descriptor !== undefined) + fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } + catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) + throw new AggregateError([error, cleanupError], "Setup publication and cleanup failed."); + } + throw error; + } +} +function backupAnchoredFile(name, bytes, timestampValue) { + const extension = path.extname(name); + const stem = extension ? name.slice(0, -extension.length) : name; + const timestamp = timestampValue.replace(/[:.]/gu, "-"); + for (let suffix = 0;; suffix += 1) { + const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; + let descriptor; + try { + descriptor = fs.openSync(candidate, "wx", 0o600); + } + catch (error) { + if (hasCode(error, "EEXIST")) + continue; + throw error; + } + try { + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + return; + } + catch (error) { + fs.closeSync(descriptor); + try { + fs.unlinkSync(candidate); + } + catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) + throw new AggregateError([error, cleanupError], "Setup backup and cleanup failed."); + } + throw error; + } + } +} +function assertDirectoryIdentity(expectedDevice, expectedInode) { + const actual = fs.statSync("."); + if (!actual.isDirectory() || String(actual.dev) !== expectedDevice || String(actual.ino) !== expectedInode) { + throw new Error(`Setup directory changed while updating: ${process.cwd()}`); + } +} +function fsyncDirectory() { + const descriptor = fs.openSync(".", "r"); + try { + fs.fsyncSync(descriptor); + } + finally { + fs.closeSync(descriptor); + } +} +function hash(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} +function noFollowFlag() { + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; +} +function requiredArgument(index) { + const value = process.argv[index]; + if (value === undefined) + throw new Error("Missing setup file worker argument."); + return value; +} +function hasCode(error, code) { + return error instanceof Error && Reflect.get(error, "code") === code; +} diff --git a/dist/setup-files.js b/dist/setup-files.js index 93523fc..2b4dc4d 100644 --- a/dist/setup-files.js +++ b/dist/setup-files.js @@ -1,19 +1,68 @@ -import { randomUUID } from "node:crypto"; +import childProcess from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -export function readSetupConfiguration(target) { - let text; - try { - text = fs.readFileSync(target, "utf8"); +import { fileURLToPath } from "node:url"; +const SETUP_LOCK_TIMEOUT_MS = 10_000; +const SETUP_LOCK_POLL_MS = 10; +const SETUP_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); +const WORKER_BUSY_EXIT = 75; +const WORKER_MISSING_EXIT = 66; +const SETUP_FILE_WORKER = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./setup-file-worker.ts" : "./setup-file-worker.js", import.meta.url)); +export class SetupFileLockTimeoutError extends Error { + lockPath; + constructor(lockPath) { + super(`agent-lcm: setup file lock timeout: ${lockPath}`); + this.name = "SetupFileLockTimeoutError"; + this.lockPath = lockPath; } - catch (error) { - if (hasCode(error, "ENOENT")) - return undefined; - throw error; +} +export class SetupConfigurationChangedError extends Error { + target; + constructor(target) { + super(`Setup configuration changed after preflight: ${target}`); + this.name = "SetupConfigurationChangedError"; + this.target = target; } +} +export function mutateSetupConfiguration(target, transform, expectedHash) { + const directory = path.dirname(target); + ensureSetupDirectory(directory); + const directoryIdentity = fs.lstatSync(directory); + if (!directoryIdentity.isDirectory()) + throw new Error(`Setup directory changed while updating: ${directory}`); + return withSetupFileLock(target, directoryIdentity, () => { + const current = readAnchoredSetupFile(target, directoryIdentity); + if (expectedHash !== undefined && setupConfigurationHash(current) !== expectedHash) { + throw new SetupConfigurationChangedError(target); + } + const existing = current ? parseSetupConfiguration(current, target) : undefined; + const next = transform(existing); + if (next === undefined) + return false; + if (existing && JSON.stringify(existing) === JSON.stringify(next)) + return false; + writeAnchoredSetupFile(target, directoryIdentity, current === undefined ? "missing" : createHash("sha256").update(current).digest("hex"), Buffer.from(`${JSON.stringify(next, null, 2)}\n`)); + return true; + }); +} +export function readSetupConfiguration(target) { + return readSetupConfigurationSnapshot(target).configuration; +} +export function readSetupConfigurationSnapshot(target) { + const bytes = readSetupFile(target); + return { + configuration: bytes ? parseSetupConfiguration(bytes, target) : undefined, + hash: setupConfigurationHash(bytes), + }; +} +function setupConfigurationHash(bytes) { + return bytes === undefined ? "missing" : createHash("sha256").update(bytes).digest("hex"); +} +function parseSetupConfiguration(bytes, target) { let value; try { - value = JSON.parse(text); + value = JSON.parse(bytes.toString("utf8")); } catch { throw new Error(`Cannot update invalid setup configuration: ${target}`); @@ -22,38 +71,146 @@ export function readSetupConfiguration(target) { throw new Error(`Cannot update invalid setup configuration: ${target}`); return value; } -export function writeSetupConfiguration(target, configuration) { - const directory = path.dirname(target); - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - fs.chmodSync(directory, 0o700); - const temporary = `${target}.${randomUUID()}.tmp`; - const descriptor = fs.openSync(temporary, "wx", 0o600); +function withSetupFileLock(target, directoryIdentity, callback) { + const lockPath = `${target}.lock`; + const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; + let acquired = false; + let failed = false; + // ponytail: a crashed setup leaves this empty directory; recover it manually + // rather than guessing whether another setup process is still alive. try { - fs.writeFileSync(descriptor, `${JSON.stringify(configuration, null, 2)}\n`); - fs.fsyncSync(descriptor); + while (!acquired) { + const result = runSetupFileWorker("lock", target, directoryIdentity); + if (result.status === 0) + acquired = true; + else if (result.status !== WORKER_BUSY_EXIT) + throw setupFileWorkerError(result); + else if (Date.now() >= deadline) + throw new SetupFileLockTimeoutError(lockPath); + else + Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); + } + return callback(); + } + catch (error) { + failed = true; + throw error; + } + finally { + if (acquired) { + const result = runSetupFileWorker("unlock", target, directoryIdentity); + if (!failed && result.status !== 0) + throw setupFileWorkerError(result); + } + } +} +function readAnchoredSetupFile(target, directoryIdentity) { + const result = runSetupFileWorker("read", target, directoryIdentity); + if (result.status === WORKER_MISSING_EXIT) + return undefined; + if (result.status !== 0) + throw setupFileWorkerError(result); + return result.stdout; +} +function writeAnchoredSetupFile(target, directoryIdentity, expectedHash, bytes) { + const result = runSetupFileWorker("write", target, directoryIdentity, [expectedHash, new Date().toISOString()], bytes); + if (result.status !== 0) + throw setupFileWorkerError(result); +} +function runSetupFileWorker(operation, target, directoryIdentity, extraArguments = [], input) { + const result = childProcess.spawnSync(process.execPath, [ + "--no-warnings", + SETUP_FILE_WORKER, + operation, + path.basename(target), + String(directoryIdentity.dev), + String(directoryIdentity.ino), + ...extraArguments, + ], { + cwd: path.dirname(target), + input, + maxBuffer: 16 * 1024 * 1024, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.error !== undefined) + throw result.error; + return result; +} +function setupFileWorkerError(result) { + const message = result.stderr.toString("utf8").trim(); + return new Error(message || `Setup file worker failed with status ${String(result.status)}.`); +} +function readSetupFile(target) { + let pathStatus; + try { + pathStatus = fs.lstatSync(target); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return undefined; + throw error; + } + if (pathStatus.isSymbolicLink()) + throw new Error(`Refusing setup configuration symlink: ${target}`); + if (!pathStatus.isFile()) + throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); + let descriptor; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); + } + catch (error) { + if (hasCode(error, "ELOOP")) + throw new Error(`Refusing setup configuration symlink: ${target}`); + throw error; + } + try { + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(target); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${target}`); + } + return fs.readFileSync(descriptor); } finally { fs.closeSync(descriptor); } - fs.renameSync(temporary, target); } -export function backupSetupConfiguration(target) { - const extension = path.extname(target); - const stem = extension ? target.slice(0, -extension.length) : target; - const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); - for (let suffix = 0;; suffix += 1) { - const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; +export function ensureSetupDirectory(directory) { + assertSafeDirectoryPath(directory); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertSafeDirectoryPath(directory); +} +function noFollowFlag() { + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; +} +function assertSafeDirectoryPath(directory) { + const resolved = path.resolve(directory); + const root = path.parse(resolved).root; + let current = root; + for (const part of path.relative(root, resolved).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + let status; try { - fs.copyFileSync(target, candidate, fs.constants.COPYFILE_EXCL); - fs.chmodSync(candidate, 0o600); - return; + status = fs.lstatSync(current); } catch (error) { - if (!hasCode(error, "EEXIST")) - throw error; + if (hasCode(error, "ENOENT")) + continue; + throw error; + } + if (status.isSymbolicLink()) { + if (isDarwinSystemAlias(current)) + continue; + throw new Error(`Refusing setup directory symlink: ${current}`); } + if (!status.isDirectory()) + throw new Error(`Cannot use setup path through a non-directory: ${current}`); } } +function isDarwinSystemAlias(target) { + return process.platform === "darwin" && (target === "/etc" || target === "/tmp" || target === "/var"); +} function hasCode(error, code) { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/dist/setup-hook-status.js b/dist/setup-hook-status.js new file mode 100644 index 0000000..8884ce5 --- /dev/null +++ b/dist/setup-hook-status.js @@ -0,0 +1,123 @@ +import path from "node:path"; +export const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"]; +export function setupHooksConfigured(harness, configuration) { + if (!configuration) + return false; + if (harness === "kiro") { + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) + return false; + return eventsFor(harness).every((event) => hooks.some((hook) => isExpectedKiroHook(hook, event))); + } + if (harness !== "codex" && configuration.version !== 1) + return false; + const hooksByEvent = configuration.hooks; + if (!isRecord(hooksByEvent)) + return false; + if (harness === "codex") + return CODEX_EVENTS.every((event) => { + const selectors = hooksByEvent[event]; + return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.some((hook) => isCodexNativeHook(event) + ? isAgentLcmCodexHook(hook, event) + : isExpectedCommandHook(hook, harness, event))); + }); + if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) + return false; + return setupEvents(harness).every(([event, captureEvent]) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); + }); +} +export function eventsFor(harness) { + return isSharedHookHarness(harness) + ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] + : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; +} +export function setupEvents(harness) { + return harness === "cursor" + ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] + : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; +} +export function isSharedHookHarness(harness) { + return harness === "copilot" || harness === "vscode"; +} +export function setupCaptureHarness(harness) { + return isSharedHookHarness(harness) ? "auto" : harness; +} +export function isAgentLcmHook(value, event, harness) { + if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") + return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u + .exec(value.command); + const captureEvent = harness === "cursor" + ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] + : event; + if (!match || match[2] !== captureEvent) + return false; + return isSharedHookHarness(harness) + ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" + : match[1] === harness; +} +export function isAgentLcmCodexHook(value, event) { + if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") + return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); + return match?.[1] === event; +} +export function isCodexNativeHook(event) { + return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; +} +export function isKiroHook(value) { + return isRecord(value) + && typeof value.name === "string" + && typeof value.trigger === "string" + && isRecord(value.action) + && value.action.type === "command" + && typeof value.action.command === "string"; +} +export function assertSafeSetupCommand(command) { + if (!command) + throw new Error("setup command must not be empty"); + if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) + throw new Error("setup command must be an absolute binary path"); + if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) + throw new Error("setup command contains unsafe shell characters"); +} +function isExpectedCommandHook(value, harness, event) { + return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); +} +function hasSharedPascalRegistration(hooksByEvent) { + return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((hook) => isRecord(hook) && hook.type === "command" && isAgentLcmHook(hook, event, "vscode")); + }); +} +function isExpectedKiroHook(value, event) { + return isKiroHook(value) + && value.name === `agent-lcm-kiro-${event}` + && value.trigger === event + && isCaptureCommand(value.action.command, "kiro", event); +} +function isCaptureCommand(value, harness, event) { + if (typeof value !== "string") + return false; + const prefix = 'node "'; + const suffix = ` capture --harness ${harness} ${event}`; + if (!value.startsWith(prefix) || !value.endsWith(suffix)) + return false; + const quoteEnd = value.length - suffix.length - 1; + if (value[quoteEnd] !== "\"") + return false; + try { + assertSafeSetupCommand(value.slice(prefix.length, quoteEnd)); + return true; + } + catch { + return false; + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/dist/setup-hooks.js b/dist/setup-hooks.js new file mode 100644 index 0000000..6104953 --- /dev/null +++ b/dist/setup-hooks.js @@ -0,0 +1,211 @@ +import { CODEX_EVENTS, eventsFor, isAgentLcmCodexHook, isAgentLcmHook, isCodexNativeHook, isKiroHook, isSharedHookHarness, setupCaptureHarness, setupEvents, } from "./setup-hook-status.js"; +export function mergeSetupHooks(existing, harness, command, target) { + if (harness === "kiro") + return mergeKiroConfiguration(existing, command, target); + if (harness === "codex") + return mergeCodexConfiguration(existing, command, target); + return mergeFlatConfiguration(existing, harness, command, target); +} +function mergeCodexConfiguration(existing, command, target) { + const configuration = existing ? structuredClone(existing) : { hooks: {} }; + if (!isRecord(configuration.hooks)) + throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every(isCodexSelectors)) + throw invalidConfiguration(target); + for (const event of CODEX_EVENTS) { + const expectedCommand = isCodexNativeHook(event) + ? `node "${command}" hook ${event}` + : captureCommand(command, "codex", event); + const selectors = configuration.hooks[event]; + if (selectors === undefined) { + configuration.hooks[event] = [{ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }]; + continue; + } + if (!isCodexSelectors(selectors)) + throw invalidConfiguration(target); + let found = false; + for (const selector of selectors) { + if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) + throw invalidConfiguration(target); + for (const hook of selector.hooks) { + if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) + continue; + hook.type = "command"; + hook.command = expectedCommand; + found = true; + } + } + if (!found) + selectors.push({ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }); + } + return configuration; +} +function mergeFlatConfiguration(existing, harness, command, target) { + const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; + if (configuration.version !== 1 || !isRecord(configuration.hooks)) + throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event, captureEvent] of setupEvents(harness)) { + const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); + if (expectedHooks.length === 0) + expectedHooks.push({}); + for (const expected of expectedHooks) { + if (harness !== "cursor") + expected.type = "command"; + expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); + } + const hooks = configuration.hooks[event]; + if (hooks === undefined) + configuration.hooks[event] = expectedHooks; + else + hooks.push(...expectedHooks); + } + return configuration; +} +function mergeKiroConfiguration(existing, command, target) { + const configuration = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { + throw invalidConfiguration(target); + } + const kiroHooks = hooks; + for (const event of eventsFor("kiro")) { + const expected = kiroHook(command, event); + const owned = kiroHooks.filter((hook) => hook.name === expected.name + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro")); + if (owned.length === 0) + kiroHooks.push(expected); + for (const hook of owned) { + hook.action.type = "command"; + hook.action.command = expected.action.command; + } + } + return configuration; +} +function takeAgentLcmHooks(hooksByEvent, harness, event) { + const found = []; + const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; + for (const candidate of candidates) { + const hooks = hooksByEvent[candidate]; + if (!Array.isArray(hooks)) + continue; + const kept = hooks.filter((hook) => { + if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) + return true; + found.push(hook); + return false; + }); + if (kept.length === 0) + delete hooksByEvent[candidate]; + else + hooksByEvent[candidate] = kept; + } + return found; +} +function sharedLegacyEvent(event) { + return { + sessionStart: "SessionStart", + userPromptSubmitted: "UserPromptSubmit", + postToolUse: "PostToolUse", + sessionEnd: "Stop", + }[event] ?? event; +} +function kiroHook(command, event) { + return { + name: `agent-lcm-kiro-${event}`, + trigger: event, + action: { type: "command", command: captureCommand(command, "kiro", event) }, + }; +} +function captureCommand(command, harness, event) { + return `node "${command}" capture --harness ${harness} ${event}`; +} +function isCodexSelectors(value) { + return Array.isArray(value) && value.every((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.every(isRecord)); +} +function invalidConfiguration(target) { + return new Error(`Cannot update invalid setup configuration: ${target}`); +} +export function validateSetupHooks(harness, configuration, target) { + if (configuration !== undefined) + mergeSetupHooks(configuration, harness, "/agent-lcm", target); +} +export function removeSharedSetupHooks(configuration, harness, target) { + const next = structuredClone(configuration); + if (next.version !== 1 || !isRecord(next.hooks) + || !Object.values(next.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event] of setupEvents(harness)) + removeSharedHooks(next.hooks, harness, event); + return next; +} +export function removeSetupHooks(configuration, harness, target) { + validateSetupHooks(harness, configuration, target); + if (harness === "codex") + return removeCodexHooks(configuration); + if (harness === "cursor") + return removeCursorHooks(configuration); + return removeKiroHooks(configuration); +} +function removeCodexHooks(configuration) { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) + return next; + for (const event of CODEX_EVENTS) { + const selectors = next.hooks[event]; + if (!Array.isArray(selectors)) + continue; + for (const selector of selectors) { + if (!isRecord(selector) || !Array.isArray(selector.hooks)) + continue; + selector.hooks = selector.hooks.filter((hook) => isCodexNativeHook(event) + ? !isAgentLcmCodexHook(hook, event) + : !isRecord(hook) || !isAgentLcmHook(hook, event, "codex")); + } + } + return next; +} +function removeCursorHooks(configuration) { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) + return next; + for (const [event] of setupEvents("cursor")) { + const hooks = next.hooks[event]; + if (Array.isArray(hooks)) { + next.hooks[event] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, event, "cursor")); + } + } + return next; +} +function removeKiroHooks(configuration) { + const next = structuredClone(configuration); + if (!Array.isArray(next.hooks)) + return next; + next.hooks = next.hooks.filter((hook) => !isKiroHook(hook) + || !eventsFor("kiro").some((event) => hook.name === `agent-lcm-kiro-${event}` + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro"))); + return next; +} +function removeSharedHooks(hooksByEvent, harness, event) { + for (const candidate of [event, sharedLegacyEvent(event)]) { + const hooks = hooksByEvent[candidate]; + if (Array.isArray(hooks)) + hooksByEvent[candidate] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/dist/setup.js b/dist/setup.js index 53f45af..95572d0 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -1,214 +1,91 @@ import path from "node:path"; -import { backupSetupConfiguration, readSetupConfiguration, writeSetupConfiguration } from "./setup-files.js"; +import { runHarnessLifecycle } from "./setup-adapters.js"; +import { ensureSetupDirectory, mutateSetupConfiguration, readSetupConfiguration, readSetupConfigurationSnapshot, SetupConfigurationChangedError, } from "./setup-files.js"; +import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.js"; +import { mergeSetupHooks, removeSetupHooks, removeSharedSetupHooks, validateSetupHooks, } from "./setup-hooks.js"; import { SETUP_HARNESSES, setupPath } from "./setup-targets.js"; -const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"]; export function setupHarness(harness, options) { const target = setupPath(harness, options.home); const command = options.command.trim(); - assertSafeCommand(command); - const existing = readSetupConfiguration(target); - const next = mergeConfiguration(existing, harness, command, target); - if (existing && JSON.stringify(existing) === JSON.stringify(next)) - return { harness, path: target, changed: false }; - if (existing) - backupSetupConfiguration(target); - writeSetupConfiguration(target, next); - return { harness, path: target, changed: true }; + assertSafeSetupCommand(command); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; + validateSetupHooks(harness, existing, target); + ensureSetupDirectory(path.dirname(target)); + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); + const changed = finishHookUpdate("setup", harness, native.status, target, () => (updateHooks(harness, native.status, target, command, snapshot.hash))); + return { + harness, + action: "setup", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, + }; +} +export function removeHarness(harness, options = {}) { + const target = setupPath(harness, options.home); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; + validateSetupHooks(harness, existing, target); + const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); + const changed = finishHookUpdate("remove", harness, native.status, target, () => (removeHooks(harness, target, existing !== undefined, snapshot.hash))); + return { + harness, + action: "remove", + status: native.status === "native-complete" ? "complete" : native.status, + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, + }; } export function setupStatus(options = {}) { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { const target = setupPath(harness, options.home); - return [harness, { configured: configured(harness, target), path: target }]; + return [harness, { hooksConfigured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })); } -function mergeConfiguration(existing, harness, command, target) { - if (harness === "kiro") - return mergeKiroConfiguration(existing, command, target); - if (harness === "codex") - return mergeCodexConfiguration(existing, command, target); - return mergeFlatConfiguration(existing, harness, command, target); -} -function mergeCodexConfiguration(existing, command, target) { - const configuration = existing ? structuredClone(existing) : { hooks: {} }; - if (!isRecord(configuration.hooks)) - throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every(isCodexSelectors)) - throw invalidConfiguration(target); - for (const event of CODEX_EVENTS) { - const expectedCommand = isCodexNativeHook(event) - ? `node "${command}" hook ${event}` - : captureCommand(command, "codex", event); - const selectors = configuration.hooks[event]; - if (selectors === undefined) { - configuration.hooks[event] = [{ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }]; - continue; +function updateHooks(harness, nativeStatus, target, command, expectedHash) { + if (harness !== "kiro" && nativeStatus !== "native-complete") + return false; + return mutateSetupConfiguration(target, (existing) => { + if (harness === "kiro") + return mergeSetupHooks(existing, harness, command, target); + if (harness === "codex" && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSetupHooks(existing, harness, target); } - if (!isCodexSelectors(selectors)) - throw invalidConfiguration(target); - let found = false; - for (const selector of selectors) { - if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) - throw invalidConfiguration(target); - for (const hook of selector.hooks) { - if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) - continue; - hook.type = "command"; - hook.command = expectedCommand; - found = true; - } + if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSharedSetupHooks(existing, harness, target); } - if (!found) - selectors.push({ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }); - } - return configuration; + return existing; + }, expectedHash); } -function mergeFlatConfiguration(existing, harness, command, target) { - const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; - if (configuration.version !== 1 || !isRecord(configuration.hooks)) - throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { - throw invalidConfiguration(target); - } - for (const [event, captureEvent] of setupEvents(harness)) { - const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); - if (expectedHooks.length === 0) - expectedHooks.push({}); - for (const expected of expectedHooks) { - if (harness !== "cursor") - expected.type = "command"; - expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); - } - const hooks = configuration.hooks[event]; - if (hooks === undefined) - configuration.hooks[event] = expectedHooks; - else - hooks.push(...expectedHooks); - } - return configuration; +function removeHooks(harness, target, targetExists, expectedHash) { + if (harness === "copilot" || harness === "vscode" || (!targetExists && harness !== "codex")) + return false; + return mutateSetupConfiguration(target, (existing) => existing === undefined + ? undefined + : removeSetupHooks(existing, harness, target), expectedHash); } -function mergeKiroConfiguration(existing, command, target) { - const configuration = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { - throw invalidConfiguration(target); +function finishHookUpdate(action, harness, nativeStatus, target, update) { + try { + return update(); } - const kiroHooks = hooks; - for (const event of eventsFor("kiro")) { - const expected = kiroHook(command, event); - const owned = kiroHooks.filter((hook) => hook.name === expected.name - && hook.trigger === event - && isAgentLcmHook(hook.action, event, "kiro")); - if (owned.length === 0) - kiroHooks.push(expected); - for (const hook of owned) { - hook.action.type = "command"; - hook.action.command = expected.action.command; + catch (error) { + if (error instanceof SetupConfigurationChangedError) { + const result = nativeStatus === "native-complete" + ? `Native ${harness} ${action} completed` + : `${harness} ${action} stopped`; + throw new Error(`${result}, but Agent LCM detected a concurrent change to ${target} and did not overwrite it. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); } - } - return configuration; -} -function eventsFor(harness) { - return isSharedHookHarness(harness) - ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] - : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; -} -function setupEvents(harness) { - return harness === "cursor" - ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] - : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; -} -function isSharedHookHarness(harness) { - return harness === "copilot" || harness === "vscode"; -} -function setupCaptureHarness(harness) { - return isSharedHookHarness(harness) ? "auto" : harness; -} -function takeAgentLcmHooks(hooksByEvent, harness, event) { - const found = []; - const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; - for (const candidate of candidates) { - const hooks = hooksByEvent[candidate]; - if (!Array.isArray(hooks)) - continue; - const kept = hooks.filter((hook) => { - if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) - return true; - found.push(hook); - return false; - }); - if (kept.length === 0) - delete hooksByEvent[candidate]; - else - hooksByEvent[candidate] = kept; - } - return found; -} -function sharedLegacyEvent(event) { - return { - sessionStart: "SessionStart", - userPromptSubmitted: "UserPromptSubmit", - postToolUse: "PostToolUse", - sessionEnd: "Stop", - }[event] ?? event; -} -function kiroHook(command, event) { - return { - name: `agent-lcm-kiro-${event}`, - trigger: event, - action: { type: "command", command: captureCommand(command, "kiro", event) }, - }; -} -function captureCommand(command, harness, event) { - return `node "${command}" capture --harness ${harness} ${event}`; -} -function assertSafeCommand(command) { - if (!command) - throw new Error("setup command must not be empty"); - if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) { - throw new Error("setup command must be an absolute binary path"); - } - if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) { - throw new Error("setup command contains unsafe shell characters"); + if (nativeStatus !== "native-complete") + throw error; + throw new Error(`Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` + + `Inspect the hook file because the local update may have completed. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, { cause: error }); } } -function configured(harness, target) { - const configuration = readConfigurationForStatus(target); - if (!configuration) - return false; - if (harness === "kiro") { - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) - return false; - const kiroHooks = hooks; - return eventsFor(harness).every((event) => kiroHooks.some((hook) => isExpectedKiroHook(hook, event))); - } - if (harness !== "codex" && configuration.version !== 1) - return false; - const hooksByEvent = configuration.hooks; - if (!isRecord(hooksByEvent)) - return false; - if (harness === "codex") - return CODEX_EVENTS.every((event) => { - const selectors = hooksByEvent[event]; - return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.some((hook) => isCodexNativeHook(event) - ? isAgentLcmCodexHook(hook, event) - : isExpectedCommandHook(hook, harness, event))); - }); - if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) - return false; - return setupEvents(harness).every(([event, captureEvent]) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); - }); -} function readConfigurationForStatus(target) { try { return readSetupConfiguration(target); @@ -217,84 +94,3 @@ function readConfigurationForStatus(target) { return undefined; } } -function isExpectedCommandHook(value, harness, event) { - return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); -} -function isCodexSelectors(value) { - return Array.isArray(value) && value.every((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.every(isRecord)); -} -function isAgentLcmHook(value, event, harness) { - if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") - return false; - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u - .exec(value.command); - const captureEvent = harness === "cursor" - ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] - : event; - if (!match || match[2] !== captureEvent) - return false; - return isSharedHookHarness(harness) - ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" - : match[1] === harness; -} -function isAgentLcmCodexHook(value, event) { - if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") { - return false; - } - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); - return match?.[1] === event; -} -function isCodexNativeHook(event) { - return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; -} -function hasSharedPascalRegistration(hooksByEvent) { - return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((hook) => { - if (!isRecord(hook) || hook.type !== "command" || typeof hook.command !== "string") - return false; - return isAgentLcmHook(hook, event, "vscode"); - }); - }); -} -function isExpectedKiroHook(value, event) { - return isKiroHook(value) - && value.name === `agent-lcm-kiro-${event}` - && value.trigger === event - && isCaptureCommand(value.action.command, "kiro", event); -} -function isKiroHook(value) { - return isRecord(value) - && typeof value.name === "string" - && typeof value.trigger === "string" - && isRecord(value.action) - && value.action.type === "command" - && typeof value.action.command === "string"; -} -function isCaptureCommand(value, harness, event) { - if (typeof value !== "string") - return false; - const prefix = 'node "'; - const suffix = ` capture --harness ${harness} ${event}`; - if (!value.startsWith(prefix) || !value.endsWith(suffix)) - return false; - const quoteEnd = value.length - suffix.length - 1; - if (value[quoteEnd] !== "\"") - return false; - const command = value.slice(prefix.length, quoteEnd); - try { - assertSafeCommand(command); - return true; - } - catch { - return false; - } -} -function invalidConfiguration(target) { - return new Error(`Cannot update invalid setup configuration: ${target}`); -} -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/docs/architecture.md b/docs/architecture.md index a43b136..b0f5dca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,10 +2,11 @@ ## Package surfaces -The repository root is an Agent Plugins 1.0 package: +The repository contains one shared implementation with client-specific package +surfaces: ```text -plugin.json portable package manifest +plugin.json Kiro and portable skills/MCP manifest mcp.json portable MCP server configuration skills/lcm-recall/ portable recall skill hooks.json shared lower-camel hook shape for client adapters @@ -16,9 +17,11 @@ bin/agent-lcm source and npm CLI entry point dist/ generated npm runtime ``` -Agent Plugins 1.0 standardizes skills and MCP servers. Hooks remain -client-specific, so `agent-lcm setup ` installs the matching capture -configuration when a client does not load a bundled hook manifest. +Agent Plugins 1.0 standardizes skills and MCP servers, not hooks. The npm +artifact therefore omits the root `plugin.json`: Codex and Cursor then select +their native manifests and load bundled hooks. The GitHub repository keeps the +root manifest for Kiro Powers. Copilot and VS Code receive a generated native +package with absolute local commands. The npm package and each native plugin copy can start the same per-user daemon. Daemon protocol compatibility, not package release version, decides whether a @@ -26,6 +29,48 @@ running daemon can be reused. This prevents independently cached plugin versions from replacing one another while preserving orderly replacement for an incompatible protocol. +## Harness lifecycle + +`agent-lcm setup ` probes and, where supported, runs the client's native +plugin commands before updating legacy capture hooks. Codex uses `codex plugin +list`, adds the installed npm package as a local marketplace, then runs `codex +plugin add agent-lcm@agent-lcm`. Its native manifest includes hooks; successful +setup removes only exact older Agent LCM entries from `~/.codex/hooks.json`. +Copilot CLI and VS Code use the shared +Copilot store. Setup generates a private Copilot-format package whose hooks and +MCP config contain the absolute installed Agent LCM command, then installs it +after `copilot plugin list` succeeds. +Cursor and Kiro run version-only probes for `cursor-agent` and `kiro-cli`. +Their Marketplace or Powers steps remain manual, so their native result is +`manual-required`. Cursor loads `.cursor-plugin/plugin.json` from the npm +package; Kiro loads the repository-root Agent Plugin and uses a separate Kiro +hook file because hooks are not portable. + +`agent-lcm remove ` removes only exact Agent LCM-owned legacy hooks. +Codex runs `codex plugin remove agent-lcm@agent-lcm`. Copilot and VS Code share a +native store, so either single-harness removal returns `shared-retained` and +does not invoke an uninstall. Deliberate shared removal remains a documented +manual Copilot action after both clients are reviewed. + +Lifecycle reports use exit status `0` for `complete`, `2` for +`manual-required` or `shared-retained`, and `1` for an error. Existing hook +configuration is validated before native work. Unrelated entries and +near-matching commands remain untouched; only an exact harness/event/command +registration is changed. + +Native client state and local hook JSON cannot share one transaction. If the +hook file changes after preflight while a native command runs, Agent LCM keeps +the changed bytes and exits with an explicit partial-state error. Repair the +file, then rerun the same setup or remove command. + +Setup files use an atomic `.lock` directory (bounded to ten seconds). +A short-lived helper changes into the checked target directory and verifies its +device and inode before it reads, backs up, or publishes. Publication writes a +unique `wx` temporary file with restrictive permissions, fsyncs it, renames it, +and fsyncs the anchored directory. Symlinked or non-regular targets are +refused, hook commands must be absolute and shell-safe, and changed files +receive a collision-safe `-pre-agent-lcm-` backup. + ## Capture and retrieval flow 1. A harness invokes `agent-lcm capture --harness ...` with a lifecycle event. diff --git a/docs/install/codex.md b/docs/install/codex.md new file mode 100644 index 0000000..ad3d767 --- /dev/null +++ b/docs/install/codex.md @@ -0,0 +1,58 @@ +# Install Agent LCM in Codex + +## What setup does + +Run: + +```sh +agent-lcm setup codex +``` + +This runs the Codex native lifecycle against the installed Agent LCM package +directory when the CLI is available and its plugin probe succeeds. The Codex +manifest includes the recall skill, MCP server, and capture hooks. After native +installation succeeds, setup removes only exact Agent LCM entries left by older +versions in `~/.codex/hooks.json` so capture does not run twice. It does not +create that user hook file. If the native probe is unavailable, setup reports +`manual-required` and leaves any existing fallback untouched. + +## Native install and inspection + +Use the documented Codex plugin flow with the installed npm package directory. +`npm root --global` prints the `` part of this path: + +```sh +codex plugin marketplace add /@team-volt/agent-lcm +codex plugin add agent-lcm@agent-lcm +codex plugin list +``` + +Do not type the angle-bracket placeholder as written. The published npm package +omits the repository's portable root manifest so Codex selects +`.codex-plugin/plugin.json`, including its native hooks. Do not substitute a +source checkout: its root `plugin.json` is the Kiro/Agent Plugins package, which +Codex treats as skills and MCP only. The first two commands add the local npm +package and the last command lists installed plugins. See the [Codex plugin installation reference](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md). + +After installation, start a new Codex thread so it picks up the plugin. If +Codex asks you to trust plugin-owned commands, review the commands and approve +them only if you expect them. + +## Remove Agent LCM + +The Agent LCM command removes the native plugin and only exact legacy Agent LCM +entries from `~/.codex/hooks.json`, if that file exists: + +```sh +agent-lcm remove codex +``` + +If the Codex CLI is unavailable, the command removes only those legacy hooks, reports +`manual-required`, and links back here. Finish the native removal with: + +```sh +codex plugin remove agent-lcm@agent-lcm +``` + +Check the native result with `codex plugin list` and `agent-lcm doctor --json`. +`agent-lcm setup status` reports only legacy hook-file state. diff --git a/docs/install/copilot.md b/docs/install/copilot.md new file mode 100644 index 0000000..3688c3b --- /dev/null +++ b/docs/install/copilot.md @@ -0,0 +1,65 @@ +# Install Agent LCM in GitHub Copilot CLI + +## What setup does + +Run: + +```sh +agent-lcm setup copilot +``` + +When the CLI is available, setup builds a private Copilot-format package with +the absolute installed Agent LCM command, then runs `copilot plugin install`. +The package includes the recall skill, capture hooks, and MCP server. Copilot +CLI copies it into its plugin store, so setup removes the temporary source +after installation. Setup does not add duplicate shared hooks. The legacy +fallback path is `~/.copilot/hooks/agent-lcm.json`; setup preserves an existing fallback when +native installation is unavailable, adds no new duplicate, and reports +`manual-required` with this guide. + +## Native install and inspection + +If setup reports that `copilot` is missing, install Copilot CLI using GitHub's +[official install guide](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli), +then rerun setup: + +```sh +agent-lcm setup copilot +copilot plugin list +``` + +Do not use the repository root as a substitute for setup. Its Agent Plugins +manifest contains portable skills and MCP but no hooks. Setup writes the +client-specific hooks and the absolute installed command into a Copilot-format +package. The list command shows the installed plugin. See the +[Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) +for the supported command set. + +The Copilot CLI reference does not require a restart after installation. If a +new plugin is not visible, start a new Copilot session as troubleshooting and +run `copilot plugin list` again. + +`agent-lcm setup status` reports legacy `hooksConfigured` state, not live +native plugin health. `false` is expected after a successful native install; +use `copilot plugin list` for the native check. + +> Warning: GitHub Copilot CLI and VS Code share the native Copilot plugin store. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only Copilot CLI. A legacy `~/.copilot/hooks/agent-lcm.json` fallback, if present, is separate. + +## Remove Agent LCM + +The safe single-harness command retains the shared native plugin: + +```sh +agent-lcm remove copilot +``` + +It reports `shared-retained`, leaves the shared store unchanged, and does not +edit a legacy fallback hook file. To deliberately remove the shared native +installation from both harnesses, run: + +```sh +copilot plugin uninstall agent-lcm +``` + +Review both harnesses before removing the shared hook file. Do not delete +unrelated entries from `~/.copilot/hooks/agent-lcm.json`. diff --git a/docs/install/cursor.md b/docs/install/cursor.md new file mode 100644 index 0000000..bb7213f --- /dev/null +++ b/docs/install/cursor.md @@ -0,0 +1,59 @@ +# Install Agent LCM in Cursor + +## What setup does + +Run: + +```sh +agent-lcm setup cursor +``` + +Cursor plugins package their hooks. Setup probes `cursor-agent --version`, then +validates and preserves any legacy capture file at `~/.cursor/hooks.json`, but +it does not add another copy. Cursor has no documented noninteractive plugin +install or remove command, so setup reports `manual-required` for the native +step. + +## Native install and inspection + +Open Cursor's Customize page and install Agent LCM if it is available in a +marketplace you trust. Until it is listed, Cursor documents loading a plugin +from `~/.cursor/plugins/local`. First run `npm root --global` and confirm the +Agent LCM package exists below the printed directory. Then, only if the target +does not already exist, link it on macOS or Linux: + +```sh +mkdir -p ~/.cursor/plugins/local +ln -s /@team-volt/agent-lcm ~/.cursor/plugins/local/agent-lcm +``` + +Do not type the angle-bracket placeholder as written. On Windows, copy the +installed npm package into +`%USERPROFILE%\.cursor\plugins\local\agent-lcm` only after confirming the +target does not exist. Do not clone the repository root for this step: its +portable manifest contains skills and MCP only, so Cursor would not load the +native hook manifest. Run `Developer: Reload Window`, then verify Agent LCM +under Customize. Cursor's [plugin guide](https://cursor.com/docs/plugins) +documents the local path, both supported manifest formats, and the reload step. + +If Cursor shows a trust prompt, review the plugin source before accepting it. +The Marketplace documentation does not establish a required restart. If the +plugin or hooks look stale, close and reopen Cursor as troubleshooting, then +check the plugin in Customize. `agent-lcm setup status` reports only legacy +hook-file state. + +If an older Agent LCM version already configured `~/.cursor/hooks.json`, run +`agent-lcm remove cursor` immediately before installing the native plugin. That +removes only the legacy Agent LCM entries and avoids running both copies. + +## Remove Agent LCM + +Use Customize to remove a marketplace install. For a local install, move the +`~/.cursor/plugins/local/agent-lcm` directory out of the local plugin folder and +reload Cursor. Then remove only its capture hooks: + +```sh +agent-lcm remove cursor +``` + +The command preserves unrelated entries in `~/.cursor/hooks.json`. diff --git a/docs/install/kiro.md b/docs/install/kiro.md new file mode 100644 index 0000000..02ca49d --- /dev/null +++ b/docs/install/kiro.md @@ -0,0 +1,39 @@ +# Install Agent LCM in Kiro + +## What setup does + +Run: + +```sh +agent-lcm setup kiro +``` + +This probes `kiro-cli --version`, then installs or repairs the Kiro capture hooks at +`~/.kiro/hooks/agent-lcm.json`. Kiro's native Power install remains a manual UI +step, so setup reports `manual-required` for that step. + +## Native install and inspection + +Open [Kiro Powers](https://kiro.dev/docs/powers/) in Kiro or on kiro.dev. Use +the Powers UI to browse the marketplace or install from a GitHub repository, +enter `https://github.com/Team-Volt/agent-lcm`, and click Install. The same +Powers UI shows the installed Power state. For Kiro, the repository root is +intentional: its `plugin.json`, `skills/`, and `mcp.json` form the Power. Hooks +remain in Kiro's separate hook file because Agent Plugins does not define them. + +The official Powers guide does not define a command-line install or a required +restart. If the Power or hooks look stale, close and reopen Kiro as +troubleshooting, then check `agent-lcm setup status`. + +## Remove Agent LCM + +Kiro's official Powers installation page does not publish a CLI or a fixed UI +sequence for uninstalling a Power. Open the Powers panel, select Agent LCM, and +use the removal control shown by your installed Kiro version. Then remove only +the Agent LCM hooks: + +```sh +agent-lcm remove kiro +``` + +The command preserves unrelated entries in `~/.kiro/hooks/agent-lcm.json`. diff --git a/docs/install/vscode.md b/docs/install/vscode.md new file mode 100644 index 0000000..930ce48 --- /dev/null +++ b/docs/install/vscode.md @@ -0,0 +1,68 @@ +# Install Agent LCM in VS Code + +## What setup does + +Run: + +```sh +agent-lcm setup vscode +``` + +When Copilot CLI is available, setup builds a private Copilot-format package +with the absolute installed Agent LCM command and installs it into the shared +plugin store. The package includes the recall skill, capture hooks, and MCP +server. VS Code discovers that store, so setup does not add duplicate shared +hooks after native installation. The legacy +fallback path is `~/.copilot/hooks/agent-lcm.json`; setup preserves an existing +fallback when native installation is unavailable, adds no new duplicate, and +reports `manual-required` with this guide. + +## Native install and inspection + +VS Code automatically discovers plugins installed by Copilot CLI from +`~/.copilot/installed-plugins/`. If setup reports that `copilot` is missing, +install Copilot CLI using GitHub's [official install guide](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli), +then rerun: + +```sh +agent-lcm setup vscode +copilot plugin list +``` + +Do not use the repository root from the VS Code command palette as a substitute +for setup. VS Code detects its Agent Plugins manifest, whose portable +components do not include hooks. Setup creates the Copilot-format package that +includes capture hooks and absolute local commands. You can +inspect, enable, disable, or uninstall it in VS Code's Agent Plugins view. See +the [VS Code agent plugin guide](https://code.visualstudio.com/docs/agent-customization/agent-plugins). + +If VS Code asks you to trust a new marketplace or repository, review the source +before confirming. The official guide does not require a restart. If the plugin +does not appear, use the documented Installed view and Command Palette refresh +actions as troubleshooting. + +`agent-lcm setup status` reports legacy `hooksConfigured` state, not live +native plugin health. `false` is expected after a successful native install; +use the Agent Plugins - Installed view or `copilot plugin list` for the native +check. + +> Warning: VS Code and GitHub Copilot share the native Copilot plugin store. A deliberate native uninstall affects both harnesses. Do not uninstall the shared plugin when you mean to remove only VS Code. A legacy `~/.copilot/hooks/agent-lcm.json` fallback, if present, is separate. + +## Remove Agent LCM + +The safe single-harness command retains the shared native plugin: + +```sh +agent-lcm remove vscode +``` + +It reports `shared-retained`, leaves the shared store unchanged, and does not +edit a legacy fallback hook file. To deliberately remove the shared native +installation from both harnesses, use the Copilot command: + +```sh +copilot plugin uninstall agent-lcm +``` + +Then manage any legacy fallback hook file only after reviewing both harnesses' +needs. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 70fc1c8..bc1633b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -10,7 +10,8 @@ agent-lcm daemon status `doctor` checks Codex plugin wiring, the recall skill, the shared daemon, the capture queue, quarantine, SQLite, and summary indexing. `setup status` reports -the harness hook files separately. +`hooksConfigured` for setup-managed or legacy hook files; it does not claim to +check native plugin health. ## The MCP server is missing @@ -32,7 +33,7 @@ prefer native plugin installation when that happens. ## Hooks are not capturing -Install or repair the harness hook file, then restart the harness: +Install or repair Agent LCM, then restart the harness: ```sh agent-lcm setup all @@ -45,13 +46,16 @@ home, configure it explicitly and pass that directory: agent-lcm setup codex --home /path/to/codex-home ``` -Codex loads `~/.codex/hooks.json`; Cursor loads `~/.cursor/hooks.json`. VS Code and GitHub Copilot share -`~/.copilot/hooks/agent-lcm.json`, and the generated hook detects which one sent -the event. Setup refuses malformed existing JSON instead of overwriting it. -Before changing a valid existing file, setup saves a timestamped -`-pre-agent-lcm-` backup in the same directory. +Codex loads hooks from its native plugin; `~/.codex/hooks.json` is only an older +fallback. Kiro uses `~/.kiro/hooks/agent-lcm.json`. Cursor, VS Code, and GitHub +Copilot native plugins carry their own hooks. Cursor setup preserves an older +`~/.cursor/hooks.json` fallback until native installation is complete. A +successful Copilot or VS Code native setup removes only exact older Agent LCM +entries from the shared fallback so capture does not run twice. Setup refuses +malformed existing JSON instead of overwriting it. Before changing a valid +existing file, setup saves a timestamped `-pre-agent-lcm-` backup beside it. -Codex and Cursor may ask you to review or trust plugin-owned commands. Capture +Codex, Cursor, VS Code, and Copilot may ask you to review or trust plugin-owned commands. Capture will not run until the harness allows those hooks. Check whether events reach the queue and daemon: @@ -66,6 +70,60 @@ A nonzero `queue_depth` means capture succeeded but the daemon has not drained the inbox. A nonzero `quarantine_count` means the daemon rejected one or more queue records; inspect `~/.agent-lcm/quarantine/` before removing them. +## Setup or removal needs manual work + +Run the harness-specific command and read its report: + +```sh +agent-lcm setup codex +agent-lcm setup copilot +agent-lcm setup vscode +agent-lcm setup cursor +agent-lcm setup kiro +agent-lcm remove codex +``` + +Replace `codex` with the harness you want to remove. + +Exit status `0` means the requested work completed. Exit status `2` means the +native step is `manual-required`, or Copilot/VS Code removal returned +`shared-retained` so the shared plugin was left in place. Exit status `1` means +the command failed; Agent LCM reports the fixed command, exit status, and a +suppressed-stderr marker so a client cannot leak secrets into logs. Use +`--json` for stable automation fields. + +Codex setup probes `codex plugin list`, then runs the marketplace-add and +plugin-add commands against the installed npm package. That package omits the +portable root manifest so Codex selects its native hook manifest. Removal runs +`codex plugin remove agent-lcm@agent-lcm`. +Copilot and VS Code probe and install through `copilot plugin`; they share the +same native plugin store, so either `agent-lcm remove copilot` or `agent-lcm +remove vscode` is intentionally conservative and does not uninstall the shared +plugin. A legacy `~/.copilot/hooks/agent-lcm.json` fallback is separate and is +left unchanged. Review both clients before using the documented Copilot +uninstall command. Cursor Marketplace and Kiro Powers installation/removal +stay manual. + +Setup validates the existing JSON before starting a native CLI. It changes only +exact Agent LCM-owned hook entries and preserves unrelated or near-matching +entries. A changed file gets a collision-safe `-pre-agent-lcm-` backup. Setup +also refuses symlinked directory components, lock paths, targets, and +non-regular files. It uses an atomic lock directory at `.lock`, with a +ten-second bound, plus unique, fsynced +temporary publication; a predictable temporary symlink cannot redirect the +write. Hook commands must use an absolute shell-safe binary path. If validation +fails, the original file and native CLI invocation remain unchanged. + +If another process changes the hook file after that preflight while a native +command is running, Agent LCM does not overwrite the new bytes. It exits `1` +and states whether the native action completed or setup stopped. Repair the +named file if needed, then rerun the same `agent-lcm setup ` or +`agent-lcm remove ` command. + +If setup times out on `.lock`, first confirm that no Agent LCM setup or +remove process is running. You may then remove that empty lock directory and +retry. Do not remove it while another process is active. + ## Isolate a storage problem Use a temporary home so tests do not touch your normal store: diff --git a/package.json b/package.json index a14c5dc..637e26d 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ ".agents/plugins/marketplace.json", ".codex-plugin/", ".cursor-plugin/", + ".mcp.json", "bin/", "dist/", "hooks/", "skills/", "hooks.json", "mcp.cursor.json", - "mcp.json", - "plugin.json" + "mcp.json" ], "scripts": { "compile": "tsc --project tsconfig.build.json", diff --git a/plugin.json b/plugin.json index f0c1cc7..55ddf55 100644 --- a/plugin.json +++ b/plugin.json @@ -3,5 +3,9 @@ "name": "agent-lcm", "version": "0.0.6", "description": "Shared local context memory for agent harnesses.", + "author": { + "name": "Team Volt" + }, + "keywords": ["agent-memory", "context", "recall", "sessions"], "homepage": "https://github.com/Team-Volt/agent-lcm" } diff --git a/src/AGENTS.md b/src/AGENTS.md index 7124625..bf485c7 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -10,6 +10,8 @@ - `storage-graph.ts` derives bounded graph slices from indexed events and summary lineage; it does not persist a graph projection. - `overflow.ts` owns bounded, content-addressed overflow storage and recovery checks. +- `setup.ts`, `setup-adapters.ts`, `setup-hooks.ts`, and `setup-hook-status.ts` own harness lifecycle reports, native CLI adapters, exact-owned hook edits, and setup status. `setup-files.ts` owns validation, per-file locks, backups, and atomic setup-file publication. + ## Storage invariants - Sanitize and normalize input before inbox publication. Only the daemon drains inbox files into storage. @@ -39,6 +41,21 @@ - Do not let a derived-index transaction cover raw appends, or keep the raw-log lock during expensive work. - Do not turn a read path into an implicit migration or backfill. +## Harness setup safety + +- Probe and invoke only documented commands: Codex uses `codex plugin`; Copilot and VS Code use the shared `copilot plugin` store; Cursor and Kiro use version-only probes and keep plugin changes manual. +- Spawn native executables directly. On Windows only, resolve npm `.cmd` or `.bat` shims from `PATH`, reject command-shell metacharacters, and pass the resolved shim through `cmd.exe` with the shell option still disabled. +- Codex and Cursor native hooks depend on the packed npm artifact omitting the repository-root Agent Plugins manifest. Never add `plugin.json` back to `package.json#files` without redesigning native package selection. +- Native Codex setup removes exact legacy fallback hooks after install and never creates a user hook file. +- Generate the Copilot-format package at setup time so hooks and MCP use the validated absolute Agent LCM command. Keep its source basename `agent-lcm` so repeat direct installs update one native plugin. +- Validate existing setup JSON before starting a native process. Match owned hooks by harness, event, and command shape; preserve unrelated and near-matching entries. +- Native lifecycle state and hook JSON are not one transaction. If the file changes during native work, preserve its bytes and throw an explicit error that says whether the native action completed or setup stopped, then tells the user to repair and rerun. +- Keep setup-file reads, backups, locks, and publication inside the helper process anchored to the validated target directory. A later path identity check does not make a path-based write safe. Refuse symlinked directory components, lock paths, targets, and non-regular files. Use a unique `wx` temporary file, restrictive permissions, fsync, rename, and directory fsync. Backups use the collision-safe `-pre-agent-lcm-` name. +- Build native plugin sources only from the current local package, never from a mutable remote ref. Treat only `ENOENT` as an unavailable CLI; all other native probe or command failures must stop before hook mutation and must not echo client stderr. +- Require an absolute hook binary path and reject shell metacharacters before writing configuration. +- Never uninstall the shared Copilot plugin for a single `copilot` or `vscode` removal; report `shared-retained` instead. +- `setup status` reports legacy/setup-managed `hooksConfigured` state only. Doctor must report native Copilot/VS Code health as unknown unless it has direct native evidence; it must not recommend setup from a missing legacy hook file. + ## Test routing - Storage, raw durability, reconciliation, locks, read-only behavior, cleanup, and overflow: `tests/storage.test.ts`. diff --git a/src/cli.ts b/src/cli.ts index 6d0ffc6..95340ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import type { CaptureHarness } from "./harnesses.ts"; import { readStatus } from "./installer.ts"; import { startMcpServer } from "./mcp.ts"; import { packageVersion } from "./release.ts"; -import { setupHarness, setupStatus, type SetupReport } from "./setup.ts"; +import { removeHarness, setupHarness, setupStatus, type RemoveReport, type SetupReport } from "./setup.ts"; import { detectedHarnesses } from "./setup-targets.ts"; type DaemonCliParams = @@ -87,9 +87,20 @@ export async function main(argv: string[]): Promise { return; } const harness = captureHarness(rest[0]); + const home = optionValue(rest, "--home"); printSetupReports(setupHarness(harness, { - home: optionValue(rest, "--home"), + home, command: commandPath, + ...(home ? { env: lifecycleEnvironment(home) } : {}), + }), rest.includes("--json")); + return; + } + if (command === "remove") { + const harness = captureHarness(rest[0], "remove"); + const home = optionValue(rest, "--home"); + printSetupReports(removeHarness(harness, { + home, + ...(home ? { env: lifecycleEnvironment(home) } : {}), }), rest.includes("--json")); return; } @@ -300,6 +311,7 @@ Commands: agent-lcm setup all agent-lcm setup [--home PATH] agent-lcm setup status + agent-lcm remove [--home PATH] agent-lcm status [--codex-home PATH] [--json] agent-lcm doctor [--codex-home PATH] [--json] Diagnose install, storage, and capture state agent-lcm health [--json] @@ -316,9 +328,9 @@ Commands: `); } -function captureHarness(value: string | undefined): CaptureHarness { +function captureHarness(value: string | undefined, action: "setup" | "remove" = "setup"): CaptureHarness { if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") return value; - throw new Error("Usage: agent-lcm setup [--home PATH]"); + throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); } function importHarness(value: string | undefined): ImportHarness { @@ -346,18 +358,37 @@ function printObjectOrText(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -function printSetupReports(value: SetupReport | SetupReport[], json: boolean): void { +function printSetupReports(value: SetupReport | RemoveReport | SetupReport[], json: boolean): void { if (json) { printObjectOrText(value); - return; + } else { + const reports = Array.isArray(value) ? value : [value]; + if (reports.length === 0) { + process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); + return; + } + for (const report of reports) { + process.stdout.write(`${report.harness} ${report.action}: ${report.status}\n`); + process.stdout.write(`Hooks ${report.hooks.changed ? "changed" : "unchanged"}: ${report.hooks.path}\n`); + if (report.status === "manual-required") { + process.stdout.write(report.nativeCli === null + ? "Native CLI unavailable.\n" + : `${report.nativeCli} is installed, but it has no supported noninteractive plugin ${report.action} command.\n`); + } + if (report.status !== "complete") process.stdout.write(`Manual steps: ${report.guide}\n`); + } } const reports = Array.isArray(value) ? value : [value]; - if (reports.length === 0) { - process.stdout.write("No supported harnesses were detected. Configure one with agent-lcm setup .\n"); - return; - } - for (const report of reports) { - const state = report.changed ? "have been configured" : "are already configured"; - process.stdout.write(`${report.harness} hooks ${state}: ${report.path}\n`); - } + if (reports.some((report) => report.status !== "complete")) process.exitCode = 2; +} + +function lifecycleEnvironment(home: string): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + COPILOT_HOME: home, + AGENT_LCM_HOME: path.join(home, "agent-lcm"), + }; } diff --git a/src/copilot-plugin.ts b/src/copilot-plugin.ts new file mode 100644 index 0000000..d0f0028 --- /dev/null +++ b/src/copilot-plugin.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { assertSafeSetupCommand } from "./setup-hook-status.ts"; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function withCopilotPluginSource(command: string, callback: (source: string) => T): T { + assertSafeSetupCommand(command); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-copilot-plugin-")); + const source = path.join(temporary, "agent-lcm"); + try { + fs.mkdirSync(source, { mode: 0o700 }); + const packageJson = readPackageJson(); + writeJson(path.join(source, "plugin.json"), { + name: "agent-lcm", + version: packageJson.version, + description: packageJson.description, + author: { name: "Team Volt" }, + homepage: "https://github.com/Team-Volt/agent-lcm", + license: "MIT", + skills: "skills/", + hooks: "hooks.json", + mcpServers: ".mcp.json", + }); + writeJson(path.join(source, "hooks.json"), copilotHooks(command)); + writeJson(path.join(source, ".mcp.json"), { + mcpServers: { + "agent-lcm": { type: "stdio", command: "node", args: [command, "mcp"] }, + }, + }); + fs.cpSync(path.join(PACKAGE_ROOT, "skills"), path.join(source, "skills"), { recursive: true }); + return callback(source); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +function copilotHooks(command: string): Record { + const capture = `node "${command}" capture --harness auto`; + return { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: capture }], + userPromptSubmitted: [{ type: "command", command: capture }], + postToolUse: [{ type: "command", command: capture }], + sessionEnd: [{ type: "command", command: capture }], + }, + }; +} + +function readPackageJson(): { readonly version: string; readonly description: string } { + const value: unknown = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")); + if (!isRecord(value) || typeof value.version !== "string" || typeof value.description !== "string") { + throw new Error("Agent LCM package metadata is invalid."); + } + return { version: value.version, description: value.description }; +} + +function writeJson(target: string, value: unknown): void { + fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/doctor.ts b/src/doctor.ts index db9653f..f38043e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -20,8 +20,8 @@ export type DoctorReport = { }; export type AdapterStatus = { - configured: boolean; - state: "configured" | "not_configured"; + configured: boolean | null; + state: "configured" | "not_configured" | "unknown"; detail: string; setup_gap?: string; }; @@ -117,10 +117,19 @@ function adapterStatus(status: Record): Record; + +export function runHarnessLifecycle( + harness: CaptureHarness, + action: HarnessLifecycleAction, + options: { readonly env?: NodeJS.ProcessEnv; readonly command?: string } = {}, +): HarnessLifecycleOutcome { + const adapter = HARNESS_LIFECYCLE_ADAPTERS[harness]; + switch (adapter.kind) { + case "manual": + return manualOutcome(harness, action, adapter, options.env); + case "copilot": + if (action === "remove") return outcome(harness, action, "shared-retained", null, adapter.guide); + return runNative(harness, action, adapter, options.env, options.command); + case "codex": + return runNative(harness, action, adapter, options.env); + default: + return assertNever(adapter); + } +} + +function runNative( + harness: CaptureHarness, + action: HarnessLifecycleAction, + adapter: CodexLifecycleAdapter | CopilotLifecycleAdapter, + env: NodeJS.ProcessEnv | undefined, + command?: string, +): HarnessLifecycleOutcome { + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); + if (isEnoent(probe.error)) { + return outcome(harness, action, "manual-required", null, adapter.guide); + } + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } + + if (action === "setup" && adapter.kind === "copilot") { + withCopilotPluginSource(command ?? path.join(PACKAGE_ROOT, "bin", "agent-lcm"), (source) => { + runNativeCommand(adapter.executable, ["plugin", "install", source], env); + }); + } else if (adapter.kind === "codex") { + const commands = action === "setup" ? adapter.setupArgv : [adapter.removeArgv]; + for (const argv of commands) runNativeCommand(adapter.executable, argv, env); + } else { + throw new Error("Copilot removal must retain the shared plugin."); + } + return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); +} + +function manualOutcome( + harness: CaptureHarness, + action: HarnessLifecycleAction, + adapter: ManualLifecycleAdapter, + env: NodeJS.ProcessEnv | undefined, +): HarnessLifecycleOutcome { + const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); + if (isEnoent(probe.error)) return outcome(harness, action, "manual-required", null, adapter.guide); + if (probe.error !== undefined || probe.status !== 0) { + throw new NativeLifecycleCommandError(adapter.executable, adapter.probeArgv, probe.status, SUPPRESSED_STDERR); + } + return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); +} + +function runNativeCommand(executable: "codex" | "copilot", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { + const result = spawnLifecycleCommand(executable, argv, env); + if (result.status === 0) return; + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); +} + +function spawnLifecycleCommand(executable: HarnessCli, argv: readonly string[], env: NodeJS.ProcessEnv | undefined) { + const options: SpawnSyncOptionsWithStringEncoding = { encoding: "utf8", env, shell: false, stdio: ["ignore", "pipe", "pipe"] }; + const direct = spawnSync(executable, argv, options); + if (process.platform !== "win32" || !needsWindowsShim(direct.error)) return direct; + + const shim = resolveWindowsShim(executable, argv, env); + if (shim === null) return direct; + assertSafeWindowsCommand([shim, ...argv], executable, argv); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/c", shim, ...argv], options); + if (isEnoent(result.error)) { + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); + } + return result; +} + +function resolveWindowsShim(executable: HarnessCli, argv: readonly string[], env: NodeJS.ProcessEnv | undefined): string | null { + const commandEnv = env ?? process.env; + const searchPath = Object.entries(commandEnv).find(([key]) => key.toUpperCase() === "PATH")?.[1]; + if (searchPath === undefined) return null; + for (const entry of searchPath.split(path.delimiter)) { + const directory = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry; + if (directory.length === 0) continue; + for (const extension of [".cmd", ".bat"] as const) { + const candidate = path.join(directory, `${executable}${extension}`); + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch (error) { + if (isMissingPathError(error)) continue; + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } + } + } + return null; +} + +function assertSafeWindowsCommand(values: readonly string[], executable: HarnessCli, argv: readonly string[]): void { + if (values.some((value) => /["&|<>^%!\r\n]/u.test(value))) { + throw new NativeLifecycleCommandError(executable, argv, null, SUPPRESSED_STDERR); + } +} + +function outcome( + harness: CaptureHarness, + action: HarnessLifecycleAction, + status: HarnessLifecycleOutcome["status"], + nativeCli: HarnessLifecycleOutcome["nativeCli"], + guide: string, +): HarnessLifecycleOutcome { + return { harness, action, status, nativeCli, guide }; +} + +function isEnoent(error: Error | undefined): boolean { + return error !== undefined && "code" in error && error.code === "ENOENT"; +} + +function needsWindowsShim(error: Error | undefined): boolean { + return error !== undefined && "code" in error && (error.code === "ENOENT" || error.code === "EINVAL"); +} + +function isMissingPathError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"); +} + +function assertNever(value: never): never { + throw new Error(`Unexpected lifecycle adapter: ${JSON.stringify(value)}`); +} diff --git a/src/setup-file-worker.ts b/src/setup-file-worker.ts new file mode 100644 index 0000000..f21549e --- /dev/null +++ b/src/setup-file-worker.ts @@ -0,0 +1,182 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const BUSY_EXIT = 75; +const MISSING_EXIT = 66; + +try { + process.exitCode = main(); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} + +function main(): number { + const operation = requiredArgument(2); + const name = requiredArgument(3); + const expectedDevice = requiredArgument(4); + const expectedInode = requiredArgument(5); + if (path.basename(name) !== name || name === "." || name === "..") throw new Error("Invalid setup file name."); + assertDirectoryIdentity(expectedDevice, expectedInode); + + switch (operation) { + case "lock": return acquireLock(name); + case "unlock": fs.rmdirSync(`${name}.lock`); return 0; + case "read": return writeCurrentFile(name); + case "write": writeChangedFile(name, requiredArgument(6), requiredArgument(7), fs.readFileSync(0)); return 0; + default: throw new Error(`Unknown setup file operation: ${operation}`); + } +} + +function acquireLock(name: string): number { + const lock = `${name}.lock`; + try { + fs.mkdirSync(lock, { mode: 0o700 }); + return 0; + } catch (error) { + if (!hasCode(error, "EEXIST")) throw error; + } + let status: fs.Stats; + try { + status = fs.lstatSync(lock); + } catch (error) { + if (hasCode(error, "ENOENT")) return BUSY_EXIT; + throw error; + } + if (status.isSymbolicLink()) throw new Error(`Refusing setup lock symlink: ${path.resolve(lock)}`); + if (!status.isDirectory()) throw new Error(`Cannot use setup lock that is not a directory: ${path.resolve(lock)}`); + return BUSY_EXIT; +} + +function writeCurrentFile(name: string): number { + const bytes = readAnchoredFile(name); + if (bytes === undefined) return MISSING_EXIT; + process.stdout.write(bytes); + return 0; +} + +function writeChangedFile(name: string, expectedHash: string, timestamp: string, next: Buffer): void { + const current = readAnchoredFile(name); + const actualHash = current === undefined ? "missing" : hash(current); + if (actualHash !== expectedHash) throw new Error(`Setup configuration changed while updating: ${path.resolve(name)}`); + if (current !== undefined) backupAnchoredFile(name, current, timestamp); + writeAnchoredFile(name, next); +} + +function readAnchoredFile(name: string): Buffer | undefined { + let pathStatus: fs.Stats; + try { + pathStatus = fs.lstatSync(name); + } catch (error) { + if (hasCode(error, "ENOENT")) return undefined; + throw error; + } + if (pathStatus.isSymbolicLink()) throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + if (!pathStatus.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${path.resolve(name)}`); + let descriptor: number; + try { + descriptor = fs.openSync(name, fs.constants.O_RDONLY | noFollowFlag()); + } catch (error) { + if (hasCode(error, "ELOOP")) throw new Error(`Refusing setup configuration symlink: ${path.resolve(name)}`); + throw error; + } + try { + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(name); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${path.resolve(name)}`); + } + return fs.readFileSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function writeAnchoredFile(name: string, bytes: Buffer): void { + const temporary = `${name}.${randomUUID()}.tmp`; + let descriptor: number | undefined; + try { + descriptor = fs.openSync(temporary, "wx", 0o600); + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.renameSync(temporary, name); + if (process.platform !== "win32") fsyncDirectory(); + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) throw new AggregateError([error, cleanupError], "Setup publication and cleanup failed."); + } + throw error; + } +} + +function backupAnchoredFile(name: string, bytes: Buffer, timestampValue: string): void { + const extension = path.extname(name); + const stem = extension ? name.slice(0, -extension.length) : name; + const timestamp = timestampValue.replace(/[:.]/gu, "-"); + for (let suffix = 0; ; suffix += 1) { + const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; + let descriptor: number; + try { + descriptor = fs.openSync(candidate, "wx", 0o600); + } catch (error) { + if (hasCode(error, "EEXIST")) continue; + throw error; + } + try { + fs.fchmodSync(descriptor, 0o600); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + return; + } catch (error) { + fs.closeSync(descriptor); + try { + fs.unlinkSync(candidate); + } catch (cleanupError) { + if (!hasCode(cleanupError, "ENOENT")) throw new AggregateError([error, cleanupError], "Setup backup and cleanup failed."); + } + throw error; + } + } +} + +function assertDirectoryIdentity(expectedDevice: string, expectedInode: string): void { + const actual = fs.statSync("."); + if (!actual.isDirectory() || String(actual.dev) !== expectedDevice || String(actual.ino) !== expectedInode) { + throw new Error(`Setup directory changed while updating: ${process.cwd()}`); + } +} + +function fsyncDirectory(): void { + const descriptor = fs.openSync(".", "r"); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function hash(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function noFollowFlag(): number { + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; +} + +function requiredArgument(index: number): string { + const value = process.argv[index]; + if (value === undefined) throw new Error("Missing setup file worker argument."); + return value; +} + +function hasCode(error: unknown, code: string): boolean { + return error instanceof Error && Reflect.get(error, "code") === code; +} diff --git a/src/setup-files.ts b/src/setup-files.ts index c8755db..d7121da 100644 --- a/src/setup-files.ts +++ b/src/setup-files.ts @@ -1,18 +1,92 @@ -import { randomUUID } from "node:crypto"; +import childProcess from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; -export function readSetupConfiguration(target: string): Record | undefined { - let text: string; - try { - text = fs.readFileSync(target, "utf8"); - } catch (error) { - if (hasCode(error, "ENOENT")) return undefined; - throw error; +const SETUP_LOCK_TIMEOUT_MS = 10_000; +const SETUP_LOCK_POLL_MS = 10; +const SETUP_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); +const WORKER_BUSY_EXIT = 75; +const WORKER_MISSING_EXIT = 66; +const SETUP_FILE_WORKER = fileURLToPath(new URL( + import.meta.url.endsWith(".ts") ? "./setup-file-worker.ts" : "./setup-file-worker.js", + import.meta.url, +)); + +export class SetupFileLockTimeoutError extends Error { + readonly lockPath: string; + + constructor(lockPath: string) { + super(`agent-lcm: setup file lock timeout: ${lockPath}`); + this.name = "SetupFileLockTimeoutError"; + this.lockPath = lockPath; + } +} + +export class SetupConfigurationChangedError extends Error { + readonly target: string; + + constructor(target: string) { + super(`Setup configuration changed after preflight: ${target}`); + this.name = "SetupConfigurationChangedError"; + this.target = target; } +} + +export type SetupConfigurationSnapshot = { + readonly configuration: Record | undefined; + readonly hash: string; +}; + +export function mutateSetupConfiguration( + target: string, + transform: (configuration: Record | undefined) => Record | undefined, + expectedHash?: string, +): boolean { + const directory = path.dirname(target); + ensureSetupDirectory(directory); + const directoryIdentity = fs.lstatSync(directory); + if (!directoryIdentity.isDirectory()) throw new Error(`Setup directory changed while updating: ${directory}`); + return withSetupFileLock(target, directoryIdentity, () => { + const current = readAnchoredSetupFile(target, directoryIdentity); + if (expectedHash !== undefined && setupConfigurationHash(current) !== expectedHash) { + throw new SetupConfigurationChangedError(target); + } + const existing = current ? parseSetupConfiguration(current, target) : undefined; + const next = transform(existing); + if (next === undefined) return false; + if (existing && JSON.stringify(existing) === JSON.stringify(next)) return false; + writeAnchoredSetupFile( + target, + directoryIdentity, + current === undefined ? "missing" : createHash("sha256").update(current).digest("hex"), + Buffer.from(`${JSON.stringify(next, null, 2)}\n`), + ); + return true; + }); +} + +export function readSetupConfiguration(target: string): Record | undefined { + return readSetupConfigurationSnapshot(target).configuration; +} + +export function readSetupConfigurationSnapshot(target: string): SetupConfigurationSnapshot { + const bytes = readSetupFile(target); + return { + configuration: bytes ? parseSetupConfiguration(bytes, target) : undefined, + hash: setupConfigurationHash(bytes), + }; +} + +function setupConfigurationHash(bytes: Buffer | undefined): string { + return bytes === undefined ? "missing" : createHash("sha256").update(bytes).digest("hex"); +} + +function parseSetupConfiguration(bytes: Buffer, target: string): Record { let value: unknown; try { - value = JSON.parse(text); + value = JSON.parse(bytes.toString("utf8")); } catch { throw new Error(`Cannot update invalid setup configuration: ${target}`); } @@ -20,37 +94,140 @@ export function readSetupConfiguration(target: string): Record return value; } -export function writeSetupConfiguration(target: string, configuration: Record): void { - const directory = path.dirname(target); - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - fs.chmodSync(directory, 0o700); - const temporary = `${target}.${randomUUID()}.tmp`; - const descriptor = fs.openSync(temporary, "wx", 0o600); +function withSetupFileLock(target: string, directoryIdentity: fs.Stats, callback: () => T): T { + const lockPath = `${target}.lock`; + const deadline = Date.now() + SETUP_LOCK_TIMEOUT_MS; + let acquired = false; + let failed = false; + // ponytail: a crashed setup leaves this empty directory; recover it manually + // rather than guessing whether another setup process is still alive. + try { + while (!acquired) { + const result = runSetupFileWorker("lock", target, directoryIdentity); + if (result.status === 0) acquired = true; + else if (result.status !== WORKER_BUSY_EXIT) throw setupFileWorkerError(result); + else if (Date.now() >= deadline) throw new SetupFileLockTimeoutError(lockPath); + else Atomics.wait(SETUP_LOCK_WAIT, 0, 0, SETUP_LOCK_POLL_MS); + } + return callback(); + } catch (error) { + failed = true; + throw error; + } finally { + if (acquired) { + const result = runSetupFileWorker("unlock", target, directoryIdentity); + if (!failed && result.status !== 0) throw setupFileWorkerError(result); + } + } +} + +function readAnchoredSetupFile(target: string, directoryIdentity: fs.Stats): Buffer | undefined { + const result = runSetupFileWorker("read", target, directoryIdentity); + if (result.status === WORKER_MISSING_EXIT) return undefined; + if (result.status !== 0) throw setupFileWorkerError(result); + return result.stdout; +} + +function writeAnchoredSetupFile(target: string, directoryIdentity: fs.Stats, expectedHash: string, bytes: Buffer): void { + const result = runSetupFileWorker("write", target, directoryIdentity, [expectedHash, new Date().toISOString()], bytes); + if (result.status !== 0) throw setupFileWorkerError(result); +} + +function runSetupFileWorker( + operation: "lock" | "unlock" | "read" | "write", + target: string, + directoryIdentity: fs.Stats, + extraArguments: readonly string[] = [], + input?: Buffer, +): childProcess.SpawnSyncReturns { + const result = childProcess.spawnSync(process.execPath, [ + "--no-warnings", + SETUP_FILE_WORKER, + operation, + path.basename(target), + String(directoryIdentity.dev), + String(directoryIdentity.ino), + ...extraArguments, + ], { + cwd: path.dirname(target), + input, + maxBuffer: 16 * 1024 * 1024, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.error !== undefined) throw result.error; + return result; +} + +function setupFileWorkerError(result: childProcess.SpawnSyncReturns): Error { + const message = result.stderr.toString("utf8").trim(); + return new Error(message || `Setup file worker failed with status ${String(result.status)}.`); +} + +function readSetupFile(target: string): Buffer | undefined { + let pathStatus: fs.Stats; try { - fs.writeFileSync(descriptor, `${JSON.stringify(configuration, null, 2)}\n`); - fs.fsyncSync(descriptor); + pathStatus = fs.lstatSync(target); + } catch (error) { + if (hasCode(error, "ENOENT")) return undefined; + throw error; + } + if (pathStatus.isSymbolicLink()) throw new Error(`Refusing setup configuration symlink: ${target}`); + if (!pathStatus.isFile()) throw new Error(`Cannot update setup configuration that is not a regular file: ${target}`); + let descriptor: number; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | noFollowFlag()); + } catch (error) { + if (hasCode(error, "ELOOP")) throw new Error(`Refusing setup configuration symlink: ${target}`); + throw error; + } + try { + const opened = fs.fstatSync(descriptor); + const current = fs.lstatSync(target); + if (!opened.isFile() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw new Error(`Setup configuration path changed while opening: ${target}`); + } + return fs.readFileSync(descriptor); } finally { fs.closeSync(descriptor); } - fs.renameSync(temporary, target); } -export function backupSetupConfiguration(target: string): void { - const extension = path.extname(target); - const stem = extension ? target.slice(0, -extension.length) : target; - const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); - for (let suffix = 0; ; suffix += 1) { - const candidate = `${stem}-pre-agent-lcm-${timestamp}${suffix ? `-${suffix}` : ""}${extension}`; +export function ensureSetupDirectory(directory: string): void { + assertSafeDirectoryPath(directory); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertSafeDirectoryPath(directory); +} + +function noFollowFlag(): number { + return process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW; +} + +function assertSafeDirectoryPath(directory: string): void { + const resolved = path.resolve(directory); + const root = path.parse(resolved).root; + let current = root; + for (const part of path.relative(root, resolved).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + let status: fs.Stats; try { - fs.copyFileSync(target, candidate, fs.constants.COPYFILE_EXCL); - fs.chmodSync(candidate, 0o600); - return; + status = fs.lstatSync(current); } catch (error) { - if (!hasCode(error, "EEXIST")) throw error; + if (hasCode(error, "ENOENT")) continue; + throw error; } + if (status.isSymbolicLink()) { + if (isDarwinSystemAlias(current)) continue; + throw new Error(`Refusing setup directory symlink: ${current}`); + } + if (!status.isDirectory()) throw new Error(`Cannot use setup path through a non-directory: ${current}`); } } +function isDarwinSystemAlias(target: string): boolean { + return process.platform === "darwin" && (target === "/etc" || target === "/tmp" || target === "/var"); +} + function hasCode(error: unknown, code: string): boolean { return error instanceof Error && Reflect.get(error, "code") === code; } diff --git a/src/setup-hook-status.ts b/src/setup-hook-status.ts new file mode 100644 index 0000000..f53a16c --- /dev/null +++ b/src/setup-hook-status.ts @@ -0,0 +1,130 @@ +import path from "node:path"; + +import type { CaptureHarness } from "./harnesses.ts"; + +export const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"] as const; + +export type KiroHook = { name: string; trigger: string; action: { type: "command"; command: string } }; + +export function setupHooksConfigured( + harness: CaptureHarness, + configuration: Record | undefined, +): boolean { + if (!configuration) return false; + if (harness === "kiro") { + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) return false; + return eventsFor(harness).every((event) => hooks.some((hook) => isExpectedKiroHook(hook, event))); + } + if (harness !== "codex" && configuration.version !== 1) return false; + const hooksByEvent = configuration.hooks; + if (!isRecord(hooksByEvent)) return false; + if (harness === "codex") return CODEX_EVENTS.every((event) => { + const selectors = hooksByEvent[event]; + return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.some((hook) => isCodexNativeHook(event) + ? isAgentLcmCodexHook(hook, event) + : isExpectedCommandHook(hook, harness, event))); + }); + if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) return false; + return setupEvents(harness).every(([event, captureEvent]) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); + }); +} + +export function eventsFor(harness: CaptureHarness): string[] { + return isSharedHookHarness(harness) + ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] + : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; +} + +export function setupEvents(harness: "cursor" | "vscode" | "copilot"): ReadonlyArray { + return harness === "cursor" + ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] + : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; +} + +export function isSharedHookHarness(harness: CaptureHarness): harness is "copilot" | "vscode" { + return harness === "copilot" || harness === "vscode"; +} + +export function setupCaptureHarness(harness: CaptureHarness): CaptureHarness | "auto" { + return isSharedHookHarness(harness) ? "auto" : harness; +} + +export function isAgentLcmHook(value: Record, event: string, harness: CaptureHarness): boolean { + if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u + .exec(value.command); + const captureEvent = harness === "cursor" + ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] + : event; + if (!match || match[2] !== captureEvent) return false; + return isSharedHookHarness(harness) + ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" + : match[1] === harness; +} + +export function isAgentLcmCodexHook(value: unknown, event: string): boolean { + if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") return false; + const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); + return match?.[1] === event; +} + +export function isCodexNativeHook(event: string): boolean { + return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; +} + +export function isKiroHook(value: unknown): value is KiroHook { + return isRecord(value) + && typeof value.name === "string" + && typeof value.trigger === "string" + && isRecord(value.action) + && value.action.type === "command" + && typeof value.action.command === "string"; +} + +export function assertSafeSetupCommand(command: string): void { + if (!command) throw new Error("setup command must not be empty"); + if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) throw new Error("setup command must be an absolute binary path"); + if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) throw new Error("setup command contains unsafe shell characters"); +} + +function isExpectedCommandHook(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { + return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); +} + +function hasSharedPascalRegistration(hooksByEvent: Record): boolean { + return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { + const hooks = hooksByEvent[event]; + return Array.isArray(hooks) && hooks.some((hook) => isRecord(hook) && hook.type === "command" && isAgentLcmHook(hook, event, "vscode")); + }); +} + +function isExpectedKiroHook(value: unknown, event: string): boolean { + return isKiroHook(value) + && value.name === `agent-lcm-kiro-${event}` + && value.trigger === event + && isCaptureCommand(value.action.command, "kiro", event); +} + +function isCaptureCommand(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { + if (typeof value !== "string") return false; + const prefix = 'node "'; + const suffix = ` capture --harness ${harness} ${event}`; + if (!value.startsWith(prefix) || !value.endsWith(suffix)) return false; + const quoteEnd = value.length - suffix.length - 1; + if (value[quoteEnd] !== "\"") return false; + try { + assertSafeSetupCommand(value.slice(prefix.length, quoteEnd)); + return true; + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/setup-hooks.ts b/src/setup-hooks.ts new file mode 100644 index 0000000..76e6b02 --- /dev/null +++ b/src/setup-hooks.ts @@ -0,0 +1,248 @@ +import type { CaptureHarness } from "./harnesses.ts"; +import { + CODEX_EVENTS, + eventsFor, + isAgentLcmCodexHook, + isAgentLcmHook, + isCodexNativeHook, + isKiroHook, + isSharedHookHarness, + setupCaptureHarness, + setupEvents, + type KiroHook, +} from "./setup-hook-status.ts"; + +export function mergeSetupHooks( + existing: Record | undefined, + harness: CaptureHarness, + command: string, + target: string, +): Record { + if (harness === "kiro") return mergeKiroConfiguration(existing, command, target); + if (harness === "codex") return mergeCodexConfiguration(existing, command, target); + return mergeFlatConfiguration(existing, harness, command, target); +} + +function mergeCodexConfiguration( + existing: Record | undefined, + command: string, + target: string, +): Record { + const configuration = existing ? structuredClone(existing) : { hooks: {} }; + if (!isRecord(configuration.hooks)) throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every(isCodexSelectors)) throw invalidConfiguration(target); + + for (const event of CODEX_EVENTS) { + const expectedCommand = isCodexNativeHook(event) + ? `node "${command}" hook ${event}` + : captureCommand(command, "codex", event); + const selectors = configuration.hooks[event]; + if (selectors === undefined) { + configuration.hooks[event] = [{ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }]; + continue; + } + if (!isCodexSelectors(selectors)) throw invalidConfiguration(target); + let found = false; + for (const selector of selectors) { + if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) throw invalidConfiguration(target); + for (const hook of selector.hooks) { + if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) continue; + hook.type = "command"; + hook.command = expectedCommand; + found = true; + } + } + if (!found) selectors.push({ + ...(event === "PreToolUse" ? { matcher: ".*" } : {}), + hooks: [{ type: "command", command: expectedCommand }], + }); + } + return configuration; +} + +function mergeFlatConfiguration( + existing: Record | undefined, + harness: "cursor" | "vscode" | "copilot", + command: string, + target: string, +): Record { + const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; + if (configuration.version !== 1 || !isRecord(configuration.hooks)) throw invalidConfiguration(target); + if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event, captureEvent] of setupEvents(harness)) { + const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); + if (expectedHooks.length === 0) expectedHooks.push({}); + for (const expected of expectedHooks) { + if (harness !== "cursor") expected.type = "command"; + expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); + } + const hooks = configuration.hooks[event] as Record[] | undefined; + if (hooks === undefined) configuration.hooks[event] = expectedHooks; + else hooks.push(...expectedHooks); + } + return configuration; +} + +function mergeKiroConfiguration(existing: Record | undefined, command: string, target: string): Record { + const configuration: Record = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; + const hooks = configuration.hooks; + if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { + throw invalidConfiguration(target); + } + const kiroHooks = hooks as KiroHook[]; + for (const event of eventsFor("kiro")) { + const expected = kiroHook(command, event); + const owned = kiroHooks.filter((hook) => hook.name === expected.name + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro")); + if (owned.length === 0) kiroHooks.push(expected); + for (const hook of owned) { + hook.action.type = "command"; + hook.action.command = expected.action.command; + } + } + return configuration; +} + +function takeAgentLcmHooks( + hooksByEvent: Record, + harness: "cursor" | "vscode" | "copilot", + event: string, +): Record[] { + const found: Record[] = []; + const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; + for (const candidate of candidates) { + const hooks = hooksByEvent[candidate]; + if (!Array.isArray(hooks)) continue; + const kept = hooks.filter((hook) => { + if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) return true; + found.push(hook); + return false; + }); + if (kept.length === 0) delete hooksByEvent[candidate]; + else hooksByEvent[candidate] = kept; + } + return found; +} + +function sharedLegacyEvent(event: string): string { + return ({ + sessionStart: "SessionStart", + userPromptSubmitted: "UserPromptSubmit", + postToolUse: "PostToolUse", + sessionEnd: "Stop", + } as Record)[event] ?? event; +} + +function kiroHook(command: string, event: string): KiroHook { + return { + name: `agent-lcm-kiro-${event}`, + trigger: event, + action: { type: "command", command: captureCommand(command, "kiro", event) }, + }; +} + +function captureCommand(command: string, harness: CaptureHarness | "auto", event: string): string { + return `node "${command}" capture --harness ${harness} ${event}`; +} + +function isCodexSelectors(value: unknown): value is Array & { hooks: Record[] }> { + return Array.isArray(value) && value.every((selector) => isRecord(selector) + && Array.isArray(selector.hooks) + && selector.hooks.every(isRecord)); +} + +function invalidConfiguration(target: string): Error { + return new Error(`Cannot update invalid setup configuration: ${target}`); +} + +export function validateSetupHooks( + harness: CaptureHarness, + configuration: Record | undefined, + target: string, +): void { + if (configuration !== undefined) mergeSetupHooks(configuration, harness, "/agent-lcm", target); +} + +export function removeSharedSetupHooks( + configuration: Record, + harness: "copilot" | "vscode", + target: string, +): Record { + const next = structuredClone(configuration); + if (next.version !== 1 || !isRecord(next.hooks) + || !Object.values(next.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { + throw invalidConfiguration(target); + } + for (const [event] of setupEvents(harness)) removeSharedHooks(next.hooks, harness, event); + return next; +} + +export function removeSetupHooks( + configuration: Record, + harness: "codex" | "cursor" | "kiro", + target: string, +): Record { + validateSetupHooks(harness, configuration, target); + if (harness === "codex") return removeCodexHooks(configuration); + if (harness === "cursor") return removeCursorHooks(configuration); + return removeKiroHooks(configuration); +} + +function removeCodexHooks(configuration: Record): Record { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) return next; + for (const event of CODEX_EVENTS) { + const selectors = next.hooks[event]; + if (!Array.isArray(selectors)) continue; + for (const selector of selectors) { + if (!isRecord(selector) || !Array.isArray(selector.hooks)) continue; + selector.hooks = selector.hooks.filter((hook) => isCodexNativeHook(event) + ? !isAgentLcmCodexHook(hook, event) + : !isRecord(hook) || !isAgentLcmHook(hook, event, "codex")); + } + } + return next; +} + +function removeCursorHooks(configuration: Record): Record { + const next = structuredClone(configuration); + if (!isRecord(next.hooks)) return next; + for (const [event] of setupEvents("cursor")) { + const hooks = next.hooks[event]; + if (Array.isArray(hooks)) { + next.hooks[event] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, event, "cursor")); + } + } + return next; +} + +function removeKiroHooks(configuration: Record): Record { + const next = structuredClone(configuration); + if (!Array.isArray(next.hooks)) return next; + next.hooks = next.hooks.filter((hook) => !isKiroHook(hook) + || !eventsFor("kiro").some((event) => hook.name === `agent-lcm-kiro-${event}` + && hook.trigger === event + && isAgentLcmHook(hook.action, event, "kiro"))); + return next; +} + +function removeSharedHooks( + hooksByEvent: Record, + harness: "copilot" | "vscode", + event: string, +): void { + for (const candidate of [event, sharedLegacyEvent(event)]) { + const hooks = hooksByEvent[candidate]; + if (Array.isArray(hooks)) hooksByEvent[candidate] = hooks.filter((hook) => !isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/setup.ts b/src/setup.ts index 7b7fbc7..66b6210 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,230 +1,147 @@ import path from "node:path"; import type { CaptureHarness } from "./harnesses.ts"; -import { backupSetupConfiguration, readSetupConfiguration, writeSetupConfiguration } from "./setup-files.ts"; +import { runHarnessLifecycle, type HarnessCli } from "./setup-adapters.ts"; +import { + ensureSetupDirectory, + mutateSetupConfiguration, + readSetupConfiguration, + readSetupConfigurationSnapshot, + SetupConfigurationChangedError, +} from "./setup-files.ts"; +import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.ts"; +import { + mergeSetupHooks, + removeSetupHooks, + removeSharedSetupHooks, + validateSetupHooks, +} from "./setup-hooks.ts"; import { SETUP_HARNESSES, setupPath } from "./setup-targets.ts"; -export type SetupOptions = { home?: string; command: string }; -export type SetupReport = { harness: CaptureHarness; path: string; changed: boolean }; -export type SetupStatusOptions = { home?: string }; - -export type HarnessSetupStatus = { configured: boolean; path: string }; - -const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"] as const; +export type SetupOptions = { readonly home?: string; readonly command: string; readonly env?: NodeJS.ProcessEnv }; +export type SetupReport = { + readonly harness: CaptureHarness; + readonly action: "setup"; + readonly status: "complete" | "manual-required"; + readonly nativeCli: HarnessCli | null; + readonly hooks: { readonly path: string; readonly changed: boolean }; + readonly guide: string; +}; +export type RemoveOptions = { readonly home?: string; readonly env?: NodeJS.ProcessEnv }; +export type RemoveReport = { + readonly harness: CaptureHarness; + readonly action: "remove"; + readonly status: "complete" | "manual-required" | "shared-retained"; + readonly nativeCli: HarnessCli | null; + readonly hooks: { readonly path: string; readonly changed: boolean }; + readonly guide: string; +}; +export type SetupStatusOptions = { readonly home?: string }; +export type HarnessSetupStatus = { readonly hooksConfigured: boolean; readonly path: string }; export function setupHarness(harness: CaptureHarness, options: SetupOptions): SetupReport { const target = setupPath(harness, options.home); const command = options.command.trim(); - assertSafeCommand(command); - const existing = readSetupConfiguration(target); - const next = mergeConfiguration(existing, harness, command, target); - if (existing && JSON.stringify(existing) === JSON.stringify(next)) return { harness, path: target, changed: false }; - if (existing) backupSetupConfiguration(target); - writeSetupConfiguration(target, next); - return { harness, path: target, changed: true }; + assertSafeSetupCommand(command); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; + validateSetupHooks(harness, existing, target); + ensureSetupDirectory(path.dirname(target)); + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env, command } : { command }); + const changed = finishHookUpdate("setup", harness, native.status, target, () => ( + updateHooks(harness, native.status, target, command, snapshot.hash) + )); + return { + harness, + action: "setup", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, + }; +} + +export function removeHarness(harness: CaptureHarness, options: RemoveOptions = {}): RemoveReport { + const target = setupPath(harness, options.home); + const snapshot = readSetupConfigurationSnapshot(target); + const existing = snapshot.configuration; + validateSetupHooks(harness, existing, target); + const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); + const changed = finishHookUpdate("remove", harness, native.status, target, () => ( + removeHooks(harness, target, existing !== undefined, snapshot.hash) + )); + return { + harness, + action: "remove", + status: native.status === "native-complete" ? "complete" : native.status, + nativeCli: native.nativeCli, + hooks: { path: target, changed }, + guide: native.guide, + }; } export function setupStatus(options: SetupStatusOptions = {}): Record { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { const target = setupPath(harness, options.home); - return [harness, { configured: configured(harness, target), path: target }]; + return [harness, { hooksConfigured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })) as Record; } -function mergeConfiguration( - existing: Record | undefined, +function updateHooks( harness: CaptureHarness, - command: string, + nativeStatus: "native-complete" | "manual-required" | "shared-retained", target: string, -): Record { - if (harness === "kiro") return mergeKiroConfiguration(existing, command, target); - if (harness === "codex") return mergeCodexConfiguration(existing, command, target); - return mergeFlatConfiguration(existing, harness, command, target); -} - -function mergeCodexConfiguration( - existing: Record | undefined, command: string, - target: string, -): Record { - const configuration = existing ? structuredClone(existing) : { hooks: {} }; - if (!isRecord(configuration.hooks)) throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every(isCodexSelectors)) throw invalidConfiguration(target); - - for (const event of CODEX_EVENTS) { - const expectedCommand = isCodexNativeHook(event) - ? `node "${command}" hook ${event}` - : captureCommand(command, "codex", event); - const selectors = configuration.hooks[event]; - if (selectors === undefined) { - configuration.hooks[event] = [{ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }]; - continue; + expectedHash: string, +): boolean { + if (harness !== "kiro" && nativeStatus !== "native-complete") return false; + return mutateSetupConfiguration(target, (existing) => { + if (harness === "kiro") return mergeSetupHooks(existing, harness, command, target); + if (harness === "codex" && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSetupHooks(existing, harness, target); } - if (!isCodexSelectors(selectors)) throw invalidConfiguration(target); - let found = false; - for (const selector of selectors) { - if (!Array.isArray(selector.hooks) || !selector.hooks.every(isRecord)) throw invalidConfiguration(target); - for (const hook of selector.hooks) { - if (!(isCodexNativeHook(event) ? isAgentLcmCodexHook(hook, event) : isAgentLcmHook(hook, event, "codex"))) continue; - hook.type = "command"; - hook.command = expectedCommand; - found = true; - } + if ((harness === "copilot" || harness === "vscode") && nativeStatus === "native-complete") { + return existing === undefined ? undefined : removeSharedSetupHooks(existing, harness, target); } - if (!found) selectors.push({ - ...(event === "PreToolUse" ? { matcher: ".*" } : {}), - hooks: [{ type: "command", command: expectedCommand }], - }); - } - return configuration; + return existing; + }, expectedHash); } -function mergeFlatConfiguration( - existing: Record | undefined, - harness: "cursor" | "vscode" | "copilot", - command: string, - target: string, -): Record { - const configuration = existing ? structuredClone(existing) : { version: 1, hooks: {} }; - if (configuration.version !== 1 || !isRecord(configuration.hooks)) throw invalidConfiguration(target); - if (!Object.values(configuration.hooks).every((hooks) => Array.isArray(hooks) && hooks.every(isRecord))) { - throw invalidConfiguration(target); - } - for (const [event, captureEvent] of setupEvents(harness)) { - const expectedHooks = takeAgentLcmHooks(configuration.hooks, harness, event); - if (expectedHooks.length === 0) expectedHooks.push({}); - for (const expected of expectedHooks) { - if (harness !== "cursor") expected.type = "command"; - expected.command = captureCommand(command, setupCaptureHarness(harness), captureEvent); - } - const hooks = configuration.hooks[event] as Record[] | undefined; - if (hooks === undefined) configuration.hooks[event] = expectedHooks; - else hooks.push(...expectedHooks); - } - return configuration; +function removeHooks(harness: CaptureHarness, target: string, targetExists: boolean, expectedHash: string): boolean { + if (harness === "copilot" || harness === "vscode" || (!targetExists && harness !== "codex")) return false; + return mutateSetupConfiguration(target, (existing) => existing === undefined + ? undefined + : removeSetupHooks(existing, harness, target), expectedHash); } -function mergeKiroConfiguration(existing: Record | undefined, command: string, target: string): Record { - const configuration: Record = existing ? structuredClone(existing) : { version: "v1", hooks: [] }; - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) { - throw invalidConfiguration(target); - } - const kiroHooks = hooks as KiroHook[]; - for (const event of eventsFor("kiro")) { - const expected = kiroHook(command, event); - const owned = kiroHooks.filter((hook) => hook.name === expected.name - && hook.trigger === event - && isAgentLcmHook(hook.action, event, "kiro")); - if (owned.length === 0) kiroHooks.push(expected); - for (const hook of owned) { - hook.action.type = "command"; - hook.action.command = expected.action.command; +function finishHookUpdate( + action: "setup" | "remove", + harness: CaptureHarness, + nativeStatus: "native-complete" | "manual-required" | "shared-retained", + target: string, + update: () => boolean, +): boolean { + try { + return update(); + } catch (error) { + if (error instanceof SetupConfigurationChangedError) { + const result = nativeStatus === "native-complete" + ? `Native ${harness} ${action} completed` + : `${harness} ${action} stopped`; + throw new Error( + `${result}, but Agent LCM detected a concurrent change to ${target} and did not overwrite it. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, + { cause: error }, + ); } + if (nativeStatus !== "native-complete") throw error; + throw new Error( + `Native ${harness} ${action} completed, but Agent LCM could not safely update ${target}. ` + + `Inspect the hook file because the local update may have completed. ` + + `Repair it if needed, then rerun agent-lcm ${action} ${harness}.`, + { cause: error }, + ); } - return configuration; -} - -function eventsFor(harness: CaptureHarness): string[] { - return isSharedHookHarness(harness) - ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] - : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; -} - -function setupEvents(harness: "cursor" | "vscode" | "copilot"): ReadonlyArray { - return harness === "cursor" - ? [["sessionStart", "SessionStart"], ["beforeSubmitPrompt", "UserPromptSubmit"], ["postToolUse", "PostToolUse"], ["stop", "Stop"]] - : [["sessionStart", "sessionStart"], ["userPromptSubmitted", "userPromptSubmitted"], ["postToolUse", "postToolUse"], ["sessionEnd", "sessionEnd"]]; -} - -function isSharedHookHarness(harness: CaptureHarness): harness is "copilot" | "vscode" { - return harness === "copilot" || harness === "vscode"; -} - -function setupCaptureHarness(harness: CaptureHarness): CaptureHarness | "auto" { - return isSharedHookHarness(harness) ? "auto" : harness; -} - -function takeAgentLcmHooks( - hooksByEvent: Record, - harness: "cursor" | "vscode" | "copilot", - event: string, -): Record[] { - const found: Record[] = []; - const candidates = isSharedHookHarness(harness) ? [event, sharedLegacyEvent(event)] : [event]; - for (const candidate of candidates) { - const hooks = hooksByEvent[candidate]; - if (!Array.isArray(hooks)) continue; - const kept = hooks.filter((hook) => { - if (!isRecord(hook) || !isAgentLcmHook(hook, candidate, harness)) return true; - found.push(hook); - return false; - }); - if (kept.length === 0) delete hooksByEvent[candidate]; - else hooksByEvent[candidate] = kept; - } - return found; -} - -function sharedLegacyEvent(event: string): string { - return ({ - sessionStart: "SessionStart", - userPromptSubmitted: "UserPromptSubmit", - postToolUse: "PostToolUse", - sessionEnd: "Stop", - } as Record)[event] ?? event; -} - -function kiroHook(command: string, event: string): KiroHook { - return { - name: `agent-lcm-kiro-${event}`, - trigger: event, - action: { type: "command", command: captureCommand(command, "kiro", event) }, - }; -} - -function captureCommand(command: string, harness: CaptureHarness | "auto", event: string): string { - return `node "${command}" capture --harness ${harness} ${event}`; -} - -function assertSafeCommand(command: string): void { - if (!command) throw new Error("setup command must not be empty"); - if (!path.isAbsolute(command) && !/^[A-Za-z]:[\\/]/u.test(command)) { - throw new Error("setup command must be an absolute binary path"); - } - if (/["'`$;&|<>\n\r%^]/u.test(command) || command.endsWith("\\")) { - throw new Error("setup command contains unsafe shell characters"); - } -} - -function configured(harness: CaptureHarness, target: string): boolean { - const configuration = readConfigurationForStatus(target); - if (!configuration) return false; - if (harness === "kiro") { - const hooks = configuration.hooks; - if (configuration.version !== "v1" || !Array.isArray(hooks) || !hooks.every(isKiroHook)) return false; - const kiroHooks = hooks as KiroHook[]; - return eventsFor(harness).every((event) => kiroHooks.some((hook) => isExpectedKiroHook(hook, event))); - } - if (harness !== "codex" && configuration.version !== 1) return false; - const hooksByEvent = configuration.hooks; - if (!isRecord(hooksByEvent)) return false; - if (harness === "codex") return CODEX_EVENTS.every((event) => { - const selectors = hooksByEvent[event]; - return Array.isArray(selectors) && selectors.some((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.some((hook) => isCodexNativeHook(event) - ? isAgentLcmCodexHook(hook, event) - : isExpectedCommandHook(hook, harness, event))); - }); - if (isSharedHookHarness(harness) && hasSharedPascalRegistration(hooksByEvent)) return false; - return setupEvents(harness).every(([event, captureEvent]) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((entry) => isExpectedCommandHook(entry, setupCaptureHarness(harness), captureEvent)); - }); } function readConfigurationForStatus(target: string): Record | undefined { @@ -234,90 +151,3 @@ function readConfigurationForStatus(target: string): Record | u return undefined; } } - -function isExpectedCommandHook(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { - return isRecord(value) && (value.type === undefined || value.type === "command") && isCaptureCommand(value.command, harness, event); -} - -function isCodexSelectors(value: unknown): value is Array & { hooks: Record[] }> { - return Array.isArray(value) && value.every((selector) => isRecord(selector) - && Array.isArray(selector.hooks) - && selector.hooks.every(isRecord)); -} - -function isAgentLcmHook(value: Record, event: string, harness: CaptureHarness): boolean { - if ((value.type !== undefined && value.type !== "command") || typeof value.command !== "string") return false; - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" capture --harness (auto|codex|cursor|copilot|vscode|kiro) (sessionStart|userPromptSubmitted|postToolUse|sessionEnd|SessionStart|UserPromptSubmit|PostToolUse|Stop)$/u - .exec(value.command); - const captureEvent = harness === "cursor" - ? setupEvents("cursor").find(([hookEvent]) => hookEvent === event)?.[1] - : event; - if (!match || match[2] !== captureEvent) return false; - return isSharedHookHarness(harness) - ? match[1] === "auto" || match[1] === "copilot" || match[1] === "vscode" - : match[1] === harness; -} - -function isAgentLcmCodexHook(value: unknown, event: string): boolean { - if (!isRecord(value) || (value.type !== undefined && value.type !== "command") || typeof value.command !== "string") { - return false; - } - const match = /^(?:node )?"(?:[^"\\/]*[\\/])*agent-lcm(?:\.(?:cmd|exe))?" hook (PreToolUse|PreCompact|PostCompact|SubagentStop)$/u.exec(value.command); - return match?.[1] === event; -} - -function isCodexNativeHook(event: string): boolean { - return event === "PreToolUse" || event === "PreCompact" || event === "PostCompact" || event === "SubagentStop"; -} - -function hasSharedPascalRegistration(hooksByEvent: Record): boolean { - return ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].some((event) => { - const hooks = hooksByEvent[event]; - return Array.isArray(hooks) && hooks.some((hook) => { - if (!isRecord(hook) || hook.type !== "command" || typeof hook.command !== "string") return false; - return isAgentLcmHook(hook, event, "vscode"); - }); - }); -} - -function isExpectedKiroHook(value: unknown, event: string): boolean { - return isKiroHook(value) - && value.name === `agent-lcm-kiro-${event}` - && value.trigger === event - && isCaptureCommand(value.action.command, "kiro", event); -} - -type KiroHook = { name: string; trigger: string; action: { type: "command"; command: string } }; - -function isKiroHook(value: unknown): value is KiroHook { - return isRecord(value) - && typeof value.name === "string" - && typeof value.trigger === "string" - && isRecord(value.action) - && value.action.type === "command" - && typeof value.action.command === "string"; -} - -function isCaptureCommand(value: unknown, harness: CaptureHarness | "auto", event: string): boolean { - if (typeof value !== "string") return false; - const prefix = 'node "'; - const suffix = ` capture --harness ${harness} ${event}`; - if (!value.startsWith(prefix) || !value.endsWith(suffix)) return false; - const quoteEnd = value.length - suffix.length - 1; - if (value[quoteEnd] !== "\"") return false; - const command = value.slice(prefix.length, quoteEnd); - try { - assertSafeCommand(command); - return true; - } catch { - return false; - } -} - -function invalidConfiguration(target: string): Error { - return new Error(`Cannot update invalid setup configuration: ${target}`); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 43df197..7a33978 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -12,6 +12,10 @@ framework, fixture library, or custom runner. - Route tests by surface: storage behavior belongs in `storage.test.ts` or `storage-api.test.ts`, protocol behavior in `mcp.test.ts`, and real command or hook behavior in `hook-cli.test.ts`. +- Native lifecycle adapter contracts and fake-CLI argv/outcome checks belong + in `setup-adapters.test.ts`. End-to-end setup/remove reports, exit statuses, + exact-owned hook edits, shared-retained behavior, lock handling, backups, + and symlink-safe publication belong in `setup.test.ts`. - Keep manifest, event, redaction, summary, and import coverage in their existing focused files instead of growing a catch-all suite. @@ -35,6 +39,18 @@ framework, fixture library, or custom runner. - Use `runCli()` for the actual `bin/agent-lcm` subprocess and assert its exit status, stderr, and parsed stdout. Set a bounded timeout for lock or worker scenarios. +- Fake `codex`, `copilot`, `cursor-agent`, and `kiro-cli` executables must record + argv and fail on demand; use them to prove the exact documented command + vectors without touching a user's installed clients. Write `.cmd` shims on + Windows so tests exercise the same npm CLI boundary as users. +- Distribution tests must prove the npm artifact omits root `plugin.json`, keeps + both native manifests, and runs bundled Codex hooks without creating a user + hook file. +- Copilot setup tests must inspect the generated package during the fake + install, assert its stable `agent-lcm` source basename and absolute hook/MCP + command, and then confirm the temporary source was removed. +- Setup-file race tests may inject faults into the helper only through a + disposable child-process preload; never add a production test bypass. - Use `runMcp()` or `runCli(["mcp"], ...)` with newline or framed JSON to test the real stdio MCP server. Assert response IDs, errors, and continuation after malformed input; do not call dispatch functions directly for protocol @@ -52,6 +68,11 @@ framework, fixture library, or custom runner. - For fsync or publication faults, assert the failed subprocess leaves no acknowledged event, then run the normal retry and verify one raw and one indexed event. +- For setup-file symlink races, swap the path at the old path-check/read or + chmod boundary and prove descriptor-bound I/O neither reads nor changes the + victim. +- For lifecycle/config races, change the target from a fake native probe and + assert the native argv, untouched new bytes, and explicit repair message. - Prefer direct assertions on counts, IDs, paths, and JSON fields over broad snapshots; preserve security checks that prove secrets are absent. @@ -62,5 +83,8 @@ framework, fixture library, or custom runner. when the test claims to cover that boundary. - Do not leave open `DatabaseSync` handles, workers, subprocesses, or lock files; leaked resources make later suites flaky. +- Do not claim native setup or removal coverage from unit calls alone: include + the real CLI boundary and assert `0` for `complete`, `2` for + `manual-required` or `shared-retained`, and `1` for command errors. - Do not add generated databases, raw logs, overflow files, or ad hoc scripts to the checkout. Keep changes and fixtures local to the test that needs them. diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index ca7af1e..84a08d6 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -25,6 +25,7 @@ test("the npm package contains the complete plugin and no development files", (t ".codex-plugin/plugin.json", ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", + ".mcp.json", "LICENSE", "README.md", "bin/agent-lcm", @@ -32,10 +33,15 @@ test("the npm package contains the complete plugin and no development files", (t "mcp.cursor.json", "mcp.json", "package.json", - "plugin.json", "skills/lcm-recall/SKILL.md", "dist/cli.js", + "dist/copilot-plugin.js", + "dist/setup-adapters.js", + "dist/setup-file-worker.js", + "dist/setup-hook-status.js", + "dist/setup-hooks.js", ]) assert.ok(names.includes(required), `missing ${required}`); + assert.equal(names.includes("plugin.json"), false, "packed native clients must not select the portable manifest"); assert.equal(names.some((name) => /^(?:\.github|docs|scripts|tests)\//u.test(name)), false); assert.equal(fs.existsSync(path.join(root, filename)), true); @@ -134,7 +140,24 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(install.status, 0, install.stderr); const executable = process.platform === "win32" ? path.join(prefix, "agent-lcm.cmd") : path.join(prefix, "bin", "agent-lcm"); const home = path.join(root, "home"); - const env = { ...process.env, HOME: home, USERPROFILE: home, AGENT_LCM_HOME: path.join(home, ".agent-lcm") }; + const fakeBin = path.join(root, "fake-bin"); + const fakeLog = path.join(root, "codex-calls.jsonl"); + const fakeScript = path.join(root, "fake-codex.cjs"); + fs.mkdirSync(fakeBin); + fs.writeFileSync(fakeScript, 'require("node:fs").appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n'); + if (process.platform === "win32") { + fs.writeFileSync(path.join(fakeBin, "codex.cmd"), `@"${process.execPath}" "${fakeScript}" %*\r\n`); + } else { + fs.writeFileSync(path.join(fakeBin, "codex"), `#!${process.execPath}\nrequire(${JSON.stringify(fakeScript)});\n`, { mode: 0o755 }); + } + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + AGENT_LCM_HOME: path.join(home, ".agent-lcm"), + AGENT_LCM_FAKE_LOG: fakeLog, + PATH: `${fakeBin}${path.delimiter}${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`, + }; const runInstalled = (args: string[], input?: string) => spawnSync(executable, args, { cwd: root, encoding: "utf8", @@ -158,6 +181,9 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", "@team-volt", "agent-lcm", ); + assert.equal(fs.existsSync(path.join(packageRoot, "plugin.json")), false); + assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot, ".codex-plugin/plugin.json"), "utf8")).hooks, "./hooks/codex.json"); + assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot, ".cursor-plugin/plugin.json"), "utf8")).hooks, "./hooks/cursor.json"); const mcpConfiguration = JSON.parse(fs.readFileSync(path.join(packageRoot, "mcp.json"), "utf8")) .mcpServers["agent-lcm"] as { command: string; args: string[] }; const mcp = spawnSync(mcpConfiguration.command, mcpConfiguration.args.map((arg) => arg.replaceAll("${PLUGIN_ROOT}", packageRoot)), { @@ -192,8 +218,10 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(fs.existsSync(path.join(home, ".cursor")), false); assert.equal(fs.existsSync(path.join(home, ".copilot")), false); assert.equal(fs.existsSync(path.join(home, ".kiro")), false); - const codexHooks = JSON.parse(fs.readFileSync(path.join(home, ".codex/hooks.json"), "utf8")); - const capture = spawnSync(codexHooks.hooks.UserPromptSubmit[0].hooks[0].command, { + assert.equal(fs.existsSync(path.join(home, ".codex/hooks.json")), false); + const codexHooks = JSON.parse(fs.readFileSync(path.join(packageRoot, "hooks/codex.json"), "utf8")); + const captureCommand = codexHooks.hooks.UserPromptSubmit[0].hooks[0].command.replaceAll("${PLUGIN_ROOT}", packageRoot); + const capture = spawnSync(captureCommand, { cwd: root, encoding: "utf8", env, @@ -202,7 +230,8 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", timeout: 15_000, }); assert.equal(capture.status, 0, capture.stderr); - const postCompact = spawnSync(codexHooks.hooks.PostCompact[0].hooks[0].command, { + const postCompactCommand = codexHooks.hooks.PostCompact[0].hooks[0].command.replaceAll("${PLUGIN_ROOT}", packageRoot); + const postCompact = spawnSync(postCompactCommand, { cwd: root, encoding: "utf8", env, @@ -217,5 +246,15 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", assert.equal(JSON.parse(daemon.stdout).running, true); const stopped = runInstalled(["daemon", "stop", "--json"]); assert.equal(stopped.status, 0, stopped.stderr); + const removed = runInstalled(["remove", "codex", "--json"]); + assert.equal(removed.status, 0, removed.stderr); + assert.equal(JSON.parse(removed.stdout).hooks.changed, false); + assert.deepEqual(fs.readFileSync(fakeLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", fs.realpathSync(packageRoot)], + ["plugin", "add", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); assert.equal(JSON.parse(stopped.stdout).running, false); }); diff --git a/tests/doctor-import.test.ts b/tests/doctor-import.test.ts index 5e7bbf2..5fca602 100644 --- a/tests/doctor-import.test.ts +++ b/tests/doctor-import.test.ts @@ -47,10 +47,11 @@ test("CLI storage commands use the daemon and daemon status controls its lifetim test("doctor reports actionable recommendations for an unwired empty install", () => { const codexHome = tempHome("codex-home-"); const lcmHome = tempHome("agent-lcm-home-"); + const harnessHome = tempHome("agent-lcm-harness-home-"); fs.writeFileSync(path.join(codexHome, "config.toml"), ""); const result = runCli(["doctor", "--codex-home", codexHome, "--json"], { - env: { AGENT_LCM_HOME: lcmHome }, + env: { AGENT_LCM_HOME: lcmHome, HOME: harnessHome, USERPROFILE: harnessHome }, }); assertCliOk(result); @@ -64,6 +65,16 @@ test("doctor reports actionable recommendations for an unwired empty install", ( detail: "Not configured.", setup_gap: "Run `agent-lcm setup cursor`, then restart Cursor.", }); + assert.deepEqual(report.adapter_status.vscode, { + configured: null, + state: "unknown", + detail: "VS Code native plugin health is not checked by doctor. Run `copilot plugin list` or use the client's installed-plugin view.", + }); + assert.deepEqual(report.adapter_status.copilot, { + configured: null, + state: "unknown", + detail: "Copilot native plugin health is not checked by doctor. Run `copilot plugin list` or use the client's installed-plugin view.", + }); assert.equal(report.adapter_status.codex.configured, false); assert.equal(report.recommendations.some((text: string) => text.includes("Install the Agent LCM plugin")), true); assert.equal(report.recommendations.some((text: string) => text.includes("import --all")), true); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index eb0d0e1..3d4aec2 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import test from "node:test"; +import { readStatus } from "../src/installer.ts"; import { assertCliOk, runCli, tempHome } from "./helpers.ts"; test("status reads Codex home and reports absent wiring", () => { @@ -31,7 +32,7 @@ test("status reads Codex home and reports absent wiring", () => { assert.equal(status.recall_skill_available, true); }); -test("status recognizes Codex-native plugin wiring", () => { +test("status does not claim native hooks when the portable manifest wins", () => { const codexHome = tempHome(); fs.writeFileSync( path.join(codexHome, "config.toml"), @@ -65,10 +66,29 @@ test("status recognizes Codex-native plugin wiring", () => { assert.equal(status.manual_mcp_configured, false); assert.equal(status.manual_hooks_configured, false); assert.equal(status.mcp_configured, true); - assert.equal(status.hooks_configured, true); + assert.equal(status.hooks_configured, false); assert.equal(status.recall_skill_available, true); }); +test("status recognizes packed Codex-native plugin wiring", () => { + const codexHome = tempHome(); + const root = tempHome(); + fs.mkdirSync(path.join(root, ".codex-plugin")); + fs.mkdirSync(path.join(root, "hooks")); + fs.mkdirSync(path.join(root, "skills", "lcm-recall"), { recursive: true }); + fs.copyFileSync(".codex-plugin/plugin.json", path.join(root, ".codex-plugin", "plugin.json")); + fs.copyFileSync(".mcp.json", path.join(root, ".mcp.json")); + fs.copyFileSync("hooks/codex.json", path.join(root, "hooks", "codex.json")); + fs.copyFileSync("skills/lcm-recall/SKILL.md", path.join(root, "skills", "lcm-recall", "SKILL.md")); + fs.writeFileSync(path.join(codexHome, "config.toml"), '[plugins."agent-lcm@agent-lcm"]\nenabled = true\n'); + + const status = readStatus({ codexHome, root }); + + assert.equal(status.plugin_configured, true); + assert.equal(status.mcp_configured, true); + assert.equal(status.hooks_configured, true); +}); + test("status treats an explicitly disabled native plugin as disabled", () => { const codexHome = tempHome(); fs.writeFileSync( diff --git a/tests/plugin-manifest.test.ts b/tests/plugin-manifest.test.ts index 9a6fd02..72bf494 100644 --- a/tests/plugin-manifest.test.ts +++ b/tests/plugin-manifest.test.ts @@ -4,9 +4,11 @@ import { readJson } from "./helpers.ts"; test("root is an Agent Plugins 1.0 package", () => { const plugin = readJson("plugin.json"); - assert.deepEqual(Object.keys(plugin).sort(), ["$schema", "description", "homepage", "name", "version"]); + assert.deepEqual(Object.keys(plugin).sort(), ["$schema", "author", "description", "homepage", "keywords", "name", "version"]); assert.equal(plugin.$schema, "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"); assert.equal(plugin.name, "agent-lcm"); + assert.deepEqual(plugin.author, { name: "Team Volt" }); + assert.deepEqual(plugin.keywords, ["agent-memory", "context", "recall", "sessions"]); assert.equal(plugin.homepage, "https://github.com/Team-Volt/agent-lcm"); const mcp = readJson("mcp.json"); @@ -21,7 +23,22 @@ test("root is an Agent Plugins 1.0 package", () => { test("client hook manifests invoke explicit or detected harness capture", () => { const codex = readJson(".codex-plugin/plugin.json"); assert.equal(codex.hooks, "./hooks/codex.json"); + assert.equal(codex.mcpServers, "./.mcp.json"); assert.equal(codex.homepage, "https://github.com/Team-Volt/agent-lcm"); + assert.deepEqual(codex.interface, { + displayName: "Agent LCM", + shortDescription: "Use shared local context memory in Codex.", + longDescription: "Agent LCM captures and recalls coding-agent sessions from one local store.", + developerName: "Team Volt", + category: "Developer Tools", + capabilities: [], + defaultPrompt: "Recall relevant work from earlier coding sessions.", + }); + assert.deepEqual(readJson(".mcp.json").mcpServers["agent-lcm"], { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"], + }); const codexManifest = readJson("hooks/codex.json"); const codexHooks = JSON.stringify(codexManifest); assert.deepEqual(Object.keys(codexManifest.hooks).sort(), [ diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts new file mode 100644 index 0000000..d501fda --- /dev/null +++ b/tests/setup-adapters.test.ts @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { NativeLifecycleCommandError, runHarnessLifecycle } from "../src/setup-adapters.ts"; + +const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("Codex setup and remove send the exact argv", (t) => { + // Given: a capable fake Codex CLI that records each argv vector. + const fake = fakeCli(t, "codex"); + + // When: Agent LCM sets up and removes Codex. + const setup = runHarnessLifecycle("codex", "setup", { env: fake.env }); + const remove = runHarnessLifecycle("codex", "remove", { env: fake.env }); + + // Then: only the documented argv arrays reached the CLI. + assert.deepEqual(setup, { + harness: "codex", + action: "setup", + status: "native-complete", + nativeCli: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(remove, { + harness: "codex", + action: "remove", + status: "native-complete", + nativeCli: "codex", + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], + ["plugin", "add", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); +}); + +test("Copilot and VS Code setup send the exact Copilot argv", (t) => { + // Given: a capable fake Copilot CLI that records each argv vector. + const fake = fakeCli(t, "copilot"); + + // When: Agent LCM sets up Copilot and VS Code. + const command = "/opt/agent-lcm/bin/agent-lcm"; + const copilot = runHarnessLifecycle("copilot", "setup", { env: fake.env, command }); + const vscode = runHarnessLifecycle("vscode", "setup", { env: fake.env, command }); + + // Then: both use the shared Copilot store and keep their own guide. + assert.deepEqual(copilot, { + harness: "copilot", + action: "setup", + status: "native-complete", + nativeCli: "copilot", + guide: `${GUIDE_ROOT}/copilot.md`, + }); + assert.deepEqual(vscode, { + harness: "vscode", + action: "setup", + status: "native-complete", + nativeCli: "copilot", + guide: `${GUIDE_ROOT}/vscode.md`, + }); + const calls = readCalls(fake.log); + assert.deepEqual(calls.map((argv) => argv.slice(0, 2)), [ + ["plugin", "list"], + ["plugin", "install"], + ["plugin", "list"], + ["plugin", "install"], + ]); + for (const argv of calls.filter((entry) => entry[1] === "install")) { + assert.equal(argv.length, 3); + assert.equal(path.basename(argv[2] ?? ""), "agent-lcm"); + assert.equal(fs.existsSync(argv[2] ?? ""), false); + } + for (const snapshot of readPluginSnapshots(fake.pluginLog)) { + assert.equal(snapshot.plugin.hooks, "hooks.json"); + assert.equal(snapshot.plugin.mcpServers, ".mcp.json"); + assert.equal(snapshot.skill, true); + assert.equal(JSON.stringify(snapshot.hooks).includes("${PLUGIN_ROOT}"), false); + assert.equal(JSON.stringify(snapshot.mcp).includes("${PLUGIN_ROOT}"), false); + assert.match(JSON.stringify(snapshot.hooks), new RegExp(command, "u")); + assert.deepEqual(snapshot.mcp.mcpServers["agent-lcm"], { + type: "stdio", + command: "node", + args: [command, "mcp"], + }); + } +}); + +test("manual-required outcomes probe only documented harness version commands", (t) => { + // Given: installed Cursor and Kiro CLIs, plus unavailable Codex and Copilot CLIs. + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-empty-bin-")); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + const cursorCli = fakeCli(t, "cursor-agent"); + const kiroCli = fakeCli(t, "kiro-cli"); + + // When: native setup is requested for every manual or unavailable harness. + const cursor = runHarnessLifecycle("cursor", "setup", { env: cursorCli.env }); + const missingCursor = runHarnessLifecycle("cursor", "remove", { env: { PATH: bin } }); + const kiro = runHarnessLifecycle("kiro", "remove", { env: kiroCli.env }); + const codex = runHarnessLifecycle("codex", "setup", { env: { PATH: bin } }); + + // Then: each reports its canonical guide without a native success claim. + assert.deepEqual(cursor, { + harness: "cursor", + action: "setup", + status: "manual-required", + nativeCli: "cursor-agent", + guide: `${GUIDE_ROOT}/cursor.md`, + }); + assert.deepEqual(kiro, { + harness: "kiro", + action: "remove", + status: "manual-required", + nativeCli: "kiro-cli", + guide: `${GUIDE_ROOT}/kiro.md`, + }); + assert.deepEqual(missingCursor, { + harness: "cursor", + action: "remove", + status: "manual-required", + nativeCli: null, + guide: `${GUIDE_ROOT}/cursor.md`, + }); + assert.deepEqual(codex, { + harness: "codex", + action: "setup", + status: "manual-required", + nativeCli: null, + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(readCalls(cursorCli.log), [["--version"]]); + assert.deepEqual(readCalls(kiroCli.log), [["--version"]]); +}); + +test("a failing native probe is a command error, not an unavailable CLI", (t) => { + const fake = fakeCli(t, "copilot", ["plugin", "list"]); + + assert.throws(() => runHarnessLifecycle("copilot", "setup", { env: fake.env }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.executable, "copilot"); + assert.deepEqual(error.argv, ["plugin", "list"]); + assert.equal(error.status, 23); + assert.equal(error.stderr, "suppressed"); + return true; + }); + assert.deepEqual(readCalls(fake.log), [["plugin", "list"]]); +}); + +test("a failing installed manual CLI probe is a command error", (t) => { + const fake = fakeCli(t, "kiro-cli", ["--version"]); + + assert.throws(() => runHarnessLifecycle("kiro", "setup", { env: fake.env }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.executable, "kiro-cli"); + assert.deepEqual(error.argv, ["--version"]); + assert.equal(error.status, 23); + assert.equal(error.stderr, "suppressed"); + return true; + }); + assert.deepEqual(readCalls(fake.log), [["--version"]]); +}); + +test("a native probe permission error is not treated as a missing CLI", { skip: process.platform === "win32" }, (t) => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-denied-cli-")); + fs.writeFileSync(path.join(bin, "codex"), "denied\n", { mode: 0o600 }); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + + assert.throws(() => runHarnessLifecycle("codex", "setup", { env: { PATH: bin } }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.deepEqual(error.argv, ["plugin", "list"]); + assert.equal(error.status, null); + assert.equal(error.stderr, "suppressed"); + return true; + }); +}); + +test("a Windows shim lookup error cannot fall through to a later PATH entry", { skip: process.platform !== "win32" }, (t) => { + const blocked = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-blocked-cli-")); + const fake = fakeCli(t, "codex"); + t.after(() => fs.rmSync(blocked, { recursive: true, force: true })); + t.mock.method(fs, "statSync", () => { + throw Object.assign(new Error("denied"), { code: "EACCES" }); + }); + + assert.throws( + () => runHarnessLifecycle("codex", "setup", { env: { ...fake.env, PATH: `${blocked}${path.delimiter}${fake.env.PATH ?? ""}` } }), + (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.status, null); + assert.equal(error.stderr, "suppressed"); + return true; + }, + ); + assert.equal(fs.existsSync(fake.log), false); +}); + +test("shared-retained removal does not spawn Copilot uninstall", (t) => { + // Given: a fake Copilot CLI that would record any spawned process. + const fake = fakeCli(t, "copilot"); + + // When: either shared-store harness is removed alone. + const vscode = runHarnessLifecycle("vscode", "remove", { env: fake.env }); + const copilot = runHarnessLifecycle("copilot", "remove", { env: fake.env }); + + // Then: no probe or uninstall runs and both outcomes retain the shared store. + assert.deepEqual(vscode, { + harness: "vscode", + action: "remove", + status: "shared-retained", + nativeCli: null, + guide: `${GUIDE_ROOT}/vscode.md`, + }); + assert.deepEqual(copilot, { + harness: "copilot", + action: "remove", + status: "shared-retained", + nativeCli: null, + guide: `${GUIDE_ROOT}/copilot.md`, + }); + assert.equal(fs.existsSync(fake.log), false); +}); + +test("mutating command failure is typed and cannot report completion", (t) => { + // Given: a capable fake Codex CLI that fails its marketplace mutation. + const fake = fakeCli(t, "codex", ["plugin", "marketplace", "add", PACKAGE_ROOT]); + + // When: Agent LCM attempts Codex setup. + const run = () => runHarnessLifecycle("codex", "setup", { env: fake.env }); + + // Then: the mutation failure includes its executable, argv, status, and bounded stderr. + assert.throws(run, (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.executable, "codex"); + assert.deepEqual(error.argv, ["plugin", "marketplace", "add", PACKAGE_ROOT]); + assert.equal(error.status, 23); + assert.equal(error.stderr, "suppressed"); + assert.equal( + error.message, + `Native lifecycle command failed: executable=codex argv=plugin marketplace add ${PACKAGE_ROOT} status=23 stderr=suppressed`, + ); + return true; + }); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], + ]); +}); + +function fakeCli( + t: test.TestContext, + name: "codex" | "copilot" | "cursor-agent" | "kiro-cli", + fails?: readonly string[], +): { readonly env: NodeJS.ProcessEnv; readonly log: string; readonly pluginLog: string } { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-fake-cli-")); + const log = path.join(bin, "calls.jsonl"); + const pluginLog = path.join(bin, "plugins.jsonl"); + const failure = fails ? JSON.stringify(fails) : ""; + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst path = require("node:path");\nconst args = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(args) + "\\n");\nif (args[0] === "plugin" && args[1] === "install" && args[2]) { const root = args[2]; fs.appendFileSync(process.env.AGENT_LCM_FAKE_PLUGIN_LOG, JSON.stringify({ plugin: JSON.parse(fs.readFileSync(path.join(root, "plugin.json"), "utf8")), hooks: JSON.parse(fs.readFileSync(path.join(root, "hooks.json"), "utf8")), mcp: JSON.parse(fs.readFileSync(path.join(root, ".mcp.json"), "utf8")), skill: fs.existsSync(path.join(root, "skills/lcm-recall/SKILL.md")) }) + "\\n"); }\nif (${JSON.stringify(failure)} && JSON.stringify(args) === ${JSON.stringify(failure)}) { process.stderr.write("mutation failed\\n"); process.exit(23); }\n`; + writeFakeCli(bin, name, script); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + return { + env: { + AGENT_LCM_FAKE_LOG: log, + AGENT_LCM_FAKE_PLUGIN_LOG: pluginLog, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + }, + log, + pluginLog, + }; +} + +function writeFakeCli(bin: string, name: string, script: string): void { + if (process.platform === "win32") { + const source = path.join(bin, `${name}.cjs`); + fs.writeFileSync(source, script.replace(/^#![^\n]*\n/u, "")); + fs.writeFileSync(path.join(bin, `${name}.cmd`), `@"${process.execPath}" "${source}" %*\r\n`); + return; + } + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); +} + +type PluginSnapshot = { + readonly plugin: Record; + readonly hooks: Record; + readonly mcp: { readonly mcpServers: Record }; + readonly skill: boolean; +}; + +function readPluginSnapshots(log: string): PluginSnapshot[] { + return fs.readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line) as PluginSnapshot); +} + +function readCalls(log: string): string[][] { + const contents = fs.readFileSync(log, "utf8").trim(); + return contents.length === 0 ? [] : contents.split("\n").map(readCall); +} + +function readCall(line: string): string[] { + const value: unknown = JSON.parse(line); + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + throw new Error("Fake CLI log entry must be a string array"); + } + return value; +} diff --git a/tests/setup-files.test.ts b/tests/setup-files.test.ts new file mode 100644 index 0000000..4d99bd0 --- /dev/null +++ b/tests/setup-files.test.ts @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import childProcess, { spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { mutateSetupConfiguration } from "../src/setup-files.ts"; +import { setupHarness } from "../src/setup.ts"; +import { tempHome } from "./helpers.ts"; + +test("setup refuses a target symlink without changing the victim", { skip: process.platform === "win32" }, () => { + // Given: a setup target points at an unrelated valid configuration. + const home = tempHome("agent-lcm-setup-symlink-"); + const target = path.join(home, "hooks.json"); + const victim = path.join(home, "victim.json"); + const original = Buffer.from('{"hooks":{}}\n'); + fs.writeFileSync(victim, original); + fs.symlinkSync(victim, target); + + // When: setup tries to mutate the target. + assert.throws( + () => setupHarness("codex", { home, command: "/opt/agent-lcm/bin/agent-lcm" }), + /Refus.*symlink/u, + ); + + // Then: neither the link nor its victim is changed and no setup artifact appears. + assert.equal(fs.lstatSync(target).isSymbolicLink(), true); + assert.deepEqual(fs.readFileSync(victim), original); + assert.deepEqual(setupArtifacts(home), []); +}); + +test("setup refuses a symlinked lock without changing the victim", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-lock-symlink-"); + const target = path.join(home, "hooks.json"); + const victim = path.join(home, "victim"); + fs.mkdirSync(victim); + fs.symlinkSync(victim, `${target}.lock`); + + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /lock.*symlink/u); + + assert.equal(fs.lstatSync(`${target}.lock`).isSymbolicLink(), true); + assert.deepEqual(fs.readdirSync(victim), []); + assert.equal(fs.existsSync(target), false); +}); + +test("setup refuses a symlinked parent directory", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-parent-symlink-"); + const victim = path.join(home, "victim"); + const linked = path.join(home, "linked"); + fs.mkdirSync(victim); + fs.symlinkSync(victim, linked); + + assert.throws(() => mutateSetupConfiguration(path.join(linked, "hooks.json"), () => ({ hooks: {} })), /directory symlink/u); + + assert.deepEqual(fs.readdirSync(victim), []); +}); + +test("a parent swap during lock acquisition cannot redirect setup", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-parent-race-"); + const safe = path.join(home, "safe"); + const originalSafe = path.join(home, "safe-original"); + const outside = path.join(home, "outside"); + const target = path.join(safe, "hooks.json"); + fs.mkdirSync(safe); + fs.mkdirSync(outside); + const originalSpawnSync = childProcess.spawnSync; + let swapped = false; + const swappedSpawnSync = ((command: string, args?: readonly string[], options?: childProcess.SpawnSyncOptions) => { + if (!swapped && args?.includes("lock")) { + swapped = true; + fs.renameSync(safe, originalSafe); + fs.symlinkSync(outside, safe); + } + return originalSpawnSync(command, args, options); + }) as typeof childProcess.spawnSync; + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: swappedSpawnSync }); + try { + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /directory changed/u); + } finally { + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: originalSpawnSync }); + } + assert.equal(fs.existsSync(path.join(outside, "hooks.json")), false); + assert.deepEqual(fs.readdirSync(outside), []); +}); + +test("a parent swap during final publication cannot overwrite an outside target", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-publish-parent-race-"); + const safe = path.join(home, "safe"); + const moved = path.join(home, "safe-moved"); + const outside = path.join(home, "outside"); + const target = path.join(safe, "hooks.json"); + const victim = path.join(outside, "hooks.json"); + const original = Buffer.from('{"outside":true}\n'); + fs.mkdirSync(safe); + fs.mkdirSync(outside); + fs.writeFileSync(victim, original); + const originalSpawnSync = childProcess.spawnSync; + let swapped = false; + const swappedSpawnSync = ((command: string, args?: readonly string[], options?: childProcess.SpawnSyncOptions) => { + if (!swapped && args?.includes("write")) { + swapped = true; + fs.renameSync(safe, moved); + fs.symlinkSync(outside, safe); + } + return originalSpawnSync(command, args, options); + }) as typeof childProcess.spawnSync; + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: swappedSpawnSync }); + try { + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /directory changed/u); + } finally { + Object.defineProperty(childProcess, "spawnSync", { configurable: true, value: originalSpawnSync }); + } + assert.equal(swapped, true); + assert.deepEqual(fs.readFileSync(victim), original); +}); + +test("a lock swap after a path check cannot chmod the victim", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-lock-chmod-race-"); + const target = path.join(home, "hooks.json"); + const lock = `${target}.lock`; + const victim = path.join(home, "victim.sqlite"); + fs.writeFileSync(lock, ""); + fs.writeFileSync(victim, ""); + fs.chmodSync(victim, 0o644); + const preload = path.join(home, "swap-lock.cjs"); + fs.writeFileSync(preload, `const fs = require("node:fs");\nconst original = fs.lstatSync;\nfs.lstatSync = function(candidate, options) { const status = original(candidate, options); if (candidate === "hooks.json.lock") { fs.unlinkSync(candidate); fs.symlinkSync(${JSON.stringify(victim)}, candidate); } return status; };\n`); + const originalNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = `--require=${preload}`; + try { + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /not a directory/u); + } finally { + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; + } + assert.equal(fs.statSync(victim).mode & 0o777, 0o644); +}); + +test("a target swap after opening cannot copy symlink-victim bytes", { skip: process.platform === "win32" }, () => { + const home = tempHome("agent-lcm-setup-target-race-"); + const target = path.join(home, "hooks.json"); + const victim = path.join(home, "victim.json"); + fs.writeFileSync(target, '{"hooks":{}}\n'); + fs.writeFileSync(victim, '{"isolated-secret":true}\n'); + const preload = path.join(home, "swap-target.cjs"); + fs.writeFileSync(preload, `const fs = require("node:fs");\nconst original = fs.openSync;\nfs.openSync = function(candidate, flags, mode) { const descriptor = original(candidate, flags, mode); if (candidate === "hooks.json") { fs.unlinkSync(candidate); fs.symlinkSync(${JSON.stringify(victim)}, candidate); } return descriptor; };\n`); + const originalNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = `--require=${preload}`; + try { + assert.throws( + () => mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })), + /path changed/u, + ); + } finally { + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; + } + const backups = fs.readdirSync(home).filter((name) => name.startsWith("hooks-pre-agent-lcm-")); + assert.deepEqual(backups, []); + assert.equal(fs.lstatSync(target).isSymbolicLink(), true); + assert.deepEqual(JSON.parse(fs.readFileSync(victim, "utf8")), { "isolated-secret": true }); +}); + +test("invalid setup bytes remain unchanged without backup or temporary files", () => { + // Given: an existing target contains invalid JSON bytes. + const home = tempHome("agent-lcm-setup-invalid-"); + const target = path.join(home, "hooks.json"); + const original = Buffer.from("{not json\n"); + fs.writeFileSync(target, original); + + // When: a serialized mutation tries to read it. + assert.throws(() => mutateSetupConfiguration(target, () => ({ hooks: {} })), /invalid setup configuration/u); + + // Then: the source bytes remain exact and no publication artifact exists. + assert.deepEqual(fs.readFileSync(target), original); + assert.deepEqual(setupArtifacts(home), []); +}); + +test("no-op setup mutations do not rewrite or back up the target", () => { + // Given: a valid setup target with a fixed modification time. + const home = tempHome("agent-lcm-setup-noop-"); + const target = path.join(home, "hooks.json"); + const original = Buffer.from('{"hooks":{}}\n'); + fs.writeFileSync(target, original); + const fixed = new Date("2026-01-01T00:00:00.000Z"); + fs.utimesSync(target, fixed, fixed); + + // When: the transform returns an equivalent configuration. + const changed = mutateSetupConfiguration(target, (configuration) => configuration ?? { hooks: {} }); + + // Then: no write or backup occurs. + assert.equal(changed, false); + assert.deepEqual(fs.readFileSync(target), original); + assert.equal(fs.statSync(target).mtimeMs, fixed.getTime()); + assert.deepEqual(setupArtifacts(home), []); +}); + +test("publication failure preserves the target, cleans temporary files, and keeps parent mode", { skip: process.platform === "win32" }, () => { + // Given: an existing setup file in a deliberately non-private existing directory. + const home = tempHome("agent-lcm-setup-publish-"); + const directory = path.join(home, "existing"); + const target = path.join(directory, "hooks.json"); + const original = Buffer.from('{"hooks":{}}\n'); + fs.mkdirSync(directory, { mode: 0o755 }); + fs.chmodSync(directory, 0o755); + fs.writeFileSync(target, original); + const preload = path.join(home, "fail-rename.cjs"); + fs.writeFileSync(preload, `const fs = require("node:fs");\nconst original = fs.renameSync;\nfs.renameSync = function(source, target) { if (String(source).endsWith(".tmp") && target === "hooks.json") throw new Error("injected publication failure"); return original(source, target); };\n`); + const originalNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = `--require=${preload}`; + + // When: atomic publication fails at rename. + try { + assert.throws( + () => mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })), + /injected publication failure/u, + ); + } finally { + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; + } + + // Then: the old target survives, temporary files are gone, and the directory mode is unchanged. + assert.deepEqual(fs.readFileSync(target), original); + assert.equal(fs.statSync(directory).mode & 0o777, 0o755); + assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith(".tmp")), []); +}); + +test("new setup directories and exact-byte backups use private modes", { skip: process.platform === "win32" }, () => { + // Given: a missing setup directory and then a valid target with non-canonical bytes. + const home = tempHome("agent-lcm-setup-modes-"); + const directory = path.join(home, "new", "hooks"); + const target = path.join(directory, "hooks.json"); + mutateSetupConfiguration(target, () => ({ hooks: {} })); + const original = Buffer.from('{ "hooks": {}, "keep": true }\n'); + fs.writeFileSync(target, original); + + // When: a changed transform publishes a new configuration. + mutateSetupConfiguration(target, (configuration) => ({ ...configuration, added: true })); + + // Then: the new directory is private and the backup retains exact bytes at a private mode. + const backup = fs.readdirSync(directory).find((name) => name.startsWith("hooks-pre-agent-lcm-")); + assert.notEqual(backup, undefined); + if (backup === undefined) throw new Error("expected setup backup"); + assert.equal(fs.statSync(directory).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(directory, backup)).mode & 0o777, 0o600); + assert.deepEqual(fs.readFileSync(path.join(directory, backup)), original); +}); + +test("serializes concurrent setup mutations", async () => { + // Given: one child holds the target lock while a second child reaches the mutation boundary. + const home = tempHome("agent-lcm-setup-concurrent-"); + const target = path.join(home, "hooks.json"); + fs.writeFileSync(target, '{"base":true}\n'); + const moduleUrl = new URL("../src/setup-files.ts", import.meta.url).href; + const holder = spawn(process.execPath, ["--no-warnings", "--input-type=module", "--eval", ` + const fs = (await import("node:fs")).default; + const { mutateSetupConfiguration } = await import(process.env.SETUP_FILES_URL); + mutateSetupConfiguration(process.env.SETUP_TARGET, (configuration) => { + process.stdout.write("locked\\n"); + fs.readSync(0, Buffer.alloc(1), 0, 1, null); + return { ...configuration, holder: true }; + }); + `], { + env: { ...process.env, SETUP_FILES_URL: moduleUrl, SETUP_TARGET: target }, + stdio: ["pipe", "pipe", "pipe"], + }); + await once(holder.stdout, "data"); + const waiter = spawn(process.execPath, ["--no-warnings", "--input-type=module", "--eval", ` + const { mutateSetupConfiguration } = await import(process.env.SETUP_FILES_URL); + process.stdout.write("waiting\\n"); + mutateSetupConfiguration(process.env.SETUP_TARGET, (configuration) => ({ ...configuration, waiter: true })); + `], { + env: { ...process.env, SETUP_FILES_URL: moduleUrl, SETUP_TARGET: target }, + stdio: ["ignore", "pipe", "pipe"], + }); + const holderErrors: Buffer[] = []; + const waiterErrors: Buffer[] = []; + holder.stderr.on("data", (chunk: Buffer) => holderErrors.push(chunk)); + waiter.stderr.on("data", (chunk: Buffer) => waiterErrors.push(chunk)); + await once(waiter.stdout, "data"); + + // When: the holder releases after the waiter has started its mutation call. + holder.stdin.end("x"); + const [holderExit, waiterExit] = await Promise.all([once(holder, "exit"), once(waiter, "exit")]); + + // Then: both transforms survive and neither process leaves a temporary file. + assert.equal(holderExit[0], 0, Buffer.concat(holderErrors).toString() || "holder failed"); + assert.equal(waiterExit[0], 0, Buffer.concat(waiterErrors).toString() || "waiter failed"); + assert.deepEqual(JSON.parse(fs.readFileSync(target, "utf8")), { base: true, holder: true, waiter: true }); + assert.deepEqual(fs.readdirSync(home).filter((name) => name.endsWith(".tmp")), []); +}); + +function setupArtifacts(directory: string): string[] { + return fs.readdirSync(directory).filter((name) => name.includes("pre-agent-lcm") || name.endsWith(".tmp")); +} diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 8811854..b80def4 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -3,10 +3,335 @@ import fs from "node:fs"; import path from "node:path"; import test from "node:test"; -import { writeSetupConfiguration } from "../src/setup-files.ts"; -import { setupHarness, setupStatus } from "../src/setup.ts"; +import { mutateSetupConfiguration } from "../src/setup-files.ts"; +import { removeHarness, setupHarness, setupStatus } from "../src/setup.ts"; import { assertCliOk, runCli, tempHome } from "./helpers.ts"; +const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; +const NO_CLI_ENV = { PATH: "" }; +const PACKAGE_ROOT = path.resolve("."); + +test("remove Codex deletes only exact owned hooks and is repeatable", (t) => { + const fake = fakeSetupCli(t, "codex"); + const home = tempHome("agent-lcm-remove-codex-"); + const target = path.join(home, "hooks.json"); + const original = JSON.stringify({ owner: "user", hooks: { + SessionStart: [{ matcher: "*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart' }, + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart extra', keep: true }, + ] }], + PreToolUse: [{ matcher: ".*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" hook PreToolUse' }, + { type: "command", command: 'node "/old/bin/agent-lcm" hook PostCompact', keep: true }, + ] }], + CustomEvent: [{ hooks: [{ type: "command", command: "keep-custom" }] }], + } }); + fs.writeFileSync(target, original); + + const first = removeHarness("codex", { home, env: fake.env }); + const second = removeHarness("codex", { home, env: fake.env }); + + assert.deepEqual(first, { + harness: "codex", + action: "remove", + status: "complete", + nativeCli: "codex", + hooks: { path: target, changed: true }, + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.equal(second.hooks.changed, false); + const configuration = JSON.parse(fs.readFileSync(target, "utf8")); + assert.deepEqual(configuration, { owner: "user", hooks: { + SessionStart: [{ matcher: "*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart extra', keep: true }, + ] }], + PreToolUse: [{ matcher: ".*", hooks: [ + { type: "command", command: 'node "/old/bin/agent-lcm" hook PostCompact', keep: true }, + ] }], + CustomEvent: [{ hooks: [{ type: "command", command: "keep-custom" }] }], + } }); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); + assert.equal(fs.readdirSync(home).filter((name) => name.startsWith("hooks-pre-agent-lcm-")).length, 1); + assert.equal(fs.readFileSync(path.join(home, fs.readdirSync(home).find((name) => name.startsWith("hooks-pre-agent-lcm-")) ?? ""), "utf8"), original); +}); + +test("remove Cursor and Kiro preserves adversarial near matches", () => { + const cursorHome = tempHome("agent-lcm-remove-cursor-"); + const cursorTarget = path.join(cursorHome, "hooks.json"); + fs.writeFileSync(cursorTarget, JSON.stringify({ version: 1, hooks: { + sessionStart: [ + { command: 'node "/old/bin/agent-lcm" capture --harness cursor SessionStart' }, + { command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', keep: true }, + ], + } })); + const cursor = removeHarness("cursor", { home: cursorHome, env: NO_CLI_ENV }); + assert.equal(cursor.status, "manual-required"); + assert.equal(cursor.hooks.changed, true); + assert.deepEqual(JSON.parse(fs.readFileSync(cursorTarget, "utf8")).hooks.sessionStart, [ + { command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', keep: true }, + ]); + + const kiroHome = tempHome("agent-lcm-remove-kiro-"); + const kiroTarget = path.join(kiroHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(kiroTarget), { recursive: true }); + fs.writeFileSync(kiroTarget, JSON.stringify({ version: "v1", hooks: [ + { name: "agent-lcm-kiro-SessionStart", trigger: "SessionStart", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro SessionStart' } }, + { name: "agent-lcm-kiro-SessionStart", trigger: "Stop", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro SessionStart' }, keep: true }, + { name: "agent-lcm-kiro-Stop", trigger: "Stop", action: { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness kiro Stop extra' }, keep: true }, + ] })); + const kiro = removeHarness("kiro", { home: kiroHome, env: NO_CLI_ENV }); + assert.equal(kiro.status, "manual-required"); + assert.equal(kiro.hooks.changed, true); + assert.equal(JSON.parse(fs.readFileSync(kiroTarget, "utf8")).hooks.length, 2); +}); + +test("remove shared harnesses retains byte-identical resources without spawning", (t) => { + const fake = fakeSetupCli(t, "copilot"); + const home = tempHome("agent-lcm-remove-shared-"); + const target = path.join(home, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const original = Buffer.from('{"version":1,"hooks":{"sessionStart":[{"command":"node \\"/old/bin/agent-lcm\\" capture --harness auto sessionStart"}]}}\n'); + fs.writeFileSync(target, original); + + const report = removeHarness("vscode", { home, env: fake.env }); + + assert.equal(report.status, "shared-retained"); + assert.deepEqual(report.hooks, { path: target, changed: false }); + assert.deepEqual(fs.readFileSync(target), original); + assert.equal(fs.existsSync(fake.log), false); +}); + +test("remove validates before native work and missing targets stay missing", (t) => { + const fake = fakeSetupCli(t, "codex"); + const invalidHome = tempHome("agent-lcm-remove-invalid-"); + const invalidTarget = path.join(invalidHome, "hooks.json"); + const original = Buffer.from('{"hooks":{"SessionStart":"invalid"}}\n'); + fs.writeFileSync(invalidTarget, original); + assert.throws(() => removeHarness("codex", { home: invalidHome, env: fake.env }), /invalid setup configuration/u); + assert.deepEqual(fs.readFileSync(invalidTarget), original); + assert.equal(fs.existsSync(fake.log), false); + assert.deepEqual(fs.readdirSync(invalidHome), ["hooks.json"]); + + const missingHome = tempHome("agent-lcm-remove-missing-"); + const missing = removeHarness("kiro", { home: missingHome, env: NO_CLI_ENV }); + assert.equal(missing.hooks.changed, false); + assert.equal(fs.existsSync(path.join(missingHome, "hooks")), false); +}); + +test("setup validates an existing hook schema before starting the native CLI", (t) => { + // Given: malformed Codex hooks and a fake CLI that records every process start. + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-setup-order-"); + fs.writeFileSync(path.join(clientHome, "hooks.json"), '{"hooks":{"CustomEvent":"invalid"}}\n'); + + // When: Codex setup is requested. + const run = () => setupHarness("codex", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: fake.env, + }); + + // Then: validation fails and no native process starts. + assert.throws(run, /invalid setup configuration/u); + assert.equal(fs.existsSync(fake.log), false); +}); + +test("Codex setup uses native plugin hooks without creating user hooks", (t) => { + // Given: a capable native Codex CLI and an empty Codex home. + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-codex-native-"); + + // When: Codex setup completes. + const report = setupHarness("codex", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: fake.env, + }); + + // Then: the native plugin owns hooks and no duplicate user hook file is created. + assert.deepEqual(report, { + harness: "codex", + action: "setup", + status: "complete", + nativeCli: "codex", + hooks: { path: path.join(clientHome, "hooks.json"), changed: false }, + guide: `${GUIDE_ROOT}/codex.md`, + }); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], + ["plugin", "add", "agent-lcm@agent-lcm"], + ]); + assert.equal(fs.existsSync(path.join(clientHome, "hooks.json")), false); +}); + +test("Codex setup reports recoverable partial state when hooks change during native install", (t) => { + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-codex-race-"); + const target = path.join(clientHome, "hooks.json"); + fs.writeFileSync(target, JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ + type: "command", + command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', + }] }] } })); + + const concurrent = JSON.stringify({ metadata: { user: true }, hooks: { SessionStart: [{ hooks: [{ + type: "command", + command: 'node "/old/bin/agent-lcm" capture --harness codex SessionStart', + }] }] } }); + assert.throws(() => setupHarness("codex", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }), /Native codex setup completed.*concurrent change.*did not overwrite.*rerun agent-lcm setup codex/u); + + assert.equal(fs.readFileSync(target, "utf8"), concurrent); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], + ["plugin", "add", "agent-lcm@agent-lcm"], + ]); +}); + +test("Codex remove reports recoverable partial state when hooks appear during native removal", (t) => { + const fake = fakeSetupCli(t, "codex"); + const clientHome = tempHome("agent-lcm-codex-remove-race-"); + const target = path.join(clientHome, "hooks.json"); + const concurrent = JSON.stringify({ metadata: { user: true }, hooks: {} }); + + assert.throws(() => removeHarness("codex", { + home: clientHome, + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }), /Native codex remove completed.*concurrent change.*did not overwrite.*rerun agent-lcm remove codex/u); + + assert.equal(fs.readFileSync(target, "utf8"), concurrent); + assert.deepEqual(readSetupCalls(fake.log), [ + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); +}); + +test("successful Copilot setup removes only legacy shared Agent LCM hooks", (t) => { + // Given: a capable Copilot CLI and a shared hook file with owned and unrelated hooks. + const fake = fakeSetupCli(t, "copilot"); + const clientHome = tempHome("agent-lcm-copilot-native-"); + const hooksPath = path.join(clientHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(hooksPath), { recursive: true }); + fs.writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: { + sessionStart: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness auto sessionStart' }, + { type: "command", command: "keep-me" }, + ], + userPromptSubmitted: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness auto userPromptSubmitted' }, + ], + PostToolUse: [ + { type: "command", command: 'node "/old/bin/agent-lcm" capture --harness vscode PostToolUse' }, + ], + custom: [{ type: "command", command: "also-keep-me" }], + } })); + + // When: native Copilot setup succeeds. + const report = setupHarness("copilot", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: fake.env, + }); + + // Then: the legacy registration is removed without touching sibling hooks. + assert.deepEqual(report, { + harness: "copilot", + action: "setup", + status: "complete", + nativeCli: "copilot", + hooks: { path: hooksPath, changed: true }, + guide: `${GUIDE_ROOT}/copilot.md`, + }); + assert.deepEqual(JSON.parse(fs.readFileSync(hooksPath, "utf8")), { version: 1, hooks: { + sessionStart: [{ type: "command", command: "keep-me" }], + userPromptSubmitted: [], + PostToolUse: [], + custom: [{ type: "command", command: "also-keep-me" }], + } }); + assert.deepEqual(setupStatus({ home: clientHome }).copilot, { + hooksConfigured: false, + path: hooksPath, + }); +}); + +test("manual setup preserves legacy shared hooks and creates no target", (t) => { + // Given: an empty PATH, one existing shared target, and one absent Cursor target. + const emptyBin = fs.mkdtempSync(path.join(tempHome("agent-lcm-empty-cli-"), "bin")); + t.after(() => fs.rmSync(path.dirname(emptyBin), { recursive: true, force: true })); + const clientHome = tempHome("agent-lcm-manual-shared-"); + const hooksPath = path.join(clientHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(hooksPath), { recursive: true }); + const original = '{"version":1,"hooks":{"sessionStart":[{"command":"node \\"/old/bin/agent-lcm\\" capture --harness auto sessionStart"}]}}\n'; + fs.writeFileSync(hooksPath, original); + + // When: shared and Cursor setup both require manual work. + const shared = setupHarness("vscode", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { PATH: emptyBin }, + }); + const cursorHome = tempHome("agent-lcm-manual-cursor-"); + const cursor = setupHarness("cursor", { + home: cursorHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { PATH: emptyBin }, + }); + + // Then: both report manual work, preserving or omitting files as found. + assert.equal(shared.status, "manual-required"); + assert.deepEqual(shared.hooks, { path: hooksPath, changed: false }); + assert.equal(fs.readFileSync(hooksPath, "utf8"), original); + assert.equal(cursor.status, "manual-required"); + assert.deepEqual(cursor.hooks, { path: path.join(cursorHome, "hooks.json"), changed: false }); + assert.equal(fs.existsSync(path.join(cursorHome, "hooks.json")), false); +}); + +test("manual setup preserves a concurrent hook rewrite when no mutation is needed", (t) => { + const fake = fakeSetupCli(t, "cursor-agent"); + const clientHome = tempHome("agent-lcm-cursor-manual-race-"); + const target = path.join(clientHome, "hooks.json"); + fs.writeFileSync(target, JSON.stringify({ version: 1, hooks: {} })); + const concurrent = JSON.stringify({ version: 1, metadata: { user: true }, hooks: {} }); + + const report = setupHarness("cursor", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }); + + assert.equal(report.status, "manual-required"); + assert.deepEqual(report.hooks, { path: target, changed: false }); + assert.equal(fs.readFileSync(target, "utf8"), concurrent); + assert.deepEqual(readSetupCalls(fake.log), [["--version"]]); +}); + +test("Kiro setup reports a concurrent hook rewrite without claiming native install", (t) => { + const fake = fakeSetupCli(t, "kiro-cli"); + const clientHome = tempHome("agent-lcm-kiro-race-"); + const target = path.join(clientHome, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify({ version: "v1", hooks: [] })); + const concurrent = JSON.stringify({ version: "v1", metadata: { user: true }, hooks: [] }); + + assert.throws(() => setupHarness("kiro", { + home: clientHome, + command: "/opt/agent-lcm/bin/agent-lcm", + env: { ...fake.env, AGENT_LCM_FAKE_MUTATE_TARGET: target, AGENT_LCM_FAKE_MUTATE_CONTENT: concurrent }, + }), /kiro setup stopped.*concurrent change.*did not overwrite.*rerun agent-lcm setup kiro/u); + + assert.equal(fs.readFileSync(target, "utf8"), concurrent); + assert.deepEqual(readSetupCalls(fake.log), [["--version"]]); +}); + test("Kiro setup uses the native array schema, is repeatable, and leaves sibling hooks unchanged", () => { const kiroHome = tempHome("agent-lcm-kiro-"); const unrelatedKiroHook = path.join(kiroHome, "hooks", "other.json"); @@ -14,14 +339,14 @@ test("Kiro setup uses the native array schema, is repeatable, and leaves sibling fs.writeFileSync(unrelatedKiroHook, '{"version":"v1","hooks":[{"name":"other","trigger":"SessionStart","action":{"type":"command","command":"other"}}]}\n'); const original = fs.readFileSync(unrelatedKiroHook); - const first = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - const second = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }); + const first = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }); + const second = setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }); - assert.equal(first.changed, true); - assert.equal(second.changed, false); - assert.equal(first.path, path.join(kiroHome, "hooks", "agent-lcm.json")); + assert.equal(first.hooks.changed, true); + assert.equal(second.hooks.changed, false); + assert.equal(first.hooks.path, path.join(kiroHome, "hooks", "agent-lcm.json")); assert.deepEqual(fs.readFileSync(unrelatedKiroHook), original); - const configuration = JSON.parse(fs.readFileSync(first.path, "utf8")); + const configuration = JSON.parse(fs.readFileSync(first.hooks.path, "utf8")); assert.equal(configuration.version, "v1"); assert.equal(Array.isArray(configuration.hooks), true); assert.equal(configuration.hooks.length, 4); @@ -40,10 +365,11 @@ test("setup leaves invalid owned configuration untouched", () => { const original = fs.readFileSync(setupPath); assert.throws( - () => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }), + () => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }), new RegExp(setupPath.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&"), "u"), ); assert.deepEqual(fs.readFileSync(setupPath), original); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json"]); }); test("setup rejects malformed Kiro schema without changing the owned file", () => { @@ -53,8 +379,9 @@ test("setup rejects malformed Kiro schema without changing the owned file", () = fs.writeFileSync(setupPath, '{"version":"v1","hooks":{}}\n'); const original = fs.readFileSync(setupPath); - assert.throws(() => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm" }), /invalid setup configuration/u); + assert.throws(() => setupHarness("kiro", { home: kiroHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }), /invalid setup configuration/u); assert.deepEqual(fs.readFileSync(setupPath), original); + assert.deepEqual(fs.readdirSync(path.dirname(setupPath)).sort(), ["agent-lcm.json"]); }); test("setup rejects malformed Codex custom events without changing or backing up the file", () => { @@ -68,34 +395,26 @@ test("setup rejects malformed Codex custom events without changing or backing up /invalid setup configuration/u, ); assert.deepEqual(fs.readFileSync(setupPath), original); - assert.deepEqual(fs.readdirSync(clientHome), ["hooks.json"]); + assert.deepEqual(fs.readdirSync(clientHome).sort(), ["hooks.json"]); }); -test("Copilot and VS Code converge on one lower-camel shared user hook configuration", () => { +test("manual Copilot and VS Code setup creates no shared user hook configuration", () => { const clientHome = tempHome("agent-lcm-copilot-"); - const copilot = setupHarness("copilot", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - const vscode = setupHarness("vscode", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); - - assert.equal(copilot.path, path.join(clientHome, "hooks", "agent-lcm.json")); - assert.equal(vscode.path, copilot.path); - assert.equal(vscode.changed, false); - const configuration = JSON.parse(fs.readFileSync(copilot.path, "utf8")); - assert.equal(configuration.version, 1); - assert.equal(configuration.hooks.userPromptSubmitted[0].type, "command"); - assert.equal( - configuration.hooks.userPromptSubmitted[0].command, - 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness auto userPromptSubmitted', - ); - assert.deepEqual(Object.keys(configuration.hooks).sort(), ["postToolUse", "sessionEnd", "sessionStart", "userPromptSubmitted"]); - assert.equal(setupStatus({ home: clientHome }).copilot.configured, true); - assert.equal(setupStatus({ home: clientHome }).vscode.configured, true); - - fs.writeFileSync(copilot.path, '{"version":1,"hooks":{"userPromptSubmitted":[{"command":"agent-lcm"}]}}\n'); - assert.equal(setupStatus({ home: clientHome }).copilot.configured, false); + const env = { PATH: path.join(clientHome, "empty-bin") }; + const copilot = setupHarness("copilot", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env }); + const vscode = setupHarness("vscode", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env }); + + assert.equal(copilot.status, "manual-required"); + assert.equal(vscode.status, "manual-required"); + assert.equal(copilot.hooks.path, path.join(clientHome, "hooks", "agent-lcm.json")); + assert.equal(vscode.hooks.path, copilot.hooks.path); + assert.equal(vscode.hooks.changed, false); + assert.equal(fs.existsSync(copilot.hooks.path), false); }); -test("shared setup replaces older Agent LCM registrations after a binary move without touching sibling hooks", () => { +test("native shared setup removes older Agent LCM registrations without touching sibling hooks", (t) => { const clientHome = tempHome("agent-lcm-copilot-legacy-"); + const fake = fakeSetupCli(t, "copilot"); const setupPath = path.join(clientHome, "hooks", "agent-lcm.json"); fs.mkdirSync(path.dirname(setupPath), { recursive: true }); fs.writeFileSync(setupPath, JSON.stringify({ @@ -117,7 +436,12 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi }, { type: "command", command: "\"/opt/custom-agent-lcm\" capture --harness vscode UserPromptSubmit" }, ], - sessionStart: [{ type: "command", command: "other-hook", timeout: 30 }], + sessionStart: [{ + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness auto sessionStart', + timeout: 30, + metadata: { owner: "user" }, + }], customEvent: [{ type: "command", command: "custom-hook", custom: true }], customCaptureEvent: [{ type: "command", @@ -127,30 +451,24 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi }, })); - const first = setupHarness("vscode", { home: clientHome, command: "/new-location/bin/agent-lcm" }); - const second = setupHarness("copilot", { home: clientHome, command: "/new-location/bin/agent-lcm" }); + const first = setupHarness("vscode", { home: clientHome, command: "/new-location/bin/agent-lcm", env: fake.env }); + const second = setupHarness("copilot", { home: clientHome, command: "/new-location/bin/agent-lcm", env: fake.env }); const configuration = JSON.parse(fs.readFileSync(setupPath, "utf8")); - assert.equal(first.changed, true); - assert.equal(second.changed, false); + assert.equal(first.hooks.changed, true); + assert.equal(second.hooks.changed, false); assert.equal(configuration.owner, "user"); assert.deepEqual(configuration.hooks.UserPromptSubmit, [ { type: "command", command: "\"/opt/custom-agent-lcm\" capture --harness vscode UserPromptSubmit" }, ]); - assert.deepEqual(configuration.hooks.sessionStart[0], { type: "command", command: "other-hook", timeout: 30 }); - assert.equal(configuration.hooks.sessionStart[1].command, "node \"/new-location/bin/agent-lcm\" capture --harness auto sessionStart"); - assert.deepEqual(configuration.hooks.userPromptSubmitted[0], { + assert.deepEqual(configuration.hooks.sessionStart[0], { type: "command", - command: 'node "/new-location/bin/agent-lcm" capture --harness auto userPromptSubmitted', - timeout: 45, - metadata: { keep: true }, - }); - assert.deepEqual(configuration.hooks.userPromptSubmitted[1], { - type: "command", - command: 'node "/new-location/bin/agent-lcm" capture --harness auto userPromptSubmitted', - timeout: 60, - metadata: { keep: "duplicate" }, + command: 'node "/opt/not-agent-lcm" capture --harness auto sessionStart', + timeout: 30, + metadata: { owner: "user" }, }); + assert.equal(configuration.hooks.sessionStart.length, 1); + assert.equal(configuration.hooks.userPromptSubmitted, undefined); assert.deepEqual(configuration.hooks.customEvent, [{ type: "command", command: "custom-hook", custom: true }]); assert.deepEqual(configuration.hooks.customCaptureEvent, [{ type: "command", @@ -159,8 +477,9 @@ test("shared setup replaces older Agent LCM registrations after a binary move wi }]); }); -test("Codex setup replaces its old Agent LCM commands and preserves unrelated hooks", () => { +test("Codex setup removes old Agent LCM commands and preserves unrelated hooks", (t) => { const clientHome = tempHome("agent-lcm-codex-legacy-"); + const fake = fakeSetupCli(t, "codex"); const setupPath = path.join(clientHome, "hooks.json"); fs.mkdirSync(path.dirname(setupPath), { recursive: true }); const original = JSON.stringify({ owner: "user", hooks: { @@ -173,7 +492,12 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho { type: "command", command: "other-post-compact-hook", timeout: 30 }, ] }], PreToolUse: [{ matcher: "Read", hooks: [ - { type: "command", command: "other-pre-tool-hook", timeout: 30 }, + { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness codex PreToolUse', + timeout: 30, + metadata: { owner: "user" }, + }, ] }], CustomEvent: [{ hooks: [{ type: "command", @@ -183,19 +507,14 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho } }); fs.writeFileSync(setupPath, original); - const first = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm" }); - const second = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm" }); + const first = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm", env: fake.env }); + const second = setupHarness("codex", { home: clientHome, command: "/new/bin/agent-lcm", env: fake.env }); const configuration = JSON.parse(fs.readFileSync(setupPath, "utf8")); - assert.equal(first.changed, true); - assert.equal(second.changed, false); + assert.equal(first.hooks.changed, true); + assert.equal(second.hooks.changed, false); assert.equal(configuration.owner, "user"); assert.deepEqual(configuration.hooks.SessionStart, [{ matcher: "*", hooks: [ - { - type: "command", - command: "node \"/new/bin/agent-lcm\" capture --harness codex SessionStart", - timeout: 15, - }, { type: "command", command: "other-hook", timeout: 30 }, ] }]); assert.deepEqual(configuration.hooks.CustomEvent, [{ hooks: [{ @@ -204,60 +523,55 @@ test("Codex setup replaces its old Agent LCM commands and preserves unrelated ho owner: "user", }] }]); assert.deepEqual(configuration.hooks.PostCompact, [{ matcher: "*", hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook PostCompact', timeout: 15 }, { type: "command", command: "other-post-compact-hook", timeout: 30 }, ] }]); assert.deepEqual(configuration.hooks.PreToolUse, [{ matcher: "Read", hooks: [ - { type: "command", command: "other-pre-tool-hook", timeout: 30 }, - ] }, { matcher: ".*", hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook PreToolUse' }, - ] }]); - assert.deepEqual(configuration.hooks.PreCompact, [{ hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook PreCompact' }, - ] }]); - assert.deepEqual(configuration.hooks.SubagentStop, [{ hooks: [ - { type: "command", command: 'node "/new/bin/agent-lcm" hook SubagentStop' }, + { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness codex PreToolUse', + timeout: 30, + metadata: { owner: "user" }, + }, ] }]); - assert.equal(setupStatus({ home: clientHome }).codex.configured, true); + assert.equal(configuration.hooks.PreCompact, undefined); + assert.equal(configuration.hooks.SubagentStop, undefined); + assert.equal(setupStatus({ home: clientHome }).codex.hooksConfigured, false); const backups = fs.readdirSync(clientHome).filter((name) => name.startsWith("hooks-pre-agent-lcm-")); assert.equal(backups.length, 1); assert.equal(fs.readFileSync(path.join(clientHome, backups[0] ?? ""), "utf8"), original); }); -test("Cursor setup writes the user hooks file in Cursor's native schema", () => { +test("Cursor setup validates and preserves its legacy user hooks", () => { const clientHome = tempHome("agent-lcm-cursor-"); const hooksPath = path.join(clientHome, "hooks.json"); fs.writeFileSync(hooksPath, JSON.stringify({ version: 1, owner: "user", hooks: { - stop: [{ command: "other-hook", timeout: 30 }], + stop: [{ + command: 'node "/opt/not-agent-lcm" capture --harness cursor Stop', + timeout: 30, + metadata: { owner: "user" }, + }], } })); - const report = setupHarness("cursor", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); + const original = fs.readFileSync(hooksPath); + const report = setupHarness("cursor", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env: NO_CLI_ENV }); - assert.equal(report.path, path.join(clientHome, "hooks.json")); - assert.deepEqual(JSON.parse(fs.readFileSync(report.path, "utf8")), { - version: 1, - owner: "user", - hooks: { - sessionStart: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor SessionStart' }], - beforeSubmitPrompt: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor UserPromptSubmit' }], - postToolUse: [{ command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor PostToolUse' }], - stop: [ - { command: "other-hook", timeout: 30 }, - { command: 'node "/opt/agent-lcm/bin/agent-lcm" capture --harness cursor Stop' }, - ], - }, - }); + assert.equal(report.hooks.path, path.join(clientHome, "hooks.json")); + assert.equal(report.hooks.changed, false); + assert.deepEqual(fs.readFileSync(report.hooks.path), original); }); test("setup all configures only harnesses already installed for the user", () => { const userHome = tempHome("agent-lcm-detected-"); fs.mkdirSync(path.join(userHome, ".codex")); - const result = runCli(["setup", "all", "--json"], { env: { HOME: userHome, USERPROFILE: userHome } }); - assertCliOk(result); + const result = runCli(["setup", "all", "--json"], { env: { HOME: userHome, USERPROFILE: userHome, PATH: "" } }); + assert.equal(result.status, 2, result.stderr); assert.deepEqual(JSON.parse(result.stdout), [{ harness: "codex", - path: path.join(userHome, ".codex", "hooks.json"), - changed: true, + action: "setup", + status: "manual-required", + nativeCli: null, + hooks: { path: path.join(userHome, ".codex", "hooks.json"), changed: false }, + guide: `${GUIDE_ROOT}/codex.md`, }]); assert.equal(fs.existsSync(path.join(userHome, ".cursor")), false); assert.equal(fs.existsSync(path.join(userHome, ".copilot")), false); @@ -266,23 +580,95 @@ test("setup all configures only harnesses already installed for the user", () => test("setup prints a clear result for people and keeps JSON output for scripts", () => { const userHome = tempHome("agent-lcm-output-"); - const text = runCli(["setup", "codex", "--home", userHome]); - assertCliOk(text); - assert.equal(text.stdout, `codex hooks have been configured: ${path.join(userHome, "hooks.json")}\n`); - - const json = runCli(["setup", "codex", "--home", userHome, "--json"]); - assertCliOk(json); + const text = runCli(["setup", "codex", "--home", userHome], { env: { PATH: "" } }); + assert.equal(text.status, 2, text.stderr); + assert.match(text.stdout, /codex setup: manual-required/u); + assert.match(text.stdout, /Native CLI unavailable/u); + assert.match(text.stdout, new RegExp(`${GUIDE_ROOT}/codex\\.md`, "u")); + + const json = runCli(["setup", "codex", "--home", userHome, "--json"], { env: { PATH: "" } }); + assert.equal(json.status, 2, json.stderr); assert.deepEqual(JSON.parse(json.stdout), { harness: "codex", - path: path.join(userHome, "hooks.json"), - changed: false, + action: "setup", + status: "manual-required", + nativeCli: null, + hooks: { path: path.join(userHome, "hooks.json"), changed: false }, + guide: `${GUIDE_ROOT}/codex.md`, + }); +}); + +test("CLI setup and remove use native Codex with an isolated explicit home", (t) => { + const home = path.join(tempHome("agent-lcm-cli-native-"), "new-codex-home"); + const fake = fakeLifecycleCli(t, "codex"); + const env = { PATH: fake.path, AGENT_LCM_FAKE_LOG: fake.log }; + + const setup = runCli(["setup", "codex", "--home", home, "--json"], { env }); + assertCliOk(setup); + assert.equal(JSON.parse(setup.stdout).status, "complete"); + + const remove = runCli(["remove", "codex", "--home", home, "--json"], { env }); + assertCliOk(remove); + assert.deepEqual(JSON.parse(remove.stdout), { + harness: "codex", + action: "remove", + status: "complete", + nativeCli: "codex", + hooks: { path: path.join(home, "hooks.json"), changed: false }, + guide: `${GUIDE_ROOT}/codex.md`, + }); + + const calls = fs.readFileSync(fake.log, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(calls.map((call) => call.argv), [ + ["plugin", "list"], + ["plugin", "marketplace", "add", PACKAGE_ROOT], + ["plugin", "add", "agent-lcm@agent-lcm"], + ["plugin", "list"], + ["plugin", "remove", "agent-lcm@agent-lcm"], + ]); + for (const call of calls) assert.deepEqual(call.env, { + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + COPILOT_HOME: home, + AGENT_LCM_HOME: path.join(home, "agent-lcm"), + }); +}); + +test("CLI reports a native probe failure without writing hooks or leaking stderr", (t) => { + const home = path.join(tempHome("agent-lcm-cli-probe-failure-"), "new-codex-home"); + const fake = fakeLifecycleCli(t, "codex", true); + + const result = runCli(["setup", "codex", "--home", home, "--json"], { + env: { PATH: fake.path, AGENT_LCM_FAKE_LOG: fake.log }, }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /status=23 stderr=suppressed/u); + assert.doesNotMatch(result.stderr, /secret-token/u); + assert.equal(fs.existsSync(path.join(home, "hooks.json")), false); +}); + +test("CLI remove reports unsupported native removal without changing shared resources", () => { + const home = tempHome("agent-lcm-cli-shared-"); + const target = path.join(home, "hooks", "agent-lcm.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const original = Buffer.from('{"version":1,"hooks":{}}\n'); + fs.writeFileSync(target, original); + + const result = runCli(["remove", "vscode", "--home", home]); + + assert.equal(result.status, 2, result.stderr); + assert.match(result.stdout, /vscode remove: shared-retained/u); + assert.match(result.stdout, new RegExp(`${GUIDE_ROOT}/vscode\\.md`, "u")); + assert.deepEqual(fs.readFileSync(target), original); }); -test("setup never overwrites an existing timestamped backup", () => { +test("setup never overwrites an existing timestamped backup", (t) => { const clientHome = tempHome("agent-lcm-backup-collision-"); + const fake = fakeSetupCli(t, "codex"); const setupPath = path.join(clientHome, "hooks.json"); - const original = '{"hooks":{}}\n'; + const original = '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node \\\"/old/bin/agent-lcm\\\" capture --harness codex SessionStart"}]}]}}\n'; const timestamp = "2026-08-07T12-34-56-789Z"; const firstBackup = path.join(clientHome, `hooks-pre-agent-lcm-${timestamp}.json`); const nextBackup = path.join(clientHome, `hooks-pre-agent-lcm-${timestamp}-1.json`); @@ -291,7 +677,7 @@ test("setup never overwrites an existing timestamped backup", () => { const originalToISOString = Date.prototype.toISOString; Date.prototype.toISOString = () => "2026-08-07T12:34:56.789Z"; try { - setupHarness("codex", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm" }); + setupHarness("codex", { home: clientHome, command: "/opt/agent-lcm/bin/agent-lcm", env: fake.env }); } finally { Date.prototype.toISOString = originalToISOString; } @@ -307,7 +693,7 @@ test("setup writes never follow a predictable temporary symlink", { skip: proces fs.writeFileSync(victim, "do not overwrite"); fs.symlinkSync(victim, `${setupPath}.${process.pid}.tmp`); - writeSetupConfiguration(setupPath, { hooks: {} }); + mutateSetupConfiguration(setupPath, () => ({ hooks: {} })); assert.equal(fs.readFileSync(victim, "utf8"), "do not overwrite"); assert.deepEqual(JSON.parse(fs.readFileSync(setupPath, "utf8")), { hooks: {} }); @@ -337,7 +723,11 @@ test("Kiro setup updates its owned hooks after a binary move", () => { { name: "agent-lcm-kiro-PostToolUse", trigger: "PostToolUse", - action: { type: "command", command: "user-owned-command", timeout: 45 }, + action: { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness kiro PostToolUse', + timeout: 45, + }, metadata: { owner: "user" }, }, { @@ -352,7 +742,7 @@ test("Kiro setup updates its owned hooks after a binary move", () => { }, ] })); - setupHarness("kiro", { home: clientHome, command: "/new/bin/agent-lcm" }); + setupHarness("kiro", { home: clientHome, command: "/new/bin/agent-lcm", env: NO_CLI_ENV }); const configuration = JSON.parse(fs.readFileSync(setupPath, "utf8")); assert.equal(configuration.owner, "user"); assert.equal(configuration.hooks[0].action.command, "node \"/new/bin/agent-lcm\" capture --harness kiro SessionStart"); @@ -367,7 +757,11 @@ test("Kiro setup updates its owned hooks after a binary move", () => { assert.deepEqual(configuration.hooks[2], { name: "agent-lcm-kiro-PostToolUse", trigger: "PostToolUse", - action: { type: "command", command: "user-owned-command", timeout: 45 }, + action: { + type: "command", + command: 'node "/opt/not-agent-lcm" capture --harness kiro PostToolUse', + timeout: 45, + }, metadata: { owner: "user" }, }); assert.equal(configuration.hooks[3].action.command, "node \"/new/bin/agent-lcm\" capture --harness kiro SessionStart"); @@ -395,3 +789,53 @@ test("setup requires an absolute installed binary path", () => { ); assert.equal(fs.existsSync(path.join(clientHome, "hooks", "agent-lcm.json")), false); }); + +function fakeSetupCli( + t: test.TestContext, + name: "codex" | "copilot" | "cursor-agent" | "kiro-cli", +): { readonly env: NodeJS.ProcessEnv; readonly log: string } { + const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-setup-cli-parent-"), "bin-")); + const log = path.join(bin, "calls.jsonl"); + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (process.env.AGENT_LCM_FAKE_MUTATE_TARGET && (JSON.stringify(argv) === JSON.stringify(["plugin", "list"]) || JSON.stringify(argv) === JSON.stringify(["--version"]))) fs.writeFileSync(process.env.AGENT_LCM_FAKE_MUTATE_TARGET, process.env.AGENT_LCM_FAKE_MUTATE_CONTENT);\n`; + writeFakeSetupCli(bin, name, script); + t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); + return { + env: { AGENT_LCM_FAKE_LOG: log, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` }, + log, + }; +} + +function fakeLifecycleCli( + t: test.TestContext, + name: "codex" | "copilot", + failProbe = false, +): { readonly path: string; readonly log: string } { + const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-lifecycle-cli-parent-"), "bin-")); + const log = path.join(bin, "calls.jsonl"); + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +if (!fs.existsSync(process.env.CODEX_HOME)) process.exit(24); +fs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify({ argv: process.argv.slice(2), env: { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, CODEX_HOME: process.env.CODEX_HOME, COPILOT_HOME: process.env.COPILOT_HOME, AGENT_LCM_HOME: process.env.AGENT_LCM_HOME } }) + "\\n"); +if (${String(failProbe)} && JSON.stringify(process.argv.slice(2)) === JSON.stringify(["plugin", "list"])) { process.stderr.write("secret-token\\n"); process.exit(23); } +`; + writeFakeSetupCli(bin, name, script); + t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); + return { + path: `${bin}${path.delimiter}${path.dirname(process.execPath)}`, + log, + }; +} + +function writeFakeSetupCli(bin: string, name: string, script: string): void { + if (process.platform === "win32") { + const source = path.join(bin, `${name}.cjs`); + fs.writeFileSync(source, script.replace(/^#![^\n]*\n/u, "")); + fs.writeFileSync(path.join(bin, `${name}.cmd`), `@"${process.execPath}" "${source}" %*\r\n`); + return; + } + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); +} + +function readSetupCalls(log: string): unknown[] { + return fs.readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)); +}