diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..7e067940 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,12 @@ +{ + "name": "zerograph", + "interface": { "displayName": "zerograph" }, + "plugins": [ + { + "name": "zerograph", + "source": { "source": "local", "path": "./agent/codex" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Coding" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9254c9da..d8b00ba9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: uses: actions/setup-node@v4 with: { node-version: '20' } - if: ${{ !cancelled() }} - run: node --test agent/hook.test.mjs agent/claude-code/scripts/mcp.test.mjs npm/zerograph/test/launcher.test.js + run: node --test agent/claude-code/scripts/hook.test.mjs agent/claude-code/scripts/mcp.test.mjs agent/codex/plugin.test.mjs npm/zerograph/test/launcher.test.js # A default Git for Windows install puts `git` on PATH and not `sh` — the Unix tools are an # opt-in — so `ask --rerank` looks for one. Route 1: Git's shell directories diff --git a/README.md b/README.md index 57a0fa63..cab0b5fc 100644 --- a/README.md +++ b/README.md @@ -34,27 +34,34 @@ npm install -g zerograph # or: pnpm add -D zerograph echo .zerograph/ >> .gitignore # the graph is built per checkout zerograph build # first run downloads the embedding model zerograph ask cancellation policy # stays fresh with your edits -zerograph install-agent --claude # optional: MCP server + session hook +zerograph install-agent --claude # optional: the Claude Code plugin ``` -`install-agent` also takes `--codex`, `--cursor`, `--copilot`, `--gemini` and `--opencode`. On -Windows, see the [Windows notes](docs/install.md#windows-notes). +`install-agent` also takes `--codex` (the Codex plugin), `--cursor`, `--copilot`, `--gemini` and +`--opencode`. On Windows, see the [Windows notes](docs/install.md#windows-notes). ## Claude Code plugin -A status mod for Claude Code puts the index's state under the prompt: `◇ zerograph · in sync`, a -progress bar while a refresh runs, the files behind and whether `serve` answers. `/zerograph` -prints the full `zerograph status` report. +The plugin is how zerograph wires into Claude Code: it starts zerograph's MCP server for the +session, briefs each session and subagent on the index, answers a concept grep from the graph +before it runs, and names the callers an edit can break. A status mod puts the index's state under +the prompt: `◇ zerograph · in sync`, a progress bar while a refresh runs, the files behind and +whether `serve` answers. `/zerograph` prints the full `zerograph status` report. ``` /plugin install zerograph --marketplace devmaxxx/zerograph ``` -It is pinned to the latest release, so `claude plugin update zerograph` moves it with zerograph. -It starts zerograph's MCP server for the session and adds `/zerograph:ask`, `/zerograph:impact`, -`/zerograph:trace`, `/zerograph:changes`, `/zerograph:explain`, `/zerograph:status` and -`/zerograph:refresh`, each of which calls that server's tools and reads the top hits. With the -plugin, skip `install-agent --claude`'s MCP server; see [the plugin](docs/install.md#claude-code-plugin). +`zerograph install-agent --claude` runs the same install through the `claude` CLI and removes what +older releases wrote into the checkout. The plugin is pinned to the latest release, so +`claude plugin update zerograph` moves it with zerograph. It adds `/zerograph:ask`, +`/zerograph:impact`, `/zerograph:trace`, `/zerograph:changes`, `/zerograph:explain`, +`/zerograph:status` and `/zerograph:refresh`, each of which calls the server's tools and reads the +top hits; see [the plugin](docs/install.md#claude-code-plugin). + +Codex gets the same as a Codex plugin, `zerograph install-agent --codex`: the MCP server, the +session hooks, and the commands as skills (`$zerograph:ask`, `$zerograph:impact`, …); see +[the Codex plugin](docs/install.md#codex-plugin). ## In action diff --git a/agent/claude-code/agents/repo-scout.md b/agent/claude-code/agents/repo-scout.md new file mode 100644 index 00000000..53661ab9 --- /dev/null +++ b/agent/claude-code/agents/repo-scout.md @@ -0,0 +1,27 @@ +--- +name: repo-scout +description: Answers "where does X live", "what calls Y", "what would this change break" from the zerograph index. Read-only, no file reading unless the graph named the path first. Use it instead of spending an expensive model's turns on orientation. +tools: Bash, Read, Grep, Glob +model: haiku +--- + +You answer questions about where things are in this repository, using the zerograph index. You are +cheap on purpose: the model that spawned you is expensive and is waiting. + +**The loop, in order:** + +1. `zerograph ask ` — or `zerograph ask ` when you were given one. Read the + `path:line` it prints. +2. `zerograph impact --depth 1` when the question is who calls something. +3. `zerograph changes --base main` when the question is about the working diff. +4. `rg` only for a literal the graph does not index: an env var name, a string in a test, a flag. +5. `Read` only a `path:line` one of the above printed. Never open a directory to orient yourself — + that is the work the index already did. + +**Answer with the paths and one line each.** No summaries of what the code does unless you were +asked for one, no speculation about files you did not open, and no plan. If the graph found +nothing, say so and name the two phrasings you tried: a miss is information, and inventing a +plausible path is not. + +If the repository has no index, say `zerograph build` once and stop; do not grep your way to an +answer that the caller could have grepped for themselves. diff --git a/agent/claude-code/hooks/hooks.json b/agent/claude-code/hooks/hooks.json index 5fcba7ce..d2d8db53 100644 --- a/agent/claude-code/hooks/hooks.json +++ b/agent/claude-code/hooks/hooks.json @@ -1 +1,9 @@ -{ "modules": ["./register.ts"] } +{ + "modules": ["./register.ts"], + "hooks": { + "SessionStart": [{ "matcher": "startup|resume|clear|compact", "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs"], "timeout": 10 }] }], + "SubagentStart": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs"], "timeout": 5 }] }], + "PreToolUse": [{ "matcher": "Bash|Grep|Glob|Agent", "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs"], "timeout": 10 }] }], + "PostToolUse": [{ "matcher": "Edit|Write|MultiEdit", "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs"], "timeout": 10 }] }] + } +} diff --git a/agent/hook.mjs b/agent/claude-code/scripts/hook.mjs similarity index 76% rename from agent/hook.mjs rename to agent/claude-code/scripts/hook.mjs index 098dac7e..6c37a918 100644 --- a/agent/hook.mjs +++ b/agent/claude-code/scripts/hook.mjs @@ -17,13 +17,15 @@ * ZEROGRAPH_HOOK_DEBUG. Every call it waits on is --no-dense, and every one but `changes` is --stale; * what it starts and leaves running — a `serve`, an `update --detach` — is neither. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, lstatSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, lstatSync } from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; -import { join, dirname, relative, delimiter, isAbsolute } from 'node:path'; +import { join, dirname, relative, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { homedir, tmpdir } from 'node:os'; +import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; +import { binaryOf, checkoutOf as repositoryOf, childEnv as cutEnv, commandOf, pathKey } from './launcher.mjs'; + const MAX_INJECTIONS = 12; // 12 × ~107 tokens: a third of the CLAUDE.md sections this replaces const SEEDS = 3; const LINE_CUT = 160; @@ -40,22 +42,10 @@ const HOOK_BUDGET = 2; const SERVE_IDLE = 1800; const SERVE_IDLE_MODEL = 300; // measured on the 33.5k-row copy: 0.08 s resident, 0.77 s after a drop -/** - * How this repository runs the binary, in every hint the hook prints. `install-agent --command` - * rewrites this one line with the command JSON-encoded, so nothing in it reaches the script as code. - * A workspace that installs zerograph as a dependency has no bare `zerograph` on PATH, and a hint - * naming a program the shell cannot find is one the agent runs, fails, and pays for twice. - */ -const COMMAND = "zerograph"; +const COMMAND = 'zerograph'; -const HERE = dirname(fileURLToPath(import.meta.url)); - -/** - * The rule handed to every subagent. Read from `rule.txt` beside this file when the installer put - * one there, and otherwise the copy below — the two are compared by a test, so a hook installed - * without its text still says the same thing. - */ -const RULE_FALLBACK = `This repository has a zerograph index. Ask it before grepping for a concept: \`${COMMAND} ask \` +/** The rule handed to every subagent. */ +const RULE = `This repository has a zerograph index. Ask it before grepping for a concept: \`${COMMAND} ask \` answers by meaning, \`${COMMAND} impact \` names who calls a symbol, \`${COMMAND} changes\` maps your diff onto the callers it reaches. Read only the \`path:line\` those answers print, and cite one for every claim; when nothing printed holds it, say so. @@ -73,8 +63,7 @@ const BUILD_NOTICE = 'afterwards; until then this notice is all it can say.'; function rule() { - const beside = join(HERE, 'rule.txt'); - try { return existsSync(beside) ? readFileSync(beside, 'utf8') : RULE_FALLBACK; } catch { return RULE_FALLBACK; } + return RULE; } function env(suffix) { @@ -85,106 +74,43 @@ function debug(...args) { if (env('HOOK_DEBUG')) console.error('[zerograph-hook]', ...args); } -/** - * The `zerograph` the machine's own PATH resolves, as an absolute path, or null. A relative entry - * (`.`, `node_modules/.bin`) resolves against the repository the hook runs in, so it is the - * repository's file and not the machine's. Resolved here rather than by the spawn: `execvp` walks - * those relative entries too, and Windows looks in the working directory before PATH at all. An - * absolute entry inside the checkout, the kind an `.envrc` adds, is the checkout's file as well — - * the whole checkout, not only the package whose store the hook found. - * - * On Windows every `.exe` on PATH outranks every `.cmd`: npm's global shim is a `.cmd`, and the - * shell a batch file needs is the path `spawnable` describes, with its costs. - */ -function onPath(root, platform = process.platform) { - const win = platform === 'win32'; - const dirs = machineDirs(root); - for (const n of win ? ['zerograph.exe', 'zerograph.cmd'] : ['zerograph']) { - for (const d of dirs) { - const p = join(d, n); - try { const s = statSync(p); if (s.isFile() && (win || (s.mode & 0o111))) return p; } catch {} - } - } - return null; -} - -/** - * PATH's absolute entries outside the checkout `root` is in: the machine's, not the repository's. - * Compared as spelled and with every link followed, so neither a symlinked alias of the checkout - * on PATH nor a session opened through one lets the checkout's file through. - */ -function machineDirs(root) { - const found = root && (checkoutRoot(root) ?? root); - // A `~/.zerograph` or a dotfiles `~/.git` makes the home directory the checkout, and cutting it - // would take nvm, cargo and `~/.local/bin` with it, where zerograph and `node` are installed. - const home = homedir(); - const checkout = found && dirname(found) !== found && !(home && (inside(found, home) || inside(real(found), real(home)))) ? found : null; - const resolved = checkout && real(checkout); - return (process.env[pathKey()] || '').split(delimiter) - .filter((d) => d && isAbsolute(d) && !inside(checkout, d) && !inside(resolved, real(d))); -} - -/** The PATH variable's name: any case on Windows, where the environment ignores it, else `PATH`. */ -function pathKey() { - return (process.platform === 'win32' && Object.keys(process.env).find((k) => k.toUpperCase() === 'PATH')) || 'PATH'; -} +const WIN = process.platform === 'win32'; -/** `p` with every link followed, or as given when it does not resolve. */ -function real(p) { - try { return realpathSync.native(p); } catch { return p; } +/** The checkout `root` is in, or `root` outside git: what a PATH entry must stay out of. */ +function checkoutOf(root) { + return root && repositoryOf(root); } /** - * The environment the binary starts in, with PATH cut to the machine's entries. npm's launcher - * finds `node` through PATH — `#!/usr/bin/env node`, or the `.cmd` shim's bare `node` — so an - * entry an `.envrc` put inside the checkout would start the checkout's `node` one step on. + * The `zerograph` the machine's own PATH resolves, as an absolute path, or null; never one in the + * checkout, which the hook runs in every repository a session opens. On Windows every `.exe` + * outranks every `.cmd`. */ -function childEnv(root, extra = {}) { - return { ...process.env, [pathKey()]: machineDirs(root).join(delimiter), ...extra }; +function onPath(root, platform = process.platform) { + return binaryOf('', { path: process.env[pathKey(process.env, platform === 'win32')], checkout: checkoutOf(root), win: platform === 'win32' }).bin ?? null; } /** - * `ZEROGRAPH_BIN`, then PATH, and nothing else; null when neither names one, and then nothing is - * spawned. The checkout is never asked: the hook runs machine-wide in every repository a session - * opens, and one that commits a `zerograph.toml` and an executable `node_modules/.bin/zerograph` - * would have that file started the moment a session began. A project-local install names its - * binary through `ZEROGRAPH_BIN`, by an absolute path. + * `ZEROGRAPH_BIN`, else the plugin's `binary` option, else PATH; null when none names one, and + * then nothing is spawned. The rules are the MCP server's, from `launcher.mjs`: a relative path + * names the checkout's file and is refused. */ function binary(root) { - const named = env('BIN'); - // A relative name resolves against whatever checkout the session opened, which makes it that - // checkout's file. - if (named && !isAbsolute(named)) { debug('ZEROGRAPH_BIN is not an absolute path; ignored'); return null; } - return named || onPath(root); + const named = env('BIN') || process.env.CLAUDE_PLUGIN_OPTION_BINARY || ''; + const found = binaryOf(named, { path: process.env[pathKey(process.env, WIN)], checkout: checkoutOf(root), win: WIN }); + if (found.error) debug(found.error); + return found.bin ?? null; } -/** Whether `dir` is `root` or below it; on Windows `relative` already ignores case. */ -function inside(root, dir) { - if (!root) return false; - const rel = relative(root, dir); - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +function childEnv(root, extra = {}) { + return cutEnv(process.env, checkoutOf(root), WIN, extra); } -/** - * How to hand one command to `spawn`. A `.cmd` or `.bat` is read by the command interpreter rather - * than by `CreateProcess`, and Node has refused to spawn one without a shell since CVE-2024-27980. - * The shell joins the file and its arguments into one command line without quoting them, so they - * are quoted here — and nothing that reaches this can close a quote: a Windows path may not contain - * one, and the words come from `queryWords`, which keeps letters, digits, `_` and `-` and nothing - * else. Everything that is not a batch file is spawned as it always was. - * - * One thing the quoting does not buy. `cmd` expands `%NAME%` inside quotes and the command line has - * no escape for it — only a batch file's own `%%` — so a repository under a directory that pairs two - * `%` around an environment variable's name is asked about by the expanded path. npm's global - * install is a `.cmd`, so this is the path a Windows machine without a `zerograph.exe` on PATH - * takes; `onPath` prefers any `.exe` to keep it the exception. - */ -function spawnable(bin, args) { - // Windows and a batch file, both: the extension alone would open the shell on a POSIX machine - // whose `ZEROGRAPH_BIN` happens to end in `.cmd`, where a repository path may contain a quote - // and the sentence above stops being true. - if (process.platform !== 'win32' || !/\.(cmd|bat)$/i.test(bin)) return { file: bin, args, opts: {} }; - return { file: `"${bin}"`, args: args.map((a) => `"${a}"`), opts: { shell: true } }; +/** `bin args` as an argv `spawn` takes without a shell, or null when `cmd` would read an argument as code. */ +function command(bin, args) { + const c = commandOf(bin, args, process.env.ComSpec); + if (c.error) debug(c.error); + return c.argv ? c : null; } /** @@ -200,13 +126,13 @@ function readerBudget(root) { return String(smallest); } -// Runs a command through the shell and, once `ms` pass, hands the shell's pid to `kill`. Run in a -// Node of its own because `spawnSync`'s timeout kills only the shell: the `zerograph` a batch file +// Starts a command and, once `ms` pass, hands its pid to `kill`. Run in a Node of its own because +// `spawnSync`'s timeout kills only the process it started: the `zerograph` a batch file's `cmd` // started would outlive the five seconds this hook waits, and go on holding the store. const BOUNDED = ` const { spawn, spawnSync } = require('node:child_process'); const [file, args, ms, kill] = JSON.parse(process.argv[1]); -const child = spawn(file, args, { shell: true, stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true }); +const child = spawn(file, args, { stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true }); const timer = setTimeout(() => { spawnSync(kill[0], [...kill.slice(1), String(child.pid)], { stdio: 'ignore', windowsHide: true }); process.exit(124); @@ -215,7 +141,7 @@ child.on('error', () => process.exit(127)); child.on('exit', (code) => { clearTimeout(timer); process.exit(code ?? 1); }); `; -/** `spawnSync` of a shell command whose whole process tree `kill` ends after `ms`. */ +/** `spawnSync` of a command whose whole process tree `kill` ends after `ms`. */ function bounded(file, args, options, kill, ms = TIMEOUT_MS) { // The outer timeout is only a backstop for the helper itself. return spawnSync(process.execPath, ['-e', BOUNDED, JSON.stringify([file, args, ms, kill])], { ...options, timeout: ms + 2000 }); @@ -228,8 +154,9 @@ function treeKill() { function run(root, args) { const bin = binary(root); - if (!bin) return ''; - const { file, args: argv, opts } = spawnable(bin, ['--repo', root, '--no-dense', ...args]); + const c = bin && command(bin, ['--repo', root, '--no-dense', ...args]); + if (!c) return ''; + const [file, ...argv] = c.argv; const options = { // Never the checkout: npm's Windows `.cmd` shim runs a bare `node` through `cmd`, which looks // in the working directory first. The binary is told its repository by `--repo`. @@ -237,7 +164,7 @@ function run(root, args) { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: childEnv(root, { ZEROGRAPH_READER_BUDGET: readerBudget(root) }), }; - const r = opts.shell ? bounded(file, argv, options, treeKill()) : spawnSync(file, argv, { ...options, timeout: TIMEOUT_MS }); + const r = c.batch ? bounded(file, argv, options, treeKill()) : spawnSync(file, argv, { ...options, timeout: TIMEOUT_MS }); if (r.status !== 0) debug('exit', r.status, args.join(' '), (r.stderr || '').slice(0, 200)); // Identifiers and headings in the answer are the repository's text, and it is about to enter an // agent's context and possibly a terminal log; control characters other than tab and newline carry nothing. @@ -273,22 +200,19 @@ function checkoutRoot(start) { } /** - * Whether a checkout asked for an index: it carries the hook `install-agent` writes, or a - * `zerograph.toml`. Codex runs one copy of this hook in every repository a session opens, and a - * build started in each of them would be the cost a git hook used to pay on every worktree add. + * Whether a checkout asked for an index: it carries a `zerograph.toml`, its main worktree has a + * built index, or either holds the hook an older `install-agent` wrote. The plugin runs this hook + * in every repository a session opens, and a build started in each of them would be the cost a git + * hook used to pay on every worktree add. */ function optedIn(checkout) { - const asked = (dir) => ['.claude/hooks/zerograph-hook.mjs', '.codex/hooks/zerograph-hook.mjs', 'zerograph.toml'] + const asked = (dir) => ['zerograph.toml', '.claude/hooks/zerograph-hook.mjs', '.codex/hooks/zerograph-hook.mjs'] .some((p) => existsSync(join(dir, ...p.split('/')))); const main = mainCheckout(checkout); - return asked(checkout) || (main !== null && asked(main)); + return asked(checkout) || (main !== null && (asked(main) || built(main))); } -/** - * The main worktree of a linked one, read off its `.git` file and the `commondir` beside it, or - * null. `install-agent --codex` puts the Codex hook there, so a linked worktree whose own tree does - * not carry it has still asked for an index. - */ +/** The main worktree of a linked one, read off its `.git` file and the `commondir` beside it, or null. */ function mainCheckout(checkout) { try { const gitdir = readFileSync(join(checkout, '.git'), 'utf8').match(/^gitdir:\s*(.+?)\s*$/m)?.[1]; @@ -490,12 +414,29 @@ function reminder(payload, sessionKey) { : null; } +/** + * The files an edit wrote. Claude Code names one in `file_path`; Codex's `apply_patch` carries a + * patch in `command`, where every file it adds, updates or moves to has its own header line, relative + * to the project. + */ +function editedFiles(input, root) { + if (input.file_path) return [String(input.file_path)]; + const patch = typeof input.command === 'string' ? input.command : ''; + const files = [...patch.matchAll(/^\*\*\* (?:Add File|Update File|Move to): (.+?)\s*$/gm)].map((m) => m[1]); + return [...new Set(files)].map((f) => (isAbsolute(f) ? f : join(root, f))); +} + function riskLine(payload, root, sessionKey) { - const file = String((payload.tool_input || {}).file_path || ''); - if (!/\.tsx?$/.test(file)) return null; + const files = editedFiles(payload.tool_input || {}, root).filter((f) => /\.tsx?$/.test(f)); + if (!files.length) return null; let r; try { r = JSON.parse(run(root, ['changes', '--depth', '1', '--json'])); } catch { return null; } if (!r || !['MEDIUM', 'HIGH', 'CRITICAL'].includes(r.risk)) return null; + const lines = files.map((file) => fileRisk(r, file, root, sessionKey)).filter(Boolean); + return lines.length ? lines.join('\n') : null; +} + +function fileRisk(r, file, root, sessionKey) { // `changes` covers the whole working diff: keep only what the edited file contributes, or an edit to // a leaf file is told about the callers of symbols some other file in the diff touched. const rel = relative(root, file).split('\\').join('/'); @@ -545,8 +486,10 @@ function detached(root, args) { const bin = binary(root); if (!bin) return false; try { - const { file, args: argv, opts } = spawnable(bin, args); - const child = spawn(file, argv, { cwd: dirname(bin), detached: true, stdio: 'ignore', windowsHide: true, env: childEnv(root), ...opts }); + const c = command(bin, args); + if (!c) return false; + const [file, ...argv] = c.argv; + const child = spawn(file, argv, { cwd: dirname(bin), detached: true, stdio: 'ignore', windowsHide: true, env: childEnv(root) }); // A spawn that fails reports it as an event, not as a throw, and an unheard `error` event ends // this process — which would turn "no binary on PATH" into a hook that dies on stderr at every // session start. The listener is what keeps the contract: exit 0, and say nothing. @@ -593,9 +536,11 @@ const handlers = { } if (!built(root)) { if (!isWatchedCall(p.tool_name, p.tool_input) || !once(key, 'build', 'once')) return null; - // SessionStart already started the build there; a `build` on top of it would embed twice. + // A machine-wide plugin says nothing in a repository that never asked for an index. const checkout = p.cwd && checkoutRoot(p.cwd); - return checkout && optedIn(checkout) && binaryFound(checkout) ? BUILDING_NOTICE : BUILD_NOTICE; + if (!checkout || !optedIn(checkout)) return null; + // SessionStart already started the build there; a `build` on top of it would embed twice. + return binaryFound(checkout) ? BUILDING_NOTICE : BUILD_NOTICE; } return intercept(p, root, key) ?? reminder(p, key); }, @@ -640,4 +585,6 @@ if (invokedDirectly()) { main(); } -export { rule, RULE_FALLBACK, COMMAND, binary, onPath, bounded, intercept, riskLine, handlers, MAX_INJECTIONS }; +// `main` for Codex on Windows, whose hook command imports this file rather than naming its path in +// a shell variable. +export { main, rule, RULE, COMMAND, binary, onPath, bounded, intercept, riskLine, handlers, MAX_INJECTIONS }; diff --git a/agent/hook.test.mjs b/agent/claude-code/scripts/hook.test.mjs similarity index 91% rename from agent/hook.test.mjs rename to agent/claude-code/scripts/hook.test.mjs index 7e0dbf5e..afa079a3 100644 --- a/agent/hook.test.mjs +++ b/agent/claude-code/scripts/hook.test.mjs @@ -1,6 +1,6 @@ // The hook, exercised the way a harness runs it: a payload on stdin, a fake `zerograph` the hook // resolves the way it resolves the real one, and a temporary root that has or lacks a store. -// Run: `node --test agent/hook.test.mjs`, on every platform the hook is installed on. +// Run: `node --test agent/claude-code/scripts/hook.test.mjs`, on every platform the hook is installed on. import { test } from 'node:test'; import assert from 'node:assert'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, copyFileSync, symlinkSync, statSync, realpathSync } from 'node:fs'; @@ -9,7 +9,7 @@ import { join, dirname, delimiter } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; -import { queryWords, searchPattern, binary, onPath, bounded, rule, RULE_FALLBACK, COMMAND } from './hook.mjs'; +import { queryWords, searchPattern, binary, onPath, bounded, rule, RULE } from './hook.mjs'; const HOOK = join(dirname(fileURLToPath(import.meta.url)), 'hook.mjs'); @@ -92,22 +92,6 @@ async function waitFor(w, re, ms = 3000) { return null; } -/** - * The hook as `install-agent --command` writes it, alone in a directory with no `rule.txt`: its one - * declaration replaced by the command JSON-encoded, which is what `src/install_agent.rs` does. A - * function replacement, because a replacement string would read a `$'` in the command as a pattern. - */ -function installed(w, command) { - const declaration = 'const COMMAND = "zerograph";'; - const source = readFileSync(HOOK, 'utf8'); - assert.equal(source.split(declaration).length, 2, 'the declaration the installer replaces is in the hook once'); - const dir = join(w.root, 'installed'); - mkdirSync(dir, { recursive: true }); - const file = join(dir, 'zerograph-hook.mjs'); - writeFileSync(file, source.replace(declaration, () => `const COMMAND = ${JSON.stringify(command)};`)); - return file; -} - /** * What `changes --depth 1 --json` prints for a diff: each part touches `symbol` in `file` and has * `callers` direct callers spread over `files` files. @@ -302,13 +286,6 @@ test('the hook keeps its session state in a directory private to the user', { sk assert.ok(!existsSync(join(w.root, 'tmp', 'zerograph-hook')), 'the predictable shared name is gone'); }); -test('the rule the hook hands a subagent is the file the installer ships', () => { - const shipped = readFileSync(join(dirname(HOOK), 'rule.txt'), 'utf8'); - assert.equal(rule(), shipped); - assert.equal(RULE_FALLBACK.trim(), shipped.split('{{command}}').join(COMMAND).trim(), - 'the built-in copy and the file cannot drift'); -}); - test('SessionStart prints the brief, and nothing where there is no store', () => { const w = world({ answer: 'zerograph: 3076 doc nodes, 5240 code nodes\nmodel=onnx-community/embeddinggemma-300m-ONNX' }); const c = context(fire(w, { hook_event_name: 'SessionStart', source: 'startup' })); @@ -404,7 +381,7 @@ test('on Windows a zerograph.exe anywhere on PATH wins over an earlier npm .cmd writeFileSync(join(npm, 'zerograph.cmd'), ''); writeFileSync(join(cargo, 'zerograph.exe'), ''); const path = process.env.PATH; - process.env.PATH = [npm, cargo].join(delimiter); + process.env.PATH = [npm, cargo].join(';'); try { assert.equal(onPath(null, 'win32'), join(cargo, 'zerograph.exe')); } finally { @@ -428,6 +405,7 @@ test('a hook run through a symlinked path still answers', () => { const real = join(w.root, 'real'); mkdirSync(real); copyFileSync(HOOK, join(real, 'hook.mjs')); + copyFileSync(join(dirname(HOOK), 'launcher.mjs'), join(real, 'launcher.mjs')); const link = join(w.root, 'link'); // A junction on Windows, which needs no symlink privilege; the type is ignored everywhere else. symlinkSync(real, link, 'junction'); @@ -492,6 +470,27 @@ test('a linked worktree whose main worktree holds the hook asked for an index to assert.equal(await waitFor(w, /update --detach/), `--repo ${wt} update --detach`); }); +test('a linked worktree of a main worktree with a built index asked for one too', async () => { + const w = world({ store: false }); + const main = join(w.root, 'main'); + mkdirSync(join(main, '.git', 'worktrees', 'wt'), { recursive: true }); + writeFileSync(join(main, '.git', 'worktrees', 'wt', 'commondir'), '../..\n'); + mkdirSync(join(main, '.zerograph'), { recursive: true }); + writeFileSync(join(main, '.zerograph', 'manifest.json'), '{}'); + const wt = join(w.root, 'wt'); + mkdirSync(wt); + writeFileSync(join(wt, '.git'), `gitdir: ${join(main, '.git', 'worktrees', 'wt')}\n`); + assert.match(context(fire(w, { hook_event_name: 'SessionStart', source: 'startup', cwd: wt })), /being built/); + assert.equal(await waitFor(w, /update --detach/), `--repo ${wt} update --detach`); +}); + +test('the plugin\'s binary option names the binary when ZEROGRAPH_BIN does not', { skip: WIN }, () => { + const w = world({ answer: 'zerograph: 3 doc nodes' }); + const c = context(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, + { PATH: join(w.root, 'nothing-here'), ZEROGRAPH_BIN: '', CLAUDE_PLUGIN_OPTION_BINARY: w.launcher, ZEROGRAPH_HOOK_SERVE: '0' })); + assert.equal(c, 'zerograph: 3 doc nodes'); +}); + test('an opted-in checkout with no binary to start claims no build', { skip: WIN }, async () => { const w = world({ store: false }); mkdirSync(join(w.root, '.git')); @@ -539,7 +538,8 @@ test('every call the hook waits on refreshes in process for less than its timeou test('SubagentStart carries the rule', () => { const w = world(); const c = context(fire(w, { hook_event_name: 'SubagentStart', agent_id: 'a1' })); - assert.equal(c.trim(), readFileSync(join(dirname(HOOK), 'rule.txt'), 'utf8').trim()); + assert.equal(c, RULE); + assert.match(c, /`zerograph ask `/); assert.deepEqual(argv(w), [], 'the rule costs no call'); }); @@ -611,6 +611,23 @@ test('an edit to a hub injects one risk line, once, and only above LOW', () => { assert.deepEqual(argv(doc), [], 'a document edit does not even ask'); }); +test('a Codex apply_patch is scored on every file its patch writes', () => { + const w = world({ answer: diff([ + { file: 'src/db.ts', symbol: 'DatabaseService.withTenant', callers: 18 }, + { file: 'src/slots.ts', symbol: 'freeSlots', callers: 6, files: 2 }, + ], 'HIGH') }); + const patch = ['*** Begin Patch', '*** Update File: src/db.ts', '@@', '-a', '+b', '*** Add File: notes.md', '+x', + '*** Update File: src/old.ts', '*** Move to: src/slots.ts', '@@', '-c', '+d', '*** End Patch'].join('\n'); + const c = context(fire(w, { hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: patch } })); + assert.match(c, /risk HIGH — 18 direct callers of DatabaseService\.withTenant/); + assert.match(c, /risk MEDIUM — 6 direct callers of freeSlots in 2 files/, 'a moved file is scored at its new path'); + + const doc = world({ answer: diff([{ file: 'src/db.ts', symbol: 'x', callers: 18 }], 'HIGH') }); + const md = { hook_event_name: 'PostToolUse', tool_name: 'apply_patch', tool_input: { command: '*** Begin Patch\n*** Add File: x.md\n+y\n*** End Patch' } }; + assert.equal(fire(doc, md), null); + assert.deepEqual(argv(doc), [], 'a patch that writes no TypeScript does not even ask'); +}); + test('an edit is scored on its own file\'s callers, not on the rest of the diff', () => { const w = world({ answer: diff([ { file: 'src/db.ts', symbol: 'DatabaseService.withTenant', callers: 40 }, @@ -654,46 +671,24 @@ test('the Agent prompt is extended only when the fallback is asked for', () => { assert.equal(out.hookSpecificOutput.permissionDecision, undefined, 'no decision: the call proceeds as it would have'); }); -test('a repository with no store gets one build notice and then silence', () => { +test('a repository that asked for an index and cannot build one gets one build notice and then silence', () => { const w = world({ store: false }); + mkdirSync(join(w.root, '.git')); + writeFileSync(join(w.root, 'zerograph.toml'), ''); const payload = { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'rg "cancellation policy"' } }; - const notice = context(fire(w, payload)); + const nothing = { PATH: join(w.root, 'nothing-here'), ZEROGRAPH_BIN: '' }; + const notice = context(fire(w, payload, nothing)); assert.match(notice, /zerograph build/); assert.match(notice, /already embedded.*vector cache/, 'a large repository is not told it costs a whole embed'); assert.doesNotMatch(notice, /couple of minutes/, 'a large repository takes far longer than that'); - assert.equal(fire(w, { ...payload, tool_input: { command: 'rg "another question here"' } }), null, 'once a session'); + assert.equal(fire(w, { ...payload, tool_input: { command: 'rg "another question here"' } }, nothing), null, 'once a session'); }); -test('every hint names the command install-agent was given, and no command can break the script', () => { - const env = { ZEROGRAPH_HOOK_SERVE: '0' }; - const command = 'pnpm exec zerograph'; - const search = { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'rg "cancellation policy"' } }; - - const bare = world({ store: false }); - assert.match(context(fire(bare, search, env, installed(bare, command))), /`pnpm exec zerograph build`/); - - const w = world({ answer: 'FR-PAY-22 docs/a.md:1 отмена' }); - const hook = installed(w, command); - const given = context(fire(w, { hook_event_name: 'SubagentStart', agent_id: 'a1' }, env, hook)); - assert.match(given, /`pnpm exec zerograph ask `/, 'no rule.txt beside it: the built-in copy answers'); - assert.ok(!given.includes('`zerograph '), given); - const injected = context(fire(w, search, env, hook)); - assert.match(injected, /^pnpm exec zerograph ask cancellation policy →/, 'the header is a command an agent re-runs'); - assert.match(injected, /`pnpm exec zerograph ask` for more\)$/); - let reminder = null; - for (let i = 0; i < 40; i += 1) { - const payload = { hook_event_name: 'PreToolUse', agent_id: 'r', tool_name: 'Bash', tool_input: { command: `rg "thing${i}"` } }; - reminder = context(fire(w, payload, { ...env, ZEROGRAPH_HOOK_INTERCEPT: '0' }, hook)) ?? reminder; - } - assert.match(reminder, /`pnpm exec zerograph ask `/); - - const hub = world({ answer: diff([{ file: 'src/db.ts', symbol: 'withTenant', callers: 18 }], 'HIGH') }); - assert.match(context(fire(hub, edit(hub, 'src', 'db.ts'), env, installed(hub, command))), /`pnpm exec zerograph changes --depth 1`/); - - const hostile = 'pnpm exec "re\'po\\graph" `x` ${y} $\''; - const odd = world(); - const said = context(fire(odd, { hook_event_name: 'SubagentStart', agent_id: 'a1' }, env, installed(odd, hostile))); - assert.ok(said.includes(`\`${hostile} ask \``), `a JSON string is a JavaScript string literal: ${said}`); +test('a repository that never asked for an index hears nothing from a search', () => { + const w = world({ store: false }); + mkdirSync(join(w.root, '.git')); + const payload = { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'rg "cancellation policy"' } }; + assert.equal(fire(w, payload), null); }); test('malformed input is silence with exit zero', () => { diff --git a/agent/claude-code/scripts/launcher.mjs b/agent/claude-code/scripts/launcher.mjs new file mode 100644 index 00000000..5b577db2 --- /dev/null +++ b/agent/claude-code/scripts/launcher.mjs @@ -0,0 +1,151 @@ +// How the plugin finds and starts `zerograph`, shared by its MCP server and its session hook so the +// two cannot disagree about which binary is safe to run. Pure functions over what the caller passes +// in (PATH, the checkout, the platform), so the tests can play Windows on any machine. +// +// `agent/codex/scripts/launcher.mjs` is a copy of this file — a plugin is installed as one +// directory and cannot import from a sibling — and a test fails when the two differ. +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const UNSET = '${user_config.binary}'; + +/** Whether `path` is absolute in either platform's spelling. */ +export const isAbsolute = path => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\'); + +/** Whether `dir` is `root` or below it; Windows paths name one file in any case. */ +export function inside(root, dir, win) { + if (!root) return false; + const norm = p => { + const slashed = p.replace(/\\/g, '/').replace(/\/+$/, ''); + return win ? slashed.toLowerCase() : slashed; + }; + const r = norm(root); + const d = norm(dir); + return d === r || d.startsWith(`${r}/`); +} + +/** `p` with every link followed, or as given when it does not resolve. */ +export function real(p) { + try { return realpathSync.native(p); } catch { return p; } +} + +/** + * `p` with the links in its existing part followed: an entry that does not exist yet still names + * the checkout when it is spelled through a link to it, such as macOS's `/var` for `/private/var`. + */ +function realAbove(p) { + const parent = dirname(p); + if (existsSync(p) || parent === p) return real(p); + return join(realAbove(parent), p.slice(parent.length).replace(/^[\\/]+/, '')); +} + +/** + * The nearest directory at or above `start` holding `name`, or null. Walked from the real path, as + * the CLI canonicalizes its working directory, so a session opened through a link finds the + * checkout the CLI would. + */ +export function nearest(start, name) { + for (let d = real(start); ; d = dirname(d)) { + if (existsSync(join(d, name))) return d; + if (dirname(d) === d) return null; + } +} + +/** + * The repository around `root`, found the way the CLI finds it: the checkout (a session opened in + * a package of a monorepo still has the whole checkout around it), else the nearest directory + * above holding a `.zerograph/`, else `root` itself. It is what zerograph indexes and what a PATH + * entry must stay out of. + */ +export function checkoutOf(root) { + return nearest(root, '.git') ?? nearest(root, '.zerograph') ?? root; +} + +/** The PATH variable's name: any case on Windows, where the environment ignores it, else `PATH`. */ +export function pathKey(env, win) { + return (win && Object.keys(env).find(k => k.toUpperCase() === 'PATH')) || 'PATH'; +} + +/** + * PATH's absolute entries outside `checkout`, compared as spelled and with every link followed, so + * neither a symlinked alias of the checkout on PATH nor a session opened through one lets the + * checkout's file through. A relative entry (`.`, `node_modules/.bin`) resolves against the + * repository the caller runs in, so it is the repository's and not the machine's. + */ +export function machineDirs(path, checkout, win, home = homedir()) { + const cut = guarded(checkout, win, home); + const resolved = cut && real(checkout); + return (path ?? '').split(win ? ';' : ':').filter(entry => { + // Windows reads a quoted entry as the directory inside the quotes. + const dir = win ? entry.replace(/^"(.*)"$/, '$1') : entry; + return dir && isAbsolute(dir) && !(cut && (inside(checkout, dir, win) || inside(resolved, realAbove(dir), win))); + }); +} + +/** + * Whether PATH entries inside `checkout` are cut. Not when it is a filesystem root or holds the home + * directory: a `~/.zerograph` or a dotfiles `~/.git` makes the home directory the checkout, and the + * cut would then take nvm, cargo and `~/.local/bin` with it, the very places zerograph and `node` + * are installed. + */ +function guarded(checkout, win, home) { + if (!checkout || dirname(checkout) === checkout) return false; + return !(home && (inside(checkout, home, win) || inside(real(checkout), real(home), win))); +} + +/** + * The configured binary when it is an absolute path, else PATH's by its absolute path, and never + * one under the checkout: a cloned repository that ships `node_modules/.bin/zerograph` would + * otherwise have it run the moment a session opened in it. A relative path is refused rather than + * read against a working directory nobody chose; a bare name means the PATH lookup. + */ +export function binaryOf(configured, { path, checkout, win, home }) { + const option = configured === UNSET ? '' : (configured ?? '').trim(); + if (option && /[\\/]/.test(option)) { + return isAbsolute(option) ? { bin: option } : { error: `the configured binary (${option}) is not an absolute path; name an absolute one in the plugin's binary option or ZEROGRAPH_BIN, or clear it to use PATH` }; + } + const dirs = machineDirs(path, checkout, win, home); + // Every `.exe` before any `.cmd`: npm's global shim is a `.cmd`, which only `cmd` can start. + const names = win ? ['zerograph.exe', 'zerograph.cmd'] : ['zerograph']; + for (const name of names) { + for (const dir of dirs) { + const file = join(dir, name); + const stat = statSync(file, { throwIfNoEntry: false }); + if (stat?.isFile() && (win || (stat.mode & 0o111))) return { bin: file }; + } + } + return { error: 'no zerograph on PATH outside this checkout; install it (npm i -g zerograph) or name its absolute path in the plugin\'s binary option or ZEROGRAPH_BIN' }; +} + +// What `cmd` acts on outside quotes (`&|<>^` run or redirect, `"` toggles quoting) or expands +// even inside them (`%`, and `!` where delayed expansion is on). +const CMD_SPECIAL = /[&|<>^"%!]/; + +/** + * The argv that starts `bin` with `args`, or why it cannot be started safely. Node has refused to + * start a batch file without a shell since CVE-2024-27980, so a `.cmd` goes through `cmd` by hand; + * `call` keeps the command line from opening on a quote, which `cmd /c` would strip along with the + * last one. `cmd` parses the line Node builds, and Node quotes an argument only when it holds a + * space or a quote, so a checkout like `C:\src\a&calc` would reach it bare and its `&` start a + * second command; a quote added here is escaped as `\"`, which `cmd` reads as one that closes. An + * argument `cmd` would act on is therefore refused. + */ +export function commandOf(bin, args, comspec) { + if (!/\.(cmd|bat)$/i.test(bin)) return { argv: [bin, ...args] }; + const unsafe = [bin, ...args].find(a => CMD_SPECIAL.test(a)); + if (unsafe) { + return { error: `${unsafe} holds a character cmd would act on (& | < > ^ " % !), so the npm shim ${bin} is not started through it; name the zerograph.exe the npm package ships in the plugin's binary option or ZEROGRAPH_BIN` }; + } + return { argv: [comspec || 'C:\\Windows\\System32\\cmd.exe', '/d', '/c', 'call', bin, ...args], batch: true }; +} + +/** + * The environment the binary runs in: PATH cut to the machine's entries, as the binary was. npm's + * launcher finds `node` through PATH — `#!/usr/bin/env node`, or the `.cmd` shim's bare `node` — so + * an entry an `.envrc` put inside the checkout would start the checkout's `node` one step on. + */ +export function childEnv(env, checkout, win, extra = {}, home = undefined) { + const key = pathKey(env, win); + return { ...env, [key]: machineDirs(env[key], checkout, win, home).join(win ? ';' : ':'), ...extra }; +} diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs index 4851ffbd..398b69dc 100644 --- a/agent/claude-code/scripts/mcp.mjs +++ b/agent/claude-code/scripts/mcp.mjs @@ -1,149 +1,32 @@ #!/usr/bin/env node -// The plugin's MCP server: `zerograph --repo mcp`, with the binary found the way the -// status mod finds it. `.mcp.json` can substitute the `binary` option but cannot branch on it, so -// the choice between the option and PATH is made here. +// The plugin's MCP server: `zerograph --repo mcp`, with the binary found by +// `launcher.mjs`, as the session hook finds it. A manifest can substitute the `binary` option but +// cannot branch on it, so the choice between the option and PATH is made here. // // Usage: node mcp.mjs '' import { spawn } from 'node:child_process'; -import { existsSync, realpathSync, statSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -const UNSET = '${user_config.binary}'; +import { binaryOf, checkoutOf, childEnv, commandOf, pathKey, real } from './launcher.mjs'; -/** Whether `path` is absolute in either platform's spelling. */ -export const isAbsolute = path => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\'); +export { binaryOf, checkoutOf, childEnv }; -/** Whether `dir` is `root` or below it; Windows paths name one file in any case. */ -export function inside(root, dir, win) { - const norm = p => { - const slashed = p.replace(/\\/g, '/').replace(/\/+$/, ''); - return win ? slashed.toLowerCase() : slashed; - }; - const r = norm(root); - const d = norm(dir); - return d === r || d.startsWith(`${r}/`); -} - -/** `p` with every link followed, or as given when it does not resolve. */ -export function real(p) { - try { return realpathSync.native(p); } catch { return p; } -} - -/** - * The nearest directory at or above `start` holding `name`, or null. Walked from the real path, as - * the CLI canonicalizes its working directory, so a session opened through a link finds the - * checkout the CLI would. - */ -function nearest(start, name) { - for (let d = real(start); ; d = dirname(d)) { - if (existsSync(join(d, name))) return d; - if (dirname(d) === d) return null; - } -} - -/** - * The repository around `root`, found the way the CLI finds it: the checkout (a session opened in - * a package of a monorepo still has the whole checkout around it), else the nearest directory - * above holding a `.zerograph/`, else `root` itself. It is what zerograph indexes and what a PATH - * entry must stay out of. - */ -export function checkoutOf(root) { - return nearest(root, '.git') ?? nearest(root, '.zerograph') ?? root; -} - -/** - * PATH's absolute entries outside `checkout`, compared as spelled and with every link followed, so - * neither a symlinked alias of the checkout on PATH nor a session opened through one lets the - * checkout's file through. - */ -export function machineDirs(path, checkout, win, home = homedir()) { - const cut = guarded(checkout, win, home); - const resolved = real(checkout); - return (path ?? '').split(win ? ';' : ':').filter(entry => { - // Windows reads a quoted entry as the directory inside the quotes. - const dir = win ? entry.replace(/^"(.*)"$/, '$1') : entry; - return dir && isAbsolute(dir) && !(cut && (inside(checkout, dir, win) || inside(resolved, real(dir), win))); - }); -} - -/** - * Whether PATH entries inside `checkout` are cut. Not when it is a filesystem root or holds the home - * directory: a `~/.zerograph` or a dotfiles `~/.git` makes the home directory the checkout, and the - * cut would then take nvm, cargo and `~/.local/bin` with it, the very places zerograph and `node` - * are installed. - */ -function guarded(checkout, win, home) { - if (!checkout || dirname(checkout) === checkout) return false; - return !(home && (inside(checkout, home, win) || inside(real(checkout), real(home), win))); -} - -/** The PATH variable's name: any case on Windows, where the environment ignores it, else `PATH`. */ -export function pathKey(env, win) { - return (win && Object.keys(env).find(k => k.toUpperCase() === 'PATH')) || 'PATH'; -} - -/** - * The configured binary when it is an absolute path, else PATH's by its absolute path, and never - * one under the checkout: a cloned repository that ships `node_modules/.bin/zerograph` would - * otherwise have it run the moment a session opened in it. A relative option is refused rather - * than read against a working directory nobody chose; a bare name means the PATH lookup. - */ -export function binaryOf(configured, { path, checkout, win, home }) { - const option = configured === UNSET ? '' : configured.trim(); - if (option && /[\\/]/.test(option)) { - return isAbsolute(option) ? { bin: option } : { error: `the plugin's binary option (${option}) is not an absolute path; set one in /config, or clear it to use PATH` }; - } - const dirs = machineDirs(path, checkout, win, home); - // Every `.exe` before any `.cmd`: npm's global shim is a `.cmd`, which only `cmd` can start. - const names = win ? ['zerograph.exe', 'zerograph.cmd'] : ['zerograph']; - for (const name of names) { - for (const dir of dirs) { - const file = join(dir, name); - const stat = statSync(file, { throwIfNoEntry: false }); - if (stat?.isFile() && (win || (stat.mode & 0o111))) return { bin: file }; - } - } - return { error: 'no zerograph on PATH outside this checkout; install it (npm i -g zerograph) or name its absolute path in the plugin\'s binary option' }; -} - -// What `cmd` acts on outside quotes (`&|<>^` run or redirect, `"` toggles quoting) or expands -// even inside them (`%`, and `!` where delayed expansion is on). -const CMD_SPECIAL = /[&|<>^"%!]/; - -/** - * The argv that starts `bin`, or why it cannot be started safely. Node has refused to start a - * batch file without a shell since CVE-2024-27980, so a `.cmd` goes through `cmd` by hand; `call` - * keeps the command line from opening on a quote, which `cmd /c` would strip along with the last - * one. `cmd` parses the line Node builds, and Node quotes an argument only when it holds a space - * or a quote, so a checkout like `C:\src\a&calc` would reach it bare and its `&` start a second - * command; a quote added here is escaped as `\"`, which `cmd` reads as one that closes. A path - * `cmd` would act on is therefore refused. - */ +/** The argv that starts the server for `checkout`, or why it cannot be started safely. */ export function argvOf(bin, checkout, comspec) { - const args = ['--repo', checkout, 'mcp']; - if (!/\.(cmd|bat)$/i.test(bin)) return { argv: [bin, ...args] }; - const unsafe = [bin, checkout].find(a => CMD_SPECIAL.test(a)); - if (unsafe) { - return { error: `${unsafe} holds a character cmd would act on (& | < > ^ " % !), so the npm shim ${bin} is not started through it; name the zerograph.exe the npm package ships in the plugin's binary option` }; - } - return { argv: [comspec || 'C:\\Windows\\System32\\cmd.exe', '/d', '/c', 'call', bin, ...args] }; -} - -/** The environment the server runs in: PATH cut to the machine's entries, as the binary was. */ -export function childEnv(env, checkout, win, home) { - const key = pathKey(env, win); - return { ...env, [key]: machineDirs(env[key], checkout, win, home).join(win ? ';' : ':') }; + return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); } -function main() { +export function main() { const win = process.platform === 'win32'; - // Claude Code names the project in the server's environment; the working directory it starts - // a plugin's server in is not documented. - const checkout = checkoutOf(process.env.CLAUDE_PROJECT_DIR || process.cwd()); const env = process.env; - const found = binaryOf(process.argv[2] ?? '', { path: env[pathKey(env, win)], checkout, win }); + // Claude Code names the project in the server's environment; Codex starts the server in the + // project and names nothing, and neither documents the working directory for the other. + const checkout = checkoutOf(env.CLAUDE_PROJECT_DIR || process.cwd()); + // ZEROGRAPH_BIN before the option, in the hook's order, so the two never run different binaries. + // Claude Code passes the option as the one argument, unsubstituted while it is unset, which + // `binaryOf` reads as empty; Codex has no options and passes ZEROGRAPH_BIN through. + const found = binaryOf(env.ZEROGRAPH_BIN || process.argv[2] || '', { path: env[pathKey(env, win)], checkout, win }); const launch = found.error ? found : argvOf(found.bin, checkout, env.ComSpec); if (launch.error) { // stderr is the server log the harness shows; stdout belongs to the protocol. @@ -160,6 +43,6 @@ function main() { for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig)); } -// Imported by its tests, run by `.claude-plugin/plugin.json`; a plugin cache reached through a -// link names this file by another spelling than the one Node resolved. +// Imported by its tests and by Codex's bootstrap, run by the Claude Code manifest; a plugin cache +// reached through a link names this file by another spelling than the one Node resolved. if (process.argv[1] && real(fileURLToPath(import.meta.url)) === real(process.argv[1])) main(); diff --git a/agent/claude-code/scripts/mcp.test.mjs b/agent/claude-code/scripts/mcp.test.mjs index 33516ec4..0b0db212 100644 --- a/agent/claude-code/scripts/mcp.test.mjs +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -3,13 +3,14 @@ // answers one MCP request. Run: `node --test agent/claude-code/scripts/mcp.test.mjs`. import { test } from 'node:test'; import assert from 'node:assert'; -import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, realpathSync, symlinkSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync, realpathSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; -import { binaryOf, argvOf, checkoutOf, childEnv, machineDirs, pathKey } from './mcp.mjs'; +import { binaryOf, argvOf, checkoutOf, childEnv } from './mcp.mjs'; +import { machineDirs, pathKey } from './launcher.mjs'; const LAUNCHER = join(dirname(fileURLToPath(import.meta.url)), 'mcp.mjs'); const WIN = process.platform === 'win32'; @@ -148,6 +149,20 @@ test('no binary is a line in the server log and a failed start, never a word on assert.match(r.stderr, /relative\/zerograph\) is not an absolute path/); }); +test('ZEROGRAPH_BIN outranks the option, set or left unsubstituted, as it does in the hook', { skip: WIN && 'a POSIX launcher for the fake' }, () => { + const w = world(); + const named = join(w.base, 'named'); + mkdirSync(named); + const seen = join(w.base, 'seen.txt'); + writeFileSync(join(named, 'zerograph'), `#!/bin/sh\necho named > '${seen}'\n`); + chmodSync(join(named, 'zerograph'), 0o755); + for (const option of ['${user_config.binary}', join(w.machine, 'zerograph')]) { + const r = spawnSync(process.execPath, [LAUNCHER, option], { cwd: w.base, input: '', env: { PATH: '/usr/bin:/bin', CLAUDE_PROJECT_DIR: w.checkout, ZEROGRAPH_BIN: join(named, 'zerograph') }, encoding: 'utf8' }); + assert.strictEqual(r.status, 0, r.stderr); + assert.strictEqual(readFileSync(seen, 'utf8').trim(), 'named', option); + } +}); + test('a checkout that is the home directory, or above it, cuts nothing from PATH', () => { const w = world(); const home = join(w.checkout, 'me'); @@ -156,7 +171,7 @@ test('a checkout that is the home directory, or above it, cuts nothing from PATH assert.deepStrictEqual(machineDirs(path, w.checkout, WIN, home), [nvm, w.machine], 'an ancestor of home'); assert.deepStrictEqual(machineDirs(path, home, WIN, home), [nvm, w.machine], 'home itself'); assert.deepStrictEqual(machineDirs(path, WIN ? 'C:\\' : '/', WIN, home), [nvm, w.machine], 'a filesystem root'); - assert.strictEqual(childEnv({ PATH: path }, home, WIN, home).PATH, path); + assert.strictEqual(childEnv({ PATH: path }, home, WIN, {}, home).PATH, path); assert.deepStrictEqual(machineDirs([join(w.checkout, 'bin'), w.machine].join(sep), w.checkout, WIN, w.machine), [w.machine], 'a checkout beside home is still cut'); }); @@ -165,7 +180,7 @@ test('PATH is the only PATH on POSIX, and any case of it on Windows', () => { assert.strictEqual(pathKey({ Path: 'x' }, false), 'PATH'); assert.strictEqual(pathKey({ Path: 'x' }, true), 'Path'); // POSIX spellings: the case is played on any host, and a drive letter's colon would split. - const env = childEnv({ Path: '/src/app/bin', PATH: '/src/app/bin:/usr/bin' }, '/src/app', false, '/home/me'); + const env = childEnv({ Path: '/src/app/bin', PATH: '/src/app/bin:/usr/bin' }, '/src/app', false, {}, '/home/me'); assert.strictEqual(env.PATH, '/usr/bin'); assert.strictEqual(env.Path, '/src/app/bin', 'another variable, left as it is'); }); @@ -173,7 +188,7 @@ test('PATH is the only PATH on POSIX, and any case of it on Windows', () => { test('a quoted Windows PATH entry is judged without its quotes and kept as written', () => { const quoted = '"C:\\Program Files\\nodejs"'; assert.deepStrictEqual(machineDirs([quoted, '"C:\\src\\app\\bin"'].join(';'), 'C:\\src\\app', true, 'C:\\Users\\me'), [quoted]); - assert.strictEqual(childEnv({ Path: quoted }, 'C:\\src\\app', true, 'C:\\Users\\me').Path, quoted); + assert.strictEqual(childEnv({ Path: quoted }, 'C:\\src\\app', true, {}, 'C:\\Users\\me').Path, quoted); }); test('a session opened through a link finds the checkout by its real path, as the CLI does', { skip: WIN && 'symlinks need a privilege' }, () => { diff --git a/agent/codex.md b/agent/codex.md index d461db78..89ef14fb 100644 --- a/agent/codex.md +++ b/agent/codex.md @@ -64,23 +64,54 @@ editing the hook script later invalidates the hash, which is a feature and not a **Nothing here writes that file.** It is machine-level: an entry added from inside one repository would fire in every other repository on the machine, which is not a side effect a `--repo` command -gets to have. `install-agent --codex` therefore installs the repository-scoped half — the stanza and -the skill — copies the hook script into `.codex/hooks/` so there is something to point at, and -prints the JSON to add to `~/.codex/hooks.json`, for the reader to paste once if they want it. The -path it prints is the main worktree's copy (the first entry of `git worktree list --porcelain`), -written there when the install runs in a linked worktree and the main one has none, so removing a -worktree does not leave every Codex session pointing at a missing file. +gets to have. Releases before 0.7.0 copied the hook script into `.codex/hooks/` and printed the +JSON to paste; since 0.7.0 the hooks ship in the plugin (below), and `install-agent --codex` takes +a pasted zerograph entry back out of `$CODEX_HOME/hooks.json`, since the plugin's would fire +beside it. Max, 2026-09-07: Codex gets the brief, the skill and the risk line, and **not** the Bash -interceptor — its verdict is being read on Claude Code and is not carried over blind. The printed -JSON reflects that: three events, no `PreToolUse`. +interceptor — its verdict is being read on Claude Code and is not carried over blind. The plugin's +hooks reflect that: three events, no `PreToolUse`. + +## The plugin: `agent/codex/` + +Established on 0.154.0 with a scratch `CODEX_HOME` and a local marketplace, no model call: + +- A plugin is a directory with `.codex-plugin/plugin.json`, which names `skills`, `mcpServers` and + `hooks` by relative path. A repository's `.agents/plugins/marketplace.json` lists it, and Codex + prefers that file to `.claude-plugin/marketplace.json` in the same repository. + `codex plugin marketplace add [--ref ]` and `codex plugin add zerograph@zerograph` + are both idempotent, exit 0 on a repeat, and copy the plugin to + `$CODEX_HOME/plugins/cache/zerograph/zerograph//`. +- Hook commands run through a shell with `PLUGIN_ROOT` (and `CLAUDE_PLUGIN_ROOT`) set, in the + session's project directory, with the payload's `cwd` naming it too. A `PostToolUse` matcher of + `apply_patch|Edit|Write` is honoured; `apply_patch` is Codex's edit tool. +- An MCP server's `args` are passed as written — no `${PLUGIN_ROOT}` is substituted — and its + environment is scrubbed down to Codex's defaults (`HOME`, `LANG`, `LC_ALL`, `LOGNAME`, `PATH`, + `SHELL`, `TERM`, `TMPDIR`, `USER` on macOS) and what `env_vars` names. With no `cwd` it starts in + the project. `command` takes only a bare name or a `./` path, and `cwd` only a `./` path — which + resolves to the plugin root, losing the project — so `${PLUGIN_ROOT}` reaches the server nowhere. + `.mcp.json` therefore runs `node -e` with a bootstrap that imports `scripts/mcp.mjs` from the + newest version directory under `$CODEX_HOME/plugins/cache/zerograph/zerograph/`. +- `codex plugin marketplace add` with a different `--ref` than the configured one fails ("already + added from a different source"); `marketplace remove` then `add` repins it and keeps the plugin + enabled, and `codex plugin add` replaces the old version directory with the new one. +- Hook handlers take a `commandWindows` override, used on Windows instead of `command`. +- A skill's `agents/openai.yaml` with `policy.allow_implicit_invocation: false` keeps it out of the + model's own choices, so the commands run only when named, as `$zerograph:`. + +The scripts are copies of `agent/claude-code/scripts/` — a plugin is installed as one directory and +cannot import from a sibling, and a symlink out of it would be a text file in a Windows checkout +without `core.symlinks` — and `plugin.test.mjs` fails when they differ; +`scripts/sync-plugin-scripts.sh` copies them over. ## Skills: `.codex/skills//SKILL.md` Confirmed by running it: a scratch repository carrying `.codex/skills/probe-codex/SKILL.md` and `.agents/skills/probe-agents/SKILL.md` had **both** listed under *Available skills* in `codex debug prompt-input`'s output, with the roots table naming each directory. So -`.codex/skills/zerograph/SKILL.md` is read, which is where the installer puts it; `.agents/skills` +`.codex/skills/zerograph/SKILL.md` is read, which is where releases before 0.7.0 put the skill and +where `install-agent --codex` now removes it from, the plugin's skills replacing it; `.agents/skills` is the other repository-scoped root, and is left alone because a repository that uses it has its own conventions there. diff --git a/agent/codex/.codex-plugin/plugin.json b/agent/codex/.codex-plugin/plugin.json new file mode 100644 index 00000000..25b1e9bf --- /dev/null +++ b/agent/codex/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "zerograph", + "version": "0.7.0", + "description": "zerograph in Codex: its MCP server, skills that call it (ask, impact, trace, changes, explain, status, refresh), a skill that reaches for the graph before grep, and a session hook that keeps the index fresh and hands every subagent the rule", + "author": { "name": "devmaxxx" }, + "homepage": "https://github.com/devmaxxx/zerograph", + "repository": "https://github.com/devmaxxx/zerograph", + "license": "MIT", + "keywords": ["knowledge-graph", "mcp", "code-search"], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "zerograph", + "shortDescription": "Ask the repository's zerograph index before grepping", + "category": "Coding" + } +} diff --git a/agent/codex/.mcp.json b/agent/codex/.mcp.json new file mode 100644 index 00000000..71f0d635 --- /dev/null +++ b/agent/codex/.mcp.json @@ -0,0 +1,48 @@ +{ + "mcpServers": { + "zerograph": { + "command": "node", + "env_vars": [ + "CODEX_HOME", + "ZEROGRAPH_BIN", + "ZEROGRAPH_CONFIG", + "ZEROGRAPH_CODE_GLOBS", + "ZEROGRAPH_TEXT_GLOBS", + "ZEROGRAPH_EMBED_MODEL", + "ZEROGRAPH_RERANK_MODEL", + "ZEROGRAPH_RERANKER_DIR", + "ZEROGRAPH_RESOURCES", + "ZEROGRAPH_READER_BUDGET", + "ZEROGRAPH_VECTOR_CACHE", + "ZEROGRAPH_QUESTIONS_GATE", + "ZEROGRAPH_TIMING", + "FASTEMBED_CACHE_DIR", + "HF_HOME", + "HF_ENDPOINT", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "USERPROFILE", + "LOCALAPPDATA", + "APPDATA", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "LANG", + "LC_ALL", + "LC_CTYPE" + ], + "args": [ + "--input-type=module", + "-e", + "import{readdirSync}from'node:fs';import{homedir}from'node:os';import{join}from'node:path';import{pathToFileURL}from'node:url';const d=join(process.env.CODEX_HOME||join(homedir(),'.codex'),'plugins','cache','zerograph','zerograph');let v=[];try{v=readdirSync(d).sort((a,b)=>b.localeCompare(a,'en',{numeric:true}))}catch{}if(!v.length){process.stderr.write(`zerograph: no installed plugin under ${d}; run codex plugin add zerograph@zerograph\\n`);process.exit(1)}(await import(pathToFileURL(join(d,v[0],'scripts','mcp.mjs')).href)).main();" + ] + } + } +} diff --git a/agent/codex/hooks/hooks.json b/agent/codex/hooks/hooks.json new file mode 100644 index 00000000..2e23c5b3 --- /dev/null +++ b/agent/codex/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "hooks": { + "SessionStart": [{ "matcher": "startup|resume|clear|compact", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main())\"", "timeout": 10 }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main())\"", "timeout": 5 }] }], + "PostToolUse": [{ "matcher": "apply_patch|Edit|Write", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "commandWindows": "node -e \"import(require('url').pathToFileURL(require('path').join(process.env.PLUGIN_ROOT,'scripts','hook.mjs')).href).then(m=>m.main())\"", "timeout": 10 }] }] + } +} diff --git a/agent/codex/plugin.test.mjs b/agent/codex/plugin.test.mjs new file mode 100644 index 00000000..4c994259 --- /dev/null +++ b/agent/codex/plugin.test.mjs @@ -0,0 +1,113 @@ +// The Codex plugin against the Claude Code one it mirrors: the same scripts byte for byte, the same +// commands as skills, one version everywhere, and the MCP bootstrap started the way Codex starts +// it. Run: `node --test agent/codex/plugin.test.mjs`. +import { test } from 'node:test'; +import assert from 'node:assert'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const CODEX = dirname(fileURLToPath(import.meta.url)); +const REPO = join(CODEX, '..', '..'); +const CLAUDE = join(REPO, 'agent', 'claude-code'); +const json = (...p) => JSON.parse(readFileSync(join(...p), 'utf8')); +const WIN = process.platform === 'win32'; + +test('the scripts are the Claude Code plugin\'s, byte for byte', () => { + for (const file of readdirSync(join(CLAUDE, 'scripts')).filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs'))) { + assert.ok(existsSync(join(CODEX, 'scripts', file)), `agent/codex/scripts/${file} is missing`); + assert.equal(readFileSync(join(CODEX, 'scripts', file), 'utf8'), readFileSync(join(CLAUDE, 'scripts', file), 'utf8'), + `agent/codex/scripts/${file} differs; run scripts/sync-plugin-scripts.sh`); + } +}); + +test('one version: both plugins and the crate', () => { + const version = json(CODEX, '.codex-plugin', 'plugin.json').version; + assert.equal(json(CLAUDE, '.claude-plugin', 'plugin.json').version, version); + assert.equal(readFileSync(join(REPO, 'Cargo.toml'), 'utf8').match(/^version = "(.+)"$/m)[1], version); + assert.doesNotMatch(json(CODEX, '.mcp.json').mcpServers.zerograph.args.join(' '), /\d+\.\d+\.\d+/, + 'the bootstrap finds the installed version; one written into it goes stale on the next release'); +}); + +test('every Claude Code command is a Codex skill, explicit-only where the command is', () => { + const front = (file) => readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---\n/)[1]; + const field = (text, k) => text.match(new RegExp(`^${k}: (.*)$`, 'm'))?.[1]; + const names = readdirSync(join(CLAUDE, 'skills')); + assert.deepEqual(readdirSync(join(CODEX, 'skills')).sort(), names.sort()); + for (const name of names) { + const theirs = front(join(CLAUDE, 'skills', name, 'SKILL.md')); + const ours = join(CODEX, 'skills', name); + const text = readFileSync(join(ours, 'SKILL.md'), 'utf8'); + assert.equal(field(front(join(ours, 'SKILL.md')), 'name'), name); + assert.equal(field(front(join(ours, 'SKILL.md')), 'description'), field(theirs, 'description'), name); + assert.doesNotMatch(text, /mcp__|\$ARGUMENTS|\/zerograph:/, `${name} names something only Claude Code has`); + const explicit = /disable-model-invocation: true/.test(theirs); + assert.match(readFileSync(join(ours, 'agents', 'openai.yaml'), 'utf8'), new RegExp(`allow_implicit_invocation: ${!explicit}`), name); + } +}); + +test('the hooks run the shipped hook, and the marketplace points at this directory', () => { + const hooks = json(CODEX, 'hooks', 'hooks.json').hooks; + assert.deepEqual(Object.keys(hooks).sort(), ['PostToolUse', 'SessionStart', 'SubagentStart']); + for (const groups of Object.values(hooks)) { + for (const h of groups.flatMap((g) => g.hooks)) { + assert.equal(h.command, 'node "${PLUGIN_ROOT}/scripts/hook.mjs"'); + // `${PLUGIN_ROOT}` is a POSIX shell's expansion; the Windows form reads the variable in node. + assert.doesNotMatch(h.commandWindows, /\$\{|%PLUGIN_ROOT%|\$env:/); + assert.match(h.commandWindows, /process\.env\.PLUGIN_ROOT/); + } + } + const market = json(REPO, '.agents', 'plugins', 'marketplace.json'); + assert.equal(market.name, 'zerograph'); + assert.equal(realpathSync(join(REPO, market.plugins[0].source.path)), realpathSync(CODEX)); + const manifest = json(CODEX, '.codex-plugin', 'plugin.json'); + for (const key of ['skills', 'mcpServers', 'hooks']) assert.ok(existsSync(join(CODEX, manifest[key])), key); +}); + +test('the Windows hook command starts the hook from PLUGIN_ROOT', () => { + const [h] = json(CODEX, 'hooks', 'hooks.json').hooks.SessionStart[0].hooks; + const project = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-codex-hook-'))); + const r = spawnSync(h.commandWindows, { shell: true, cwd: project, encoding: 'utf8', + env: { ...process.env, PLUGIN_ROOT: CODEX }, input: JSON.stringify({ hook_event_name: 'SessionStart', cwd: project }) }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stderr, ''); +}); + +test('the MCP bootstrap finds the installed plugin under CODEX_HOME and serves the project it starts in', { skip: WIN && 'a POSIX launcher for the fake' }, () => { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-codex-test-'))); + const home = join(base, 'codex-home'); + const cache = join(home, 'plugins', 'cache', 'zerograph', 'zerograph'); + cpSync(CODEX, join(cache, '0.10.0'), { recursive: true }); + // An older release left behind must not win: 0.9.0 sorts after 0.10.0 as text. + mkdirSync(join(cache, '0.9.0', 'scripts'), { recursive: true }); + writeFileSync(join(cache, '0.9.0', 'scripts', 'mcp.mjs'), 'export function main() { process.exit(7); }\n'); + const project = join(base, 'project'); + mkdirSync(join(project, '.git'), { recursive: true }); + mkdirSync(join(project, 'src')); + const bin = join(base, 'bin'); + mkdirSync(bin); + const seen = join(base, 'seen.txt'); + writeFileSync(join(bin, 'zerograph'), `#!/bin/sh\necho "$@" > '${seen}'\n`); + chmodSync(join(bin, 'zerograph'), 0o755); + const server = json(CODEX, '.mcp.json').mcpServers.zerograph; + // Codex hands the server only the variables it names and a few of its own, and no `cwd` keeps + // the session's project as the working directory. + const env = { PATH: `${bin}:${process.env.PATH}`, HOME: base, CODEX_HOME: home }; + const r = spawnSync(server.command, server.args, { cwd: join(project, 'src'), env, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + assert.equal(readFileSync(seen, 'utf8').trim(), `--repo ${project} mcp`); + + const bare = spawnSync(server.command, server.args, { cwd: project, env: { ...env, CODEX_HOME: join(base, 'empty') }, encoding: 'utf8' }); + assert.equal(bare.status, 1); + assert.match(bare.stderr, /no installed plugin under .*codex plugin add zerograph@zerograph/); +}); + +test('the MCP server sees the variables the binary and its model download read', () => { + const passed = json(CODEX, '.mcp.json').mcpServers.zerograph.env_vars; + // PATH, HOME, LANG, TMPDIR and the like Codex passes by itself; everything else it scrubs. + for (const name of ['ZEROGRAPH_BIN', 'ZEROGRAPH_CONFIG', 'HF_HOME', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'USERPROFILE', 'LC_ALL']) { + assert.ok(passed.includes(name), name); + } +}); diff --git a/agent/codex/scripts/hook.mjs b/agent/codex/scripts/hook.mjs new file mode 100644 index 00000000..6c37a918 --- /dev/null +++ b/agent/codex/scripts/hook.mjs @@ -0,0 +1,590 @@ +#!/usr/bin/env node +/** + * The one zerograph hook, dispatched on the event the harness names in its payload. + * + * SessionStart → a repository brief, so the graph's state and its five commands are in context + * at start and again after a compaction, without a 7k-token CLAUDE.md. + * PreToolUse → a Bash/Grep/Glob search gets the graph's top three lines beside it, under gates + * written to keep an injection out rather than to put one in: an injection is + * paid once as output and then on every later turn as input. Read is not a search. + * PostToolUse → an Edit/Write to TypeScript gets one risk line when the touched symbol's + * callers cross MEDIUM. The one call here that may refresh the store. + * SubagentStart → every subagent gets the rule. Prose asking the parent to pass it on reached 19 + * of 146 subagent spawns in the recorded sessions, and 0 of the 140 that did not + * carry it ever ran the tool. + * + * Contract: exit 0 always, stdout only when there is something to say, stderr only under + * ZEROGRAPH_HOOK_DEBUG. Every call it waits on is --no-dense, and every one but `changes` is --stale; + * what it starts and leaves running — a `serve`, an `update --detach` — is neither. + */ +import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, lstatSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { join, dirname, relative, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; + +import { binaryOf, checkoutOf as repositoryOf, childEnv as cutEnv, commandOf, pathKey } from './launcher.mjs'; + +const MAX_INJECTIONS = 12; // 12 × ~107 tokens: a third of the CLAUDE.md sections this replaces +const SEEDS = 3; +const LINE_CUT = 160; +const REMINDER_EVERY = 40; +const TIMEOUT_MS = 5000; +/** + * The seconds of in-process refresh a call gets, passed as `ZEROGRAPH_READER_BUDGET`. Under the + * binary's own 10 s a refresh of 5–10 s was killed at the timeout above with its work lost, and the + * next call started it again; at 2 s what does not fit goes to a detached `update`, and the other + * 3 s are the process start, the store load, the walk and the answer. + */ +const HOOK_BUDGET = 2; +/** What the autostarted `serve` is told: leave after half an hour unasked, forget the model after five minutes. */ +const SERVE_IDLE = 1800; +const SERVE_IDLE_MODEL = 300; // measured on the 33.5k-row copy: 0.08 s resident, 0.77 s after a drop + +const COMMAND = 'zerograph'; + +/** The rule handed to every subagent. */ +const RULE = `This repository has a zerograph index. Ask it before grepping for a concept: \`${COMMAND} ask \` +answers by meaning, \`${COMMAND} impact \` names who calls a symbol, \`${COMMAND} changes\` maps +your diff onto the callers it reaches. Read only the \`path:line\` those answers print, and cite one +for every claim; when nothing printed holds it, say so. +Grep is still the right tool for a literal — an env var name, a string in a test, a config key. +`; + +const BUILDING_NOTICE = + `zerograph: this checkout has no index yet, so one is being built in the background ` + + `(\`${COMMAND} status\` shows how far it has got); until it lands, search as usual.`; + +const BUILD_NOTICE = + `zerograph: this repository has no index yet. \`${COMMAND} build\` writes one in a few minutes on a ` + + 'small repository and longer on a large one, though rows another checkout on this machine already ' + + 'embedded come from its vector cache instead of being embedded again. It answers by meaning ' + + 'afterwards; until then this notice is all it can say.'; + +function rule() { + return RULE; +} + +function env(suffix) { + return process.env[`ZEROGRAPH_${suffix}`]; +} + +function debug(...args) { + if (env('HOOK_DEBUG')) console.error('[zerograph-hook]', ...args); +} + +const WIN = process.platform === 'win32'; + +/** The checkout `root` is in, or `root` outside git: what a PATH entry must stay out of. */ +function checkoutOf(root) { + return root && repositoryOf(root); +} + +/** + * The `zerograph` the machine's own PATH resolves, as an absolute path, or null; never one in the + * checkout, which the hook runs in every repository a session opens. On Windows every `.exe` + * outranks every `.cmd`. + */ +function onPath(root, platform = process.platform) { + return binaryOf('', { path: process.env[pathKey(process.env, platform === 'win32')], checkout: checkoutOf(root), win: platform === 'win32' }).bin ?? null; +} + +/** + * `ZEROGRAPH_BIN`, else the plugin's `binary` option, else PATH; null when none names one, and + * then nothing is spawned. The rules are the MCP server's, from `launcher.mjs`: a relative path + * names the checkout's file and is refused. + */ +function binary(root) { + const named = env('BIN') || process.env.CLAUDE_PLUGIN_OPTION_BINARY || ''; + const found = binaryOf(named, { path: process.env[pathKey(process.env, WIN)], checkout: checkoutOf(root), win: WIN }); + if (found.error) debug(found.error); + return found.bin ?? null; +} + +function childEnv(root, extra = {}) { + return cutEnv(process.env, checkoutOf(root), WIN, extra); +} + +/** `bin args` as an argv `spawn` takes without a shell, or null when `cmd` would read an argument as code. */ +function command(bin, args) { + const c = commandOf(bin, args, process.env.ComSpec); + if (c.error) debug(c.error); + return c.argv ? c : null; +} + +/** + * `HOOK_BUDGET`, or a smaller whole number of seconds the environment or the repository's + * `zerograph.toml` already sets. The variable outranks every file, so passing 2 would otherwise + * lift a repository's `reader_budget = 0` back to 2. + */ +function readerBudget(root) { + const set = String(env('READER_BUDGET') ?? '').trim(); + let file = ''; + try { file = readFileSync(join(root, 'zerograph.toml'), 'utf8').match(/^\s*reader_budget\s*=\s*(\d+)\s*(?:#.*)?$/m)?.[1] ?? ''; } catch {} + const smallest = [set, file].filter((v) => /^\d+$/.test(v)).map(Number).reduce((a, b) => Math.min(a, b), HOOK_BUDGET); + return String(smallest); +} + +// Starts a command and, once `ms` pass, hands its pid to `kill`. Run in a Node of its own because +// `spawnSync`'s timeout kills only the process it started: the `zerograph` a batch file's `cmd` +// started would outlive the five seconds this hook waits, and go on holding the store. +const BOUNDED = ` +const { spawn, spawnSync } = require('node:child_process'); +const [file, args, ms, kill] = JSON.parse(process.argv[1]); +const child = spawn(file, args, { stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true }); +const timer = setTimeout(() => { + spawnSync(kill[0], [...kill.slice(1), String(child.pid)], { stdio: 'ignore', windowsHide: true }); + process.exit(124); +}, ms); +child.on('error', () => process.exit(127)); +child.on('exit', (code) => { clearTimeout(timer); process.exit(code ?? 1); }); +`; + +/** `spawnSync` of a command whose whole process tree `kill` ends after `ms`. */ +function bounded(file, args, options, kill, ms = TIMEOUT_MS) { + // The outer timeout is only a backstop for the helper itself. + return spawnSync(process.execPath, ['-e', BOUNDED, JSON.stringify([file, args, ms, kill])], { ...options, timeout: ms + 2000 }); +} + +// By its full path: a bare `taskkill` would be looked up in the working directory first. +function treeKill() { + return [join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'), '/T', '/F', '/PID']; +} + +function run(root, args) { + const bin = binary(root); + const c = bin && command(bin, ['--repo', root, '--no-dense', ...args]); + if (!c) return ''; + const [file, ...argv] = c.argv; + const options = { + // Never the checkout: npm's Windows `.cmd` shim runs a bare `node` through `cmd`, which looks + // in the working directory first. The binary is told its repository by `--repo`. + cwd: dirname(bin), + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + env: childEnv(root, { ZEROGRAPH_READER_BUDGET: readerBudget(root) }), + }; + const r = c.batch ? bounded(file, argv, options, treeKill()) : spawnSync(file, argv, { ...options, timeout: TIMEOUT_MS }); + if (r.status !== 0) debug('exit', r.status, args.join(' '), (r.stderr || '').slice(0, 200)); + // Identifiers and headings in the answer are the repository's text, and it is about to enter an + // agent's context and possibly a terminal log; control characters other than tab and newline carry nothing. + return r.status === 0 ? r.stdout.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '') : ''; +} + +function built(root) { + return existsSync(join(root, '.zerograph', 'manifest.json')); +} + +/** + * The repository the hook asks about, chosen the way the CLI chooses it without `--repo`: inside a + * checkout its top level, whether or not a store is built there, so the hook never answers from a + * package's store that `zerograph status` run beside it does not see. Outside git, the nearest + * directory at or above `start` that carries an index. The harness's `cwd` follows a `cd`, and a + * nested worktree is a checkout of its own, so it never borrows its parent's index. + */ +function indexRoot(start) { + const checkout = checkoutRoot(start); + if (checkout) return checkout; + for (let d = start; ; d = dirname(d)) { + if (built(d)) return d; + if (dirname(d) === d) return null; + } +} + +/** The checkout `start` is in: the nearest directory at or above it holding a `.git`, file or directory. */ +function checkoutRoot(start) { + for (let d = start; ; d = dirname(d)) { + if (existsSync(join(d, '.git'))) return d; + if (dirname(d) === d) return null; + } +} + +/** + * Whether a checkout asked for an index: it carries a `zerograph.toml`, its main worktree has a + * built index, or either holds the hook an older `install-agent` wrote. The plugin runs this hook + * in every repository a session opens, and a build started in each of them would be the cost a git + * hook used to pay on every worktree add. + */ +function optedIn(checkout) { + const asked = (dir) => ['zerograph.toml', '.claude/hooks/zerograph-hook.mjs', '.codex/hooks/zerograph-hook.mjs'] + .some((p) => existsSync(join(dir, ...p.split('/')))); + const main = mainCheckout(checkout); + return asked(checkout) || (main !== null && (asked(main) || built(main))); +} + +/** The main worktree of a linked one, read off its `.git` file and the `commondir` beside it, or null. */ +function mainCheckout(checkout) { + try { + const gitdir = readFileSync(join(checkout, '.git'), 'utf8').match(/^gitdir:\s*(.+?)\s*$/m)?.[1]; + if (!gitdir) return null; + const linked = isAbsolute(gitdir) ? gitdir : join(checkout, gitdir); + const common = readFileSync(join(linked, 'commondir'), 'utf8').trim(); + const commonDir = isAbsolute(common) ? common : join(linked, common); + return /^\.git$/.test(commonDir.split(/[\\/]/).filter(Boolean).pop() ?? '') ? dirname(commonDir) : null; + } catch { return null; } +} + +/** Whether `binary` names something that will start: an explicit one, or PATH's. */ +function binaryFound(checkout) { + return binary(checkout) !== null; +} + +/** + * Words worth asking, or null. Four letters is the floor because a shorter token is a flag, an + * extension or a variable name far more often than it is a concept; six words is the ceiling + * because the seventh never changed an answer and every one costs prompt. + */ +export function queryWords(pattern) { + if (!pattern) return null; + if (/\//.test(pattern) && !/\s/.test(pattern)) return null; // a path, not a question + // A class escape (`\b`, `\w`, `\s`) goes whole: dropping only the backslash glues its letter to + // the word beside it, and `\bwithTenant\b` would ask about `bwithTenant`. + const bare = pattern.replace(/\\[A-Za-z]/g, ' ').replace(/[\\^$.*+?()[\]{}|]/g, ' ').trim(); + if (!bare) return null; + // A lone identifier or a document id is sent whole: the exact stage answers it in 60 ms and + // 20–40 tokens, which is the best case this hook has. A milestone or task id (`BE-M17`, + // `BE-M01-T03`) ends in a capital and digits, and has no four-letter word to fall back on. + if (/^[A-Za-z][A-Za-z0-9_]*$/.test(bare) && bare.length >= 4) return [bare]; + if (/^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-[A-Z]?\d{1,4}$/.test(bare)) return [bare]; + // A leading dash is dropped: `--rerank` sent as a word is parsed by `ask` as its paid flag. + const words = bare.split(/[^\p{L}\p{N}_-]+/u).map((w) => w.replace(/^-+/, '')) + .filter((w) => /\p{L}{4,}/u.test(w)).slice(0, 6); + return words.length ? words : null; +} + +/** + * A shell line cut into its stages, each marked by whether a pipe feeds it. Quoted strings and + * backslash escapes are kept whole, so the `|` in `grep -E "a|b"` or `grep a\|b` is a pattern and + * not a pipe. + */ +function stages(command) { + const out = [{ piped: false, text: '' }]; + for (const [part] of command.matchAll(/(?:"[^"]*"|'[^']*'|\\[\s\S]|[^"'\\|;&\n])+|\|&|\|\||&&|[|;&\n]/g)) { + const last = out[out.length - 1]; + // A newline after an operator continues the line: `git log |\n grep x` is still fed by the pipe. + if (part === '\n' && !last.text.trim()) continue; + if (/^(?:\|&|\|\||&&|[|;&\n])$/.test(part)) out.push({ piped: part === '|' || part === '|&', text: '' }); + // A backslash-newline continues the line and reads as a space; kept, its backslash is taken for + // the pattern of an `rg` whose arguments start on the next line. + else last.text += part.replace(/\\\r?\n/g, ' '); + } + return out; +} + +/** + * The flags whose value is the next word, one list per tool, read off `rg --help` and + * `grep --help`: a shared list is wrong for both, because the same letter means different things + * under each — `-r` is a replacement under `rg` and recursion under `grep`, `-E` an encoding and + * extended syntax, `-T` a type to exclude and a leading tab. A flag missing here hands its value to + * the hook as the pattern, and `--exclude-dir node_modules` spent an injection on `node_modules`. + * A value attached with `=` is one token that starts with `-` and needs no entry; `--color` under + * `grep` only takes its value that way, so it is absent from that list on purpose. + */ +const VALUE_FLAGS = { + rg: /^-(A|B|C|E|M|T|d|f|g|j|m|r|t|-after-context|-before-context|-context|-color|-colors|-context-separator|-dfa-size-limit|-encoding|-engine|-field-context-separator|-field-match-separator|-file|-generate|-glob|-hostname-bin|-hyperlink-format|-iglob|-ignore-file|-max-columns|-max-count|-max-depth|-max-filesize|-path-separator|-pre|-pre-glob|-regex-size-limit|-replace|-sort|-sortr|-threads|-type|-type-add|-type-clear|-type-not)$/, + grep: /^-(A|B|C|D|d|f|m|-after-context|-before-context|-context|-devices|-directories|-file|-max-count|-exclude|-exclude-dir|-exclude-from|-include|-include-dir|-label|-binary-files|-group-separator)$/, +}; + +/** + * What a tool call searches for. Bash is the surface that matters: a pass over 38 recorded sessions + * found Grep 0, Glob 0 and Bash 2,727 calls, 933 of them running `rg` or `grep` — the harness tells + * the model to search through Bash, so a hook that only watched Grep watched nothing. + */ +export function searchPattern(tool, input) { + if (!input) return null; + if (tool === 'Grep') return input.pattern || null; + if (tool === 'Glob') { + const segments = String(input.pattern || '').split(/[/*]+/) + .map((s) => s.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')) + .filter((s) => /\p{L}{4,}/u.test(s)); + return segments.sort((a, b) => b.length - a.length)[0] || null; + } + if (tool !== 'Bash') return null; + const command = String(input.command || ''); + if (!/\b(rg|grep)\b/.test(command)) return null; + for (const stage of stages(command)) { + // A stage a pipe feeds searches what the stage before it printed, not the repository: + // `gh auth status 2>&1 | grep -E "Logged in|Active account"` came back with three graph lines + // about accounts. `| xargs grep` is skipped too — a missed injection costs nothing, and a wrong + // one is paid on every later turn. + if (stage.piped) continue; + // Quoted first: a phrase is one argument, and splitting on whitespace would send the graph the + // first word of `rg -n "cancellation policy"` and dedup every later phrase starting the same way. + const tokens = stage.text.match(/"[^"]*"|'[^']*'|\S+/g) || []; + const at = tokens.findIndex((t) => /^(rg|grep)$/.test(t) || /\/(rg|grep)$/.test(t)); + if (at < 0) continue; + const takesValue = VALUE_FLAGS[tokens[at].replace(/.*\//, '')]; + for (let i = at + 1; i < tokens.length; i += 1) { + const t = tokens[i]; + // `-e` and `--regexp` carry the pattern itself; skipping their value would ask about the path after it. + if (/^-(e|-regexp)$/.test(t)) return (tokens[i + 1] || '').replace(/^['"]|['"]$/g, '') || null; + if (takesValue.test(t)) { i += 1; continue; } + if (t.startsWith('-')) continue; + return t.replace(/^['"]|['"]$/g, ''); + } + } + return null; +} + +/** + * The directory the hook keeps its markers in: one per user under the temp directory, made 0700 + * and checked to be ours on every use. A name anyone can predict in a shared `/tmp` is a name + * another user can create first, or point at a file of yours; null here means no state, which + * costs a repeated answer and nothing else. Windows' temp directory is already per-user. + */ +function stateRoot() { + const uid = process.getuid?.(); + const root = join(tmpdir(), uid === undefined ? 'zerograph-hook' : `zerograph-hook-${uid}`); + if (uid === undefined) return root; + try { + mkdirSync(root, { recursive: true, mode: 0o700 }); + const st = lstatSync(root); + return st.isDirectory() && st.uid === uid && (st.mode & 0o077) === 0 ? root : null; + } catch { return null; } +} + +/** + * The per-session state directory. The key is built from ids the harness supplies, so it is + * reduced to word characters before it becomes a path segment: an id carrying a slash or a `..` + * would otherwise put this hook's empty marker files anywhere the process can write. + */ +function stateDir(sessionKey) { + const root = stateRoot(); + return root && join(root, sessionKey.replace(/\W+/g, '-')); +} + +/** True the first time this session asks about this key; an unwritable tmp answers once and never dedups. */ +function once(sessionKey, kind, key) { + const dir = stateDir(sessionKey); + if (!dir) return true; + const file = join(dir, `${kind}-${createHash('sha1').update(key).digest('hex').slice(0, 16)}`); + if (existsSync(file)) return false; + try { mkdirSync(dir, { recursive: true }); writeFileSync(file, ''); } catch { /* answer this one */ } + return true; +} + +function count(sessionKey, name) { + const dir = stateDir(sessionKey); + if (!dir) return 0; + try { return Number(readFileSync(join(dir, `count-${name}`), 'utf8')) || 0; } catch { return 0; } +} + +function bump(sessionKey, name) { + const n = count(sessionKey, name) + 1; + const dir = stateDir(sessionKey); + if (!dir) return n; + try { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, `count-${name}`), String(n)); } catch { /* no state, no cadence */ } + return n; +} + +function isWatchedCall(tool, input) { + if (tool === 'Bash') return /\b(rg|grep)\b/.test(String((input || {}).command || '')) || /\.(ts|tsx|md)\b/.test(String((input || {}).command || '')); + return tool === 'Grep' || tool === 'Glob'; +} + +/** + * On by default because the harness said so, under a rule committed before the number was read: + * interception ships when its tokens per hit are lower than reminder-only's. Sonnet, twice over the + * twelve tasks — 137,991 and 129,243 tokens per hit with it, 194,011 and 169,036 without, the two + * ranges not overlapping — and 11/11 hits against 11/9. `ZEROGRAPH_HOOK_INTERCEPT=0` turns it off. + * `docs/bench/2026-09-09-agent-surface-results.md` has all four runs and what they are worth. + */ +function intercept(payload, root, sessionKey) { + if (env('HOOK_INTERCEPT') === '0') return null; + const input = payload.tool_input || {}; + // A search the agent runs *with* zerograph is not one to answer beside. + if (payload.tool_name === 'Bash' && /\bzerograph\b/.test(String(input.command || ''))) return null; + if (/\.zerograph\//.test(JSON.stringify(input))) return null; + const words = queryWords(searchPattern(payload.tool_name, input)); + if (!words) return null; + if (!once(sessionKey, 'ask', words.join(' ').toLowerCase())) return null; + if (count(sessionKey, 'injections') >= MAX_INJECTIONS) return null; + const lines = run(root, ['ask', '--stale', '--seeds', String(SEEDS), ...words]) + .split('\n').filter((l) => l && !l.startsWith(' ')).map((l) => l.slice(0, LINE_CUT)); + if (!lines.length) return null; + bump(sessionKey, 'injections'); + return [`${COMMAND} ask ${words.join(' ')} →`, ...lines, `(read only these path:line; \`${COMMAND} ask\` for more)`].join('\n'); +} + +function reminder(payload, sessionKey) { + if (!isWatchedCall(payload.tool_name, payload.tool_input)) return null; + const n = bump(sessionKey, 'watched'); + return n % REMINDER_EVERY === 0 + ? `zerograph: this repository has an index — \`${COMMAND} ask \` answers by meaning, no tokens.` + : null; +} + +/** + * The files an edit wrote. Claude Code names one in `file_path`; Codex's `apply_patch` carries a + * patch in `command`, where every file it adds, updates or moves to has its own header line, relative + * to the project. + */ +function editedFiles(input, root) { + if (input.file_path) return [String(input.file_path)]; + const patch = typeof input.command === 'string' ? input.command : ''; + const files = [...patch.matchAll(/^\*\*\* (?:Add File|Update File|Move to): (.+?)\s*$/gm)].map((m) => m[1]); + return [...new Set(files)].map((f) => (isAbsolute(f) ? f : join(root, f))); +} + +function riskLine(payload, root, sessionKey) { + const files = editedFiles(payload.tool_input || {}, root).filter((f) => /\.tsx?$/.test(f)); + if (!files.length) return null; + let r; + try { r = JSON.parse(run(root, ['changes', '--depth', '1', '--json'])); } catch { return null; } + if (!r || !['MEDIUM', 'HIGH', 'CRITICAL'].includes(r.risk)) return null; + const lines = files.map((file) => fileRisk(r, file, root, sessionKey)).filter(Boolean); + return lines.length ? lines.join('\n') : null; +} + +function fileRisk(r, file, root, sessionKey) { + // `changes` covers the whole working diff: keep only what the edited file contributes, or an edit to + // a leaf file is told about the callers of symbols some other file in the diff touched. + const rel = relative(root, file).split('\\').join('/'); + const name = (id) => String(id || '').split('::').pop(); + const touched = (r.touched || []).filter((t) => t.indexed !== false); + const mine = touched.filter((t) => String(t.at || '').startsWith(`${rel}:`)); + // A caller that imports through a barrel is reached by the barrel's alias, `sym:::`, + // so a symbol is matched by name as well — unless another file in the diff touched the same name. + const theirs = new Set(touched.filter((t) => !mine.includes(t)).map((t) => name(t.id))); + const names = mine.map((t) => name(t.id)).filter((n) => !theirs.has(n)); + const direct = (r.affected || []).filter((d) => d.depth === 1 && (String(d.via || '').startsWith(`sym:${rel}::`) + || names.some((n) => name(d.via) === n || name(d.via).startsWith(`${n}.`)))); + if (!direct.length) return null; + const files = new Set(direct.map((d) => String(d.at || '').split(':')[0])).size; + // `r.risk` scores the whole diff; the level is this file's own direct callers, on the thresholds + // `impact::risk` prints beside its counts. + const n = direct.length; + const risk = n >= 30 || files >= 25 ? 'CRITICAL' : n >= 15 || files >= 10 ? 'HIGH' : n >= 5 || files >= 3 ? 'MEDIUM' : null; + if (!risk) return null; + if (!once(sessionKey, 'risk', `${file}:${risk}`)) return null; + const called = [...new Set(direct.map((d) => name(d.via)))].slice(0, 3).join(', '); + return `zerograph changes: risk ${risk} — ${n} direct callers of ${called} in ${files} files; \`${COMMAND} changes --depth 1\` lists them.`; +} + +function brief(root) { + const text = run(root, ['prime']); + return text ? text.trimEnd() : null; +} + +/** + * Starts a resident `serve` for this repository, detached, and never waits on it. No probe first: + * a second `serve` refuses to bind while one answers and exits by itself, so the start *is* the + * check. What it buys is the difference between a gate that fires and one that times out — a fused + * ask is ~0.4 s against a cold process and ~80 ms through the socket, inside a 5 s hook budget on a + * machine that is also running a build. `--idle-model` is what keeps that cheap between questions. + */ +function autostartServe(root) { + if (env('HOOK_SERVE') === '0') return false; + return detached(root, ['--repo', root, 'serve', '--idle', String(SERVE_IDLE), '--idle-model', String(SERVE_IDLE_MODEL)]); +} + +/** + * Starts `zerograph ` and leaves it running. Whether the start was handed to the OS, which + * is not whether the binary was found: that failure arrives later, as an event. + */ +function detached(root, args) { + const bin = binary(root); + if (!bin) return false; + try { + const c = command(bin, args); + if (!c) return false; + const [file, ...argv] = c.argv; + const child = spawn(file, argv, { cwd: dirname(bin), detached: true, stdio: 'ignore', windowsHide: true, env: childEnv(root) }); + // A spawn that fails reports it as an event, not as a throw, and an unheard `error` event ends + // this process — which would turn "no binary on PATH" into a hook that dies on stderr at every + // session start. The listener is what keeps the contract: exit 0, and say nothing. + child.on('error', (e) => debug(args[2], e.message)); + child.unref(); + return true; + } catch (e) { debug(args[2], e.message); return false; } +} + +/** + * A detached `update`, at background priority, which walks the tree itself and writes nothing when + * nothing moved — so the hook never pays a walk to decide whether one is owed. Dense, as every + * refresh nobody waits on is: the background band keeps the embed out of the way. + */ +function startUpdate(root) { + return detached(root, ['--repo', root, 'update', '--detach']); +} + +/** + * A checkout with no index of its own — a new worktree, typically — gets one built in the + * background from its first session, where a git hook used to start one on every `git worktree add`. + */ +function bootstrap(cwd) { + const checkout = cwd && checkoutRoot(cwd); + // A spawn reports a missing binary only later, as an event, so the notice would claim a build + // that never started. + if (!checkout || !optedIn(checkout) || !binaryFound(checkout)) return null; + return startUpdate(checkout) ? BUILDING_NOTICE : null; +} + +const handlers = { + SessionStart: (p, root) => { + if (!built(root)) return bootstrap(p.cwd || root); + // The resident serve catches the store up as it starts; without one, a detached update does. + if (!autostartServe(root)) startUpdate(root); + return brief(root); + }, + SubagentStart: (p, root) => (built(root) ? rule() : null), + PreToolUse: (p, root, key) => { + if (p.tool_name === 'Agent') { + if (env('HOOK_AGENT_INPUT') !== '1') return null; + const prompt = String((p.tool_input || {}).prompt || ''); + return { updatedInput: { ...(p.tool_input || {}), prompt: `${prompt}\n\n${rule()}` } }; + } + if (!built(root)) { + if (!isWatchedCall(p.tool_name, p.tool_input) || !once(key, 'build', 'once')) return null; + // A machine-wide plugin says nothing in a repository that never asked for an index. + const checkout = p.cwd && checkoutRoot(p.cwd); + if (!checkout || !optedIn(checkout)) return null; + // SessionStart already started the build there; a `build` on top of it would embed twice. + return binaryFound(checkout) ? BUILDING_NOTICE : BUILD_NOTICE; + } + return intercept(p, root, key) ?? reminder(p, key); + }, + PostToolUse: (p, root, key) => (built(root) ? riskLine(p, root, key) : null), +}; + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +async function main() { + let payload; + try { payload = JSON.parse(await readStdin()); } catch { return; } + const event = payload.hook_event_name; + const handler = handlers[event]; + if (!handler) return; + // The harness's cwd follows a `cd` into a subdirectory, which has no `.zerograph/` of its own. + const root = (payload.cwd && indexRoot(payload.cwd)) || payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + // The subagent's own id is in the key: a subagent shares its parent's session_id, so a counter + // keyed by session alone finds the count already past zero on a subagent's first call and says + // nothing — which is a second reason 140 unprimed subagents went on grepping. + const sessionKey = `${payload.session_id || 'nosession'}-${payload.agent_id || 'main'}`; + let out; + try { out = handler(payload, root, sessionKey); } catch (e) { debug('handler', e); return; } + if (!out) return; + const body = typeof out === 'string' + ? { hookEventName: event, additionalContext: out } + : { hookEventName: event, ...out }; + // Written without process.exit: on Windows the pipe may still be draining when exit would cut it. + process.stdout.write(JSON.stringify({ hookSpecificOutput: body })); +} + +// Importable for the tests, which exercise the pure parts directly; only the real run reads stdin. +// `import.meta.url` is the resolved path and argv[1] keeps a symlink in the project path, so both are +// resolved: compared as given, a project reached through a link ran the hook and it said nothing. +function invokedDirectly() { + try { return Boolean(process.argv[1]) && fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); } catch { return false; } +} +if (invokedDirectly()) { + main(); +} + +// `main` for Codex on Windows, whose hook command imports this file rather than naming its path in +// a shell variable. +export { main, rule, RULE, COMMAND, binary, onPath, bounded, intercept, riskLine, handlers, MAX_INJECTIONS }; diff --git a/agent/codex/scripts/launcher.mjs b/agent/codex/scripts/launcher.mjs new file mode 100644 index 00000000..5b577db2 --- /dev/null +++ b/agent/codex/scripts/launcher.mjs @@ -0,0 +1,151 @@ +// How the plugin finds and starts `zerograph`, shared by its MCP server and its session hook so the +// two cannot disagree about which binary is safe to run. Pure functions over what the caller passes +// in (PATH, the checkout, the platform), so the tests can play Windows on any machine. +// +// `agent/codex/scripts/launcher.mjs` is a copy of this file — a plugin is installed as one +// directory and cannot import from a sibling — and a test fails when the two differ. +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const UNSET = '${user_config.binary}'; + +/** Whether `path` is absolute in either platform's spelling. */ +export const isAbsolute = path => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\'); + +/** Whether `dir` is `root` or below it; Windows paths name one file in any case. */ +export function inside(root, dir, win) { + if (!root) return false; + const norm = p => { + const slashed = p.replace(/\\/g, '/').replace(/\/+$/, ''); + return win ? slashed.toLowerCase() : slashed; + }; + const r = norm(root); + const d = norm(dir); + return d === r || d.startsWith(`${r}/`); +} + +/** `p` with every link followed, or as given when it does not resolve. */ +export function real(p) { + try { return realpathSync.native(p); } catch { return p; } +} + +/** + * `p` with the links in its existing part followed: an entry that does not exist yet still names + * the checkout when it is spelled through a link to it, such as macOS's `/var` for `/private/var`. + */ +function realAbove(p) { + const parent = dirname(p); + if (existsSync(p) || parent === p) return real(p); + return join(realAbove(parent), p.slice(parent.length).replace(/^[\\/]+/, '')); +} + +/** + * The nearest directory at or above `start` holding `name`, or null. Walked from the real path, as + * the CLI canonicalizes its working directory, so a session opened through a link finds the + * checkout the CLI would. + */ +export function nearest(start, name) { + for (let d = real(start); ; d = dirname(d)) { + if (existsSync(join(d, name))) return d; + if (dirname(d) === d) return null; + } +} + +/** + * The repository around `root`, found the way the CLI finds it: the checkout (a session opened in + * a package of a monorepo still has the whole checkout around it), else the nearest directory + * above holding a `.zerograph/`, else `root` itself. It is what zerograph indexes and what a PATH + * entry must stay out of. + */ +export function checkoutOf(root) { + return nearest(root, '.git') ?? nearest(root, '.zerograph') ?? root; +} + +/** The PATH variable's name: any case on Windows, where the environment ignores it, else `PATH`. */ +export function pathKey(env, win) { + return (win && Object.keys(env).find(k => k.toUpperCase() === 'PATH')) || 'PATH'; +} + +/** + * PATH's absolute entries outside `checkout`, compared as spelled and with every link followed, so + * neither a symlinked alias of the checkout on PATH nor a session opened through one lets the + * checkout's file through. A relative entry (`.`, `node_modules/.bin`) resolves against the + * repository the caller runs in, so it is the repository's and not the machine's. + */ +export function machineDirs(path, checkout, win, home = homedir()) { + const cut = guarded(checkout, win, home); + const resolved = cut && real(checkout); + return (path ?? '').split(win ? ';' : ':').filter(entry => { + // Windows reads a quoted entry as the directory inside the quotes. + const dir = win ? entry.replace(/^"(.*)"$/, '$1') : entry; + return dir && isAbsolute(dir) && !(cut && (inside(checkout, dir, win) || inside(resolved, realAbove(dir), win))); + }); +} + +/** + * Whether PATH entries inside `checkout` are cut. Not when it is a filesystem root or holds the home + * directory: a `~/.zerograph` or a dotfiles `~/.git` makes the home directory the checkout, and the + * cut would then take nvm, cargo and `~/.local/bin` with it, the very places zerograph and `node` + * are installed. + */ +function guarded(checkout, win, home) { + if (!checkout || dirname(checkout) === checkout) return false; + return !(home && (inside(checkout, home, win) || inside(real(checkout), real(home), win))); +} + +/** + * The configured binary when it is an absolute path, else PATH's by its absolute path, and never + * one under the checkout: a cloned repository that ships `node_modules/.bin/zerograph` would + * otherwise have it run the moment a session opened in it. A relative path is refused rather than + * read against a working directory nobody chose; a bare name means the PATH lookup. + */ +export function binaryOf(configured, { path, checkout, win, home }) { + const option = configured === UNSET ? '' : (configured ?? '').trim(); + if (option && /[\\/]/.test(option)) { + return isAbsolute(option) ? { bin: option } : { error: `the configured binary (${option}) is not an absolute path; name an absolute one in the plugin's binary option or ZEROGRAPH_BIN, or clear it to use PATH` }; + } + const dirs = machineDirs(path, checkout, win, home); + // Every `.exe` before any `.cmd`: npm's global shim is a `.cmd`, which only `cmd` can start. + const names = win ? ['zerograph.exe', 'zerograph.cmd'] : ['zerograph']; + for (const name of names) { + for (const dir of dirs) { + const file = join(dir, name); + const stat = statSync(file, { throwIfNoEntry: false }); + if (stat?.isFile() && (win || (stat.mode & 0o111))) return { bin: file }; + } + } + return { error: 'no zerograph on PATH outside this checkout; install it (npm i -g zerograph) or name its absolute path in the plugin\'s binary option or ZEROGRAPH_BIN' }; +} + +// What `cmd` acts on outside quotes (`&|<>^` run or redirect, `"` toggles quoting) or expands +// even inside them (`%`, and `!` where delayed expansion is on). +const CMD_SPECIAL = /[&|<>^"%!]/; + +/** + * The argv that starts `bin` with `args`, or why it cannot be started safely. Node has refused to + * start a batch file without a shell since CVE-2024-27980, so a `.cmd` goes through `cmd` by hand; + * `call` keeps the command line from opening on a quote, which `cmd /c` would strip along with the + * last one. `cmd` parses the line Node builds, and Node quotes an argument only when it holds a + * space or a quote, so a checkout like `C:\src\a&calc` would reach it bare and its `&` start a + * second command; a quote added here is escaped as `\"`, which `cmd` reads as one that closes. An + * argument `cmd` would act on is therefore refused. + */ +export function commandOf(bin, args, comspec) { + if (!/\.(cmd|bat)$/i.test(bin)) return { argv: [bin, ...args] }; + const unsafe = [bin, ...args].find(a => CMD_SPECIAL.test(a)); + if (unsafe) { + return { error: `${unsafe} holds a character cmd would act on (& | < > ^ " % !), so the npm shim ${bin} is not started through it; name the zerograph.exe the npm package ships in the plugin's binary option or ZEROGRAPH_BIN` }; + } + return { argv: [comspec || 'C:\\Windows\\System32\\cmd.exe', '/d', '/c', 'call', bin, ...args], batch: true }; +} + +/** + * The environment the binary runs in: PATH cut to the machine's entries, as the binary was. npm's + * launcher finds `node` through PATH — `#!/usr/bin/env node`, or the `.cmd` shim's bare `node` — so + * an entry an `.envrc` put inside the checkout would start the checkout's `node` one step on. + */ +export function childEnv(env, checkout, win, extra = {}, home = undefined) { + const key = pathKey(env, win); + return { ...env, [key]: machineDirs(env[key], checkout, win, home).join(win ? ';' : ':'), ...extra }; +} diff --git a/agent/codex/scripts/mcp.mjs b/agent/codex/scripts/mcp.mjs new file mode 100644 index 00000000..398b69dc --- /dev/null +++ b/agent/codex/scripts/mcp.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// The plugin's MCP server: `zerograph --repo mcp`, with the binary found by +// `launcher.mjs`, as the session hook finds it. A manifest can substitute the `binary` option but +// cannot branch on it, so the choice between the option and PATH is made here. +// +// Usage: node mcp.mjs '' +import { spawn } from 'node:child_process'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { binaryOf, checkoutOf, childEnv, commandOf, pathKey, real } from './launcher.mjs'; + +export { binaryOf, checkoutOf, childEnv }; + +/** The argv that starts the server for `checkout`, or why it cannot be started safely. */ +export function argvOf(bin, checkout, comspec) { + return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); +} + +export function main() { + const win = process.platform === 'win32'; + const env = process.env; + // Claude Code names the project in the server's environment; Codex starts the server in the + // project and names nothing, and neither documents the working directory for the other. + const checkout = checkoutOf(env.CLAUDE_PROJECT_DIR || process.cwd()); + // ZEROGRAPH_BIN before the option, in the hook's order, so the two never run different binaries. + // Claude Code passes the option as the one argument, unsubstituted while it is unset, which + // `binaryOf` reads as empty; Codex has no options and passes ZEROGRAPH_BIN through. + const found = binaryOf(env.ZEROGRAPH_BIN || process.argv[2] || '', { path: env[pathKey(env, win)], checkout, win }); + const launch = found.error ? found : argvOf(found.bin, checkout, env.ComSpec); + if (launch.error) { + // stderr is the server log the harness shows; stdout belongs to the protocol. + process.stderr.write(`zerograph: ${launch.error}\n`); + process.exit(1); + } + const [cmd, ...args] = launch.argv; + // Never from the checkout: npm's `.cmd` shim starts a bare `node`, and `cmd` looks for that in + // the working directory before PATH. The cut PATH keeps a checkout's `node_modules/.bin/node` + // from being the one the shim, or anything the server starts, finds. + const child = spawn(cmd, args, { stdio: 'inherit', cwd: dirname(found.bin), windowsHide: true, env: childEnv(env, checkout, win) }); + child.on('error', err => { process.stderr.write(`zerograph: ${found.bin}: ${err.message}\n`); process.exit(1); }); + child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0))); + for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig)); +} + +// Imported by its tests and by Codex's bootstrap, run by the Claude Code manifest; a plugin cache +// reached through a link names this file by another spelling than the one Node resolved. +if (process.argv[1] && real(fileURLToPath(import.meta.url)) === real(process.argv[1])) main(); diff --git a/agent/codex/skills/ask/SKILL.md b/agent/codex/skills/ask/SKILL.md new file mode 100644 index 00000000..5789ebb4 --- /dev/null +++ b/agent/codex/skills/ask/SKILL.md @@ -0,0 +1,19 @@ +--- +name: ask +description: Ask the zerograph index a question, an id or a symbol, then read the top hits +--- + +Call the zerograph MCP server's `ask` tool with `question` set to what the user gave () + +Pass the words as typed. Set `seeds: 8` only when the user wrote `--seeds 8` or asked for a wider +search, and `bodies: true` only when they asked for full requirement text. Never set `rerank`: it +spends model tokens, and that is the user's call. + +Each top-level line of the answer is a seed, `path:line` first; a `└` line is a neighbour one hop +from the seed above it. + +1. Open the top two or three seeds at their `path:line`, a window around the line, not + the whole file. Skip a seed whose headline plainly answers something else. +2. Answer from what you opened, citing `path:line`. Nothing printed holds it: say so, and offer one + rephrase or a wider search, not a directory walk. +3. A literal string, an env var or a flag is a text search's question, not the graph's: use rg. diff --git a/agent/codex/skills/ask/agents/openai.yaml b/agent/codex/skills/ask/agents/openai.yaml new file mode 100644 index 00000000..0488f6dc --- /dev/null +++ b/agent/codex/skills/ask/agents/openai.yaml @@ -0,0 +1,3 @@ +# A command: run when asked for by name, as the Claude Code plugin's slash command is. +policy: + allow_implicit_invocation: false diff --git a/agent/codex/skills/changes/SKILL.md b/agent/codex/skills/changes/SKILL.md new file mode 100644 index 00000000..4c28ddf7 --- /dev/null +++ b/agent/codex/skills/changes/SKILL.md @@ -0,0 +1,18 @@ +--- +name: changes +description: What the current diff touches and who reaches it — run before a commit or a PR +--- + +Call the zerograph MCP server's `changes` tool for what the user gave ([base] [--depth N]) + +A first word that is not a flag is `base`, the revision to diff against (`main` reads the whole +branch); none leaves it at the uncommitted diff. `--depth N` sets `depth` (1 is the direct callers +alone; the default is 2). The answer is a risk word, the symbols the diff touches and the callers +that reach them. + +1. Lead with the risk word and the touched symbols. +2. For each affected caller outside the diff, open its `path:line` and say whether the change can + break it: a new parameter, a changed return, a renamed export. Those are the review's and the + test plan's real targets. +3. Name the affected callers no test covers, if any stand out. A caller list is the graph's view; a + dynamic call has no edge, so rg the changed names before calling a change contained. diff --git a/agent/codex/skills/changes/agents/openai.yaml b/agent/codex/skills/changes/agents/openai.yaml new file mode 100644 index 00000000..0488f6dc --- /dev/null +++ b/agent/codex/skills/changes/agents/openai.yaml @@ -0,0 +1,3 @@ +# A command: run when asked for by name, as the Claude Code plugin's slash command is. +policy: + allow_implicit_invocation: false diff --git a/agent/codex/skills/explain/SKILL.md b/agent/codex/skills/explain/SKILL.md new file mode 100644 index 00000000..fb476111 --- /dev/null +++ b/agent/codex/skills/explain/SKILL.md @@ -0,0 +1,14 @@ +--- +name: explain +description: One node and every edge attached to it — an id, a symbol or a label +--- + +Call the zerograph MCP server's `explain` tool with `node` set to what the user gave () + +It tries the argument as an exact id, then a symbol name, then a label, and prints the node's kind +and `path:line`, then each edge in or out with the place it is written. + +1. Open the node at its `path:line` and say what it is in a sentence. +2. Group the edges by kind and direction (what it cites, what cites it, what it calls) and open the + ones the user's question is about; do not list every edge back. +3. Not found: suggest `$zerograph:ask` with the same words, which also matches by meaning. diff --git a/agent/codex/skills/explain/agents/openai.yaml b/agent/codex/skills/explain/agents/openai.yaml new file mode 100644 index 00000000..0488f6dc --- /dev/null +++ b/agent/codex/skills/explain/agents/openai.yaml @@ -0,0 +1,3 @@ +# A command: run when asked for by name, as the Claude Code plugin's slash command is. +policy: + allow_implicit_invocation: false diff --git a/agent/codex/skills/impact/SKILL.md b/agent/codex/skills/impact/SKILL.md new file mode 100644 index 00000000..5dbd928d --- /dev/null +++ b/agent/codex/skills/impact/SKILL.md @@ -0,0 +1,17 @@ +--- +name: impact +description: Blast radius of a symbol before you change it — callers by depth, importing files and a risk line +--- + +Call the zerograph MCP server's `impact` tool for what the user gave ( [--depth N] [--down]) + +The first word is `symbol`. `--depth N` sets `depth` (1 is the "will break" list alone; the +default is 3), and `--down` sets `down: true`, which walks what the symbol calls instead of who +reaches it. + +1. Lead with the risk line and the depth-1 callers: those are the call sites a signature change + breaks. Open each at its `path:line` before saying how it uses the symbol. +2. A note naming other candidates means the name was ambiguous; say which one was taken. +3. An empty or short list is not proof that nothing uses it: a call through a callback, a chained + expression or a destructured method has no edge. Confirm with an rg for the name (files with + matches) before calling anything dead or safe to delete. diff --git a/agent/codex/skills/impact/agents/openai.yaml b/agent/codex/skills/impact/agents/openai.yaml new file mode 100644 index 00000000..0488f6dc --- /dev/null +++ b/agent/codex/skills/impact/agents/openai.yaml @@ -0,0 +1,3 @@ +# A command: run when asked for by name, as the Claude Code plugin's slash command is. +policy: + allow_implicit_invocation: false diff --git a/agent/codex/skills/refresh/SKILL.md b/agent/codex/skills/refresh/SKILL.md new file mode 100644 index 00000000..7666a870 --- /dev/null +++ b/agent/codex/skills/refresh/SKILL.md @@ -0,0 +1,15 @@ +--- +name: refresh +description: Bring the zerograph index in line with the tree — a first build where there is no store, else an update +--- + +Call the zerograph MCP server's `reindex` tool with no arguments. It starts `zerograph build` in +the background where the checkout has no store, else `zerograph update`, and returns at once; the +run takes the writer lock as the command line does, so readers keep answering from the store as it +stands. When it says one is already running, nothing new was started. + +Then call the zerograph MCP server's `status` tool once and report in one or two lines: what was +started, and how far the run has got. Do not poll it in a loop; `$zerograph:status` asks again. + +The readers (`ask`, `impact`, `trace`, `changes`, `explain`) already bring the store in line before +they answer, so this is for a first build, or for catching up before a long run of questions. diff --git a/agent/codex/skills/refresh/agents/openai.yaml b/agent/codex/skills/refresh/agents/openai.yaml new file mode 100644 index 00000000..0488f6dc --- /dev/null +++ b/agent/codex/skills/refresh/agents/openai.yaml @@ -0,0 +1,3 @@ +# A command: run when asked for by name, as the Claude Code plugin's slash command is. +policy: + allow_implicit_invocation: false diff --git a/agent/codex/skills/status/SKILL.md b/agent/codex/skills/status/SKILL.md new file mode 100644 index 00000000..a8e547b0 --- /dev/null +++ b/agent/codex/skills/status/SKILL.md @@ -0,0 +1,17 @@ +--- +name: status +description: zerograph's index state, refresh, serve and the machine's vector cache +--- + +Call the zerograph MCP server's `status` tool and the zerograph MCP server's `cache_status` tool, +both with no arguments, in one turn. + +Summarise the two reports in a few lines: index size and files behind the tree, the model and any +vectors owed, whether a refresh is running and how far it has got, whether `serve` answers, and the +cache's size against its cap. Then say what, if anything, wants doing: + +- no store, or files behind or vectors owed with no refresh running: `$zerograph:refresh`; +- warnings: quote them as printed; +- the cache at or over its cap: the user can run `zerograph cache prune` in a terminal. + +Everything in order: say so in one line. diff --git a/agent/codex/skills/status/agents/openai.yaml b/agent/codex/skills/status/agents/openai.yaml new file mode 100644 index 00000000..0488f6dc --- /dev/null +++ b/agent/codex/skills/status/agents/openai.yaml @@ -0,0 +1,3 @@ +# A command: run when asked for by name, as the Claude Code plugin's slash command is. +policy: + allow_implicit_invocation: false diff --git a/agent/codex/skills/trace/SKILL.md b/agent/codex/skills/trace/SKILL.md new file mode 100644 index 00000000..2a038fdb --- /dev/null +++ b/agent/codex/skills/trace/SKILL.md @@ -0,0 +1,16 @@ +--- +name: trace +description: Shortest chain of calls from one symbol to another +--- + +Call the zerograph MCP server's `trace` tool for what the user gave ( [--depth N]) + +The first word is `from`, the second `to`; `--depth N` sets `depth` (default 6). The answer is one +step per line, each at its `path:line`; a `Passes` step hands the next symbol over as a value +rather than calling it. + +1. Walk the chain by opening each step's `path:line`; describe what carries the call across, in + order. +2. No path within the depth is an answer, not an error. Say so, then offer a higher `depth`, the + reverse direction, or an rg for a link the graph cannot see (a callback, an event, a string + key). diff --git a/agent/codex/skills/trace/agents/openai.yaml b/agent/codex/skills/trace/agents/openai.yaml new file mode 100644 index 00000000..0488f6dc --- /dev/null +++ b/agent/codex/skills/trace/agents/openai.yaml @@ -0,0 +1,3 @@ +# A command: run when asked for by name, as the Claude Code plugin's slash command is. +policy: + allow_implicit_invocation: false diff --git a/agent/codex/skills/use-the-graph/SKILL.md b/agent/codex/skills/use-the-graph/SKILL.md new file mode 100644 index 00000000..fdab3f94 --- /dev/null +++ b/agent/codex/skills/use-the-graph/SKILL.md @@ -0,0 +1,29 @@ +--- +name: use-the-graph +description: Use when you need who calls a symbol, what a change would break, what an id like FR-PAY-22 is, or where a concept lives in a repository with a zerograph index — before grepping or reading files to orient yourself. +--- + +# zerograph before grep + +The plugin's MCP server answers from the repository's zerograph index: code symbols, document ids +and the edges between them, at no model tokens. Its tools are named after the commands below. + +| The question | Tool | Not | +| --- | --- | --- | +| what is `FR-PAY-22`, `BE-M10/T05`, `asGrosze` | `ask` with the id or symbol | rg for the id | +| where is this concept written | `ask` with four words | reading directories | +| who calls `X` | `impact` with `symbol: X`, `depth: 1` | rg for the name | +| what breaks if I change `Y` | `impact` with `symbol: Y` | guessing from memory | +| how does `A` reach `B` | `trace` with `from: A`, `to: B` | following imports by hand | +| what does my diff touch | `changes` (`base: main` for the branch) | re-reading the diff | +| a literal string, an env var, a flag | rg | the graph: it does not index literals | + +- Every answer line starts with `path:line`. Open those; do not re-derive them. +- "Nothing uses this" from `impact` is the graph's view: a callback, a chained call or a destructured + method has no edge. Pair it with an rg for the name before deleting anything. +- The readers bring the index in line with the tree before they answer; no build step belongs in + your loop. `status` says whether the index is behind and whether a refresh runs. +- A thin `ask`: rephrase once or set `seeds: 8`. Still nothing: say the repository does not show it. + +The user can run the same through `$zerograph:ask`, `$zerograph:impact`, `$zerograph:trace`, +`$zerograph:changes`, `$zerograph:explain`, `$zerograph:status` and `$zerograph:refresh`. diff --git a/agent/codex/skills/use-the-graph/agents/openai.yaml b/agent/codex/skills/use-the-graph/agents/openai.yaml new file mode 100644 index 00000000..fcf9655e --- /dev/null +++ b/agent/codex/skills/use-the-graph/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: true diff --git a/agent/skill/repo-query.md b/agent/legacy/repo-query.md similarity index 100% rename from agent/skill/repo-query.md rename to agent/legacy/repo-query.md diff --git a/agent/subagent.md b/agent/legacy/repo-scout.md similarity index 100% rename from agent/subagent.md rename to agent/legacy/repo-scout.md diff --git a/agent/rule.txt b/agent/legacy/rule.txt similarity index 100% rename from agent/rule.txt rename to agent/legacy/rule.txt diff --git a/agent/skill/SKILL.md b/agent/legacy/skill.md similarity index 100% rename from agent/skill/SKILL.md rename to agent/legacy/skill.md diff --git a/agent/legacy/zerograph-hook.mjs b/agent/legacy/zerograph-hook.mjs new file mode 100644 index 00000000..7e9449f1 --- /dev/null +++ b/agent/legacy/zerograph-hook.mjs @@ -0,0 +1,459 @@ +#!/usr/bin/env node +/** + * The one zerograph hook, dispatched on the event the harness names in its payload. + * + * SessionStart → a repository brief, so the graph's state and its five commands are in context + * at start and again after a compaction, without a 7k-token CLAUDE.md. + * PreToolUse → a Bash/Grep/Glob search gets the graph's top three lines beside it, under gates + * written to keep an injection out rather than to put one in: an injection is + * paid once as output and then on every later turn as input. Read is not a search. + * PostToolUse → an Edit/Write to TypeScript gets one risk line when the touched symbol's + * callers cross MEDIUM. The one call here that may refresh the store. + * SubagentStart → every subagent gets the rule. Prose asking the parent to pass it on reached 19 + * of 146 subagent spawns in the recorded sessions, and 0 of the 140 that did not + * carry it ever ran the tool. + * + * Contract: exit 0 always, stdout only when there is something to say, stderr only under + * ZEROGRAPH_HOOK_DEBUG. Every call is --no-dense; every call but `changes` is --stale. + */ +import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, lstatSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { join, dirname, relative, delimiter, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; + +const MAX_INJECTIONS = 12; // 12 × ~107 tokens: a third of the CLAUDE.md sections this replaces +const SEEDS = 3; +const LINE_CUT = 160; +const REMINDER_EVERY = 40; +const TIMEOUT_MS = 5000; +/** What the autostarted `serve` is told: leave after half an hour unasked, forget the model after five minutes. */ +const SERVE_IDLE = 1800; +const SERVE_IDLE_MODEL = 300; // measured on the 33.5k-row copy: 0.08 s resident, 0.77 s after a drop + +/** + * How this repository runs the binary, in every hint the hook prints. `install-agent --command` + * rewrites this one line with the command JSON-encoded, so nothing in it reaches the script as code. + * A workspace that installs zerograph as a dependency has no bare `zerograph` on PATH, and a hint + * naming a program the shell cannot find is one the agent runs, fails, and pays for twice. + */ +const COMMAND = "zerograph"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** + * The rule handed to every subagent. Read from `rule.txt` beside this file when the installer put + * one there, and otherwise the copy below — the two are compared by a test, so a hook installed + * without its text still says the same thing. + */ +const RULE_FALLBACK = `This repository has a zerograph index. Ask it before grepping for a concept: \`${COMMAND} ask \` +answers by meaning, \`${COMMAND} impact \` names who calls a symbol, \`${COMMAND} changes\` maps +your diff onto the callers it reaches. Read only the \`path:line\` those answers print, and cite one +for every claim; when nothing printed holds it, say so. +Grep is still the right tool for a literal — an env var name, a string in a test, a config key. +`; + +const BUILD_NOTICE = + `zerograph: this repository has no index yet. \`${COMMAND} build\` writes one in a couple of minutes ` + + 'and answers by meaning afterwards; until then this notice is all it can say.'; + +function rule() { + const beside = join(HERE, 'rule.txt'); + try { return existsSync(beside) ? readFileSync(beside, 'utf8') : RULE_FALLBACK; } catch { return RULE_FALLBACK; } +} + +// The name a setting had before the rename is read while the new one is unset, so a shell profile +// written then keeps working. +function env(suffix) { + return process.env[`ZEROGRAPH_${suffix}`] ?? process.env[`REPOGRAPH_${suffix}`]; +} + +function debug(...args) { + if (env('HOOK_DEBUG')) console.error('[zerograph-hook]', ...args); +} + +/** + * Whether a `zerograph` the machine's own PATH resolves exists, so that it can win over the + * repository's. A relative entry (`.`, `node_modules/.bin`) resolves against the repository the + * hook runs in, so it is the repository's file and not the machine's. + */ +function onPath() { + const names = process.platform === 'win32' ? ['zerograph.exe', 'zerograph.cmd'] : ['zerograph']; + return (process.env.PATH || '').split(delimiter).some((d) => d && isAbsolute(d) && names.some((n) => existsSync(join(d, n)))); +} + +/** + * `ZEROGRAPH_BIN`, then PATH, then the package's own bin, and nothing else: a hook that installed + * things would not be a hook. The repository's `node_modules/.bin` comes after PATH because it is + * the repository's own file, and a cloned repository that ships one would otherwise run its + * binary the moment a session starts in it. npm, pnpm and yarn all write the same trio under `node_modules/.bin` — an + * extensionless shell script, a `.cmd` and a `.ps1` — and on Windows the `.cmd` is the one that + * starts. The extensionless shim is worse than nothing there: returning it spawns a file + * `CreateProcess` cannot read, where falling through finds whatever `zerograph.exe` an installer + * put on PATH. + */ +function binary(root) { + const named = env('BIN'); + if (named) return named; + if (onPath()) return 'zerograph'; + const local = join(root, 'node_modules', '.bin', 'zerograph'); + const shim = process.platform === 'win32' ? `${local}.cmd` : local; + return existsSync(shim) ? shim : 'zerograph'; +} + +/** + * How to hand one command to `spawn`. A `.cmd` or `.bat` is read by the command interpreter rather + * than by `CreateProcess`, and Node has refused to spawn one without a shell since CVE-2024-27980. + * The shell joins the file and its arguments into one command line without quoting them, so they + * are quoted here — and nothing that reaches this can close a quote: a Windows path may not contain + * one, and the words come from `queryWords`, which keeps letters, digits, `_` and `-` and nothing + * else. Everything that is not a batch file is spawned as it always was. + * + * Two things the quoting does not buy, both only on that batch path. `cmd` expands `%NAME%` inside + * quotes and the command line has no escape for it — only a batch file's own `%%` — so a repository + * under a directory that pairs two `%` around an environment variable's name is asked about by the + * expanded path. And `spawnSync`'s timeout kills the interpreter rather than its child, so a wedged + * `zerograph` outlives the five seconds this hook waits. Both need a local install on Windows to + * reach at all; neither is worth the shell it would take to avoid, and neither is silent about it. + */ +function spawnable(bin, args) { + // Windows and a batch file, both: the extension alone would open the shell on a POSIX machine + // whose `ZEROGRAPH_BIN` happens to end in `.cmd`, where a repository path may contain a quote + // and the sentence above stops being true. + if (process.platform !== 'win32' || !/\.(cmd|bat)$/i.test(bin)) return { file: bin, args, opts: {} }; + return { file: `"${bin}"`, args: args.map((a) => `"${a}"`), opts: { shell: true } }; +} + +function run(root, args) { + const { file, args: argv, opts } = spawnable(binary(root), ['--repo', root, '--no-dense', ...args]); + const r = spawnSync(file, argv, { + encoding: 'utf8', timeout: TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, ...opts, + }); + if (r.status !== 0) debug('exit', r.status, args.join(' '), (r.stderr || '').slice(0, 200)); + // Identifiers and headings in the answer are the repository's text, and it is about to enter an + // agent's context and possibly a terminal log; control characters other than tab and newline carry nothing. + return r.status === 0 ? r.stdout.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '') : ''; +} + +// The directory the binary reads: `.zerograph` whenever anything stands at that name, else a +// `.repograph` no write has moved yet. A manifest under the other name is no index it answers from. +function built(root) { + const standing = (p) => { try { lstatSync(p); return true; } catch { return false; } }; + const dir = standing(join(root, '.zerograph')) ? '.zerograph' : '.repograph'; + return existsSync(join(root, dir, 'manifest.json')); +} + +/** + * The nearest directory at or above `start` that carries an index. The harness's `cwd` follows a + * `cd`, and a subdirectory has no `.zerograph/` of its own; the walk stops at a `.git`, file or + * directory, so a worktree nested inside a checkout never borrows the checkout's index. + */ +function indexRoot(start) { + for (let d = start; ; d = dirname(d)) { + if (built(d)) return d; + if (existsSync(join(d, '.git')) || dirname(d) === d) return null; + } +} + +/** + * Words worth asking, or null. Four letters is the floor because a shorter token is a flag, an + * extension or a variable name far more often than it is a concept; six words is the ceiling + * because the seventh never changed an answer and every one costs prompt. + */ +export function queryWords(pattern) { + if (!pattern) return null; + if (/\//.test(pattern) && !/\s/.test(pattern)) return null; // a path, not a question + // A class escape (`\b`, `\w`, `\s`) goes whole: dropping only the backslash glues its letter to + // the word beside it, and `\bwithTenant\b` would ask about `bwithTenant`. + const bare = pattern.replace(/\\[A-Za-z]/g, ' ').replace(/[\\^$.*+?()[\]{}|]/g, ' ').trim(); + if (!bare) return null; + // A lone identifier or a document id is sent whole: the exact stage answers it in 60 ms and + // 20–40 tokens, which is the best case this hook has. A milestone or task id (`BE-M17`, + // `BE-M01-T03`) ends in a capital and digits, and has no four-letter word to fall back on. + if (/^[A-Za-z][A-Za-z0-9_]*$/.test(bare) && bare.length >= 4) return [bare]; + if (/^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-[A-Z]?\d{1,4}$/.test(bare)) return [bare]; + // A leading dash is dropped: `--rerank` sent as a word is parsed by `ask` as its paid flag. + const words = bare.split(/[^\p{L}\p{N}_-]+/u).map((w) => w.replace(/^-+/, '')) + .filter((w) => /\p{L}{4,}/u.test(w)).slice(0, 6); + return words.length ? words : null; +} + +/** + * A shell line cut into its stages, each marked by whether a pipe feeds it. Quoted strings and + * backslash escapes are kept whole, so the `|` in `grep -E "a|b"` or `grep a\|b` is a pattern and + * not a pipe. + */ +function stages(command) { + const out = [{ piped: false, text: '' }]; + for (const [part] of command.matchAll(/(?:"[^"]*"|'[^']*'|\\[\s\S]|[^"'\\|;&\n])+|\|&|\|\||&&|[|;&\n]/g)) { + const last = out[out.length - 1]; + // A newline after an operator continues the line: `git log |\n grep x` is still fed by the pipe. + if (part === '\n' && !last.text.trim()) continue; + if (/^(?:\|&|\|\||&&|[|;&\n])$/.test(part)) out.push({ piped: part === '|' || part === '|&', text: '' }); + // A backslash-newline continues the line and reads as a space; kept, its backslash is taken for + // the pattern of an `rg` whose arguments start on the next line. + else last.text += part.replace(/\\\r?\n/g, ' '); + } + return out; +} + +/** + * The flags whose value is the next word, one list per tool, read off `rg --help` and + * `grep --help`: a shared list is wrong for both, because the same letter means different things + * under each — `-r` is a replacement under `rg` and recursion under `grep`, `-E` an encoding and + * extended syntax, `-T` a type to exclude and a leading tab. A flag missing here hands its value to + * the hook as the pattern, and `--exclude-dir node_modules` spent an injection on `node_modules`. + * A value attached with `=` is one token that starts with `-` and needs no entry; `--color` under + * `grep` only takes its value that way, so it is absent from that list on purpose. + */ +const VALUE_FLAGS = { + rg: /^-(A|B|C|E|M|T|d|f|g|j|m|r|t|-after-context|-before-context|-context|-color|-colors|-context-separator|-dfa-size-limit|-encoding|-engine|-field-context-separator|-field-match-separator|-file|-generate|-glob|-hostname-bin|-hyperlink-format|-iglob|-ignore-file|-max-columns|-max-count|-max-depth|-max-filesize|-path-separator|-pre|-pre-glob|-regex-size-limit|-replace|-sort|-sortr|-threads|-type|-type-add|-type-clear|-type-not)$/, + grep: /^-(A|B|C|D|d|f|m|-after-context|-before-context|-context|-devices|-directories|-file|-max-count|-exclude|-exclude-dir|-exclude-from|-include|-include-dir|-label|-binary-files|-group-separator)$/, +}; + +/** + * What a tool call searches for. Bash is the surface that matters: a pass over 38 recorded sessions + * found Grep 0, Glob 0 and Bash 2,727 calls, 933 of them running `rg` or `grep` — the harness tells + * the model to search through Bash, so a hook that only watched Grep watched nothing. + */ +export function searchPattern(tool, input) { + if (!input) return null; + if (tool === 'Grep') return input.pattern || null; + if (tool === 'Glob') { + const segments = String(input.pattern || '').split(/[/*]+/) + .map((s) => s.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')) + .filter((s) => /\p{L}{4,}/u.test(s)); + return segments.sort((a, b) => b.length - a.length)[0] || null; + } + if (tool !== 'Bash') return null; + const command = String(input.command || ''); + if (!/\b(rg|grep)\b/.test(command)) return null; + for (const stage of stages(command)) { + // A stage a pipe feeds searches what the stage before it printed, not the repository: + // `gh auth status 2>&1 | grep -E "Logged in|Active account"` came back with three graph lines + // about accounts. `| xargs grep` is skipped too — a missed injection costs nothing, and a wrong + // one is paid on every later turn. + if (stage.piped) continue; + // Quoted first: a phrase is one argument, and splitting on whitespace would send the graph the + // first word of `rg -n "cancellation policy"` and dedup every later phrase starting the same way. + const tokens = stage.text.match(/"[^"]*"|'[^']*'|\S+/g) || []; + const at = tokens.findIndex((t) => /^(rg|grep)$/.test(t) || /\/(rg|grep)$/.test(t)); + if (at < 0) continue; + const takesValue = VALUE_FLAGS[tokens[at].replace(/.*\//, '')]; + for (let i = at + 1; i < tokens.length; i += 1) { + const t = tokens[i]; + // `-e` and `--regexp` carry the pattern itself; skipping their value would ask about the path after it. + if (/^-(e|-regexp)$/.test(t)) return (tokens[i + 1] || '').replace(/^['"]|['"]$/g, '') || null; + if (takesValue.test(t)) { i += 1; continue; } + if (t.startsWith('-')) continue; + return t.replace(/^['"]|['"]$/g, ''); + } + } + return null; +} + +/** + * The directory the hook keeps its markers in: one per user under the temp directory, made 0700 + * and checked to be ours on every use. A name anyone can predict in a shared `/tmp` is a name + * another user can create first, or point at a file of yours; null here means no state, which + * costs a repeated answer and nothing else. Windows' temp directory is already per-user. + */ +function stateRoot() { + const uid = process.getuid?.(); + const root = join(tmpdir(), uid === undefined ? 'zerograph-hook' : `zerograph-hook-${uid}`); + if (uid === undefined) return root; + try { + mkdirSync(root, { recursive: true, mode: 0o700 }); + const st = lstatSync(root); + return st.isDirectory() && st.uid === uid && (st.mode & 0o077) === 0 ? root : null; + } catch { return null; } +} + +/** + * The per-session state directory. The key is built from ids the harness supplies, so it is + * reduced to word characters before it becomes a path segment: an id carrying a slash or a `..` + * would otherwise put this hook's empty marker files anywhere the process can write. + */ +function stateDir(sessionKey) { + const root = stateRoot(); + return root && join(root, sessionKey.replace(/\W+/g, '-')); +} + +/** True the first time this session asks about this key; an unwritable tmp answers once and never dedups. */ +function once(sessionKey, kind, key) { + const dir = stateDir(sessionKey); + if (!dir) return true; + const file = join(dir, `${kind}-${createHash('sha1').update(key).digest('hex').slice(0, 16)}`); + if (existsSync(file)) return false; + try { mkdirSync(dir, { recursive: true }); writeFileSync(file, ''); } catch { /* answer this one */ } + return true; +} + +function count(sessionKey, name) { + const dir = stateDir(sessionKey); + if (!dir) return 0; + try { return Number(readFileSync(join(dir, `count-${name}`), 'utf8')) || 0; } catch { return 0; } +} + +function bump(sessionKey, name) { + const n = count(sessionKey, name) + 1; + const dir = stateDir(sessionKey); + if (!dir) return n; + try { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, `count-${name}`), String(n)); } catch { /* no state, no cadence */ } + return n; +} + +function isWatchedCall(tool, input) { + if (tool === 'Bash') return /\b(rg|grep)\b/.test(String((input || {}).command || '')) || /\.(ts|tsx|md)\b/.test(String((input || {}).command || '')); + return tool === 'Grep' || tool === 'Glob'; +} + +/** + * On by default because the harness said so, under a rule committed before the number was read: + * interception ships when its tokens per hit are lower than reminder-only's. Sonnet, twice over the + * twelve tasks — 137,991 and 129,243 tokens per hit with it, 194,011 and 169,036 without, the two + * ranges not overlapping — and 11/11 hits against 11/9. `ZEROGRAPH_HOOK_INTERCEPT=0` turns it off. + * `docs/bench/2026-09-09-agent-surface-results.md` has all four runs and what they are worth. + */ +function intercept(payload, root, sessionKey) { + if (env('HOOK_INTERCEPT') === '0') return null; + const input = payload.tool_input || {}; + // A search the agent runs *with* zerograph is not one to answer beside. + if (payload.tool_name === 'Bash' && /\b(?:zerograph|repograph)\b/.test(String(input.command || ''))) return null; + if (/\.(?:zerograph|repograph)\//.test(JSON.stringify(input))) return null; + const words = queryWords(searchPattern(payload.tool_name, input)); + if (!words) return null; + if (!once(sessionKey, 'ask', words.join(' ').toLowerCase())) return null; + if (count(sessionKey, 'injections') >= MAX_INJECTIONS) return null; + const lines = run(root, ['ask', '--stale', '--seeds', String(SEEDS), ...words]) + .split('\n').filter((l) => l && !l.startsWith(' ')).map((l) => l.slice(0, LINE_CUT)); + if (!lines.length) return null; + bump(sessionKey, 'injections'); + return [`${COMMAND} ask ${words.join(' ')} →`, ...lines, `(read only these path:line; \`${COMMAND} ask\` for more)`].join('\n'); +} + +function reminder(payload, sessionKey) { + if (!isWatchedCall(payload.tool_name, payload.tool_input)) return null; + const n = bump(sessionKey, 'watched'); + return n % REMINDER_EVERY === 0 + ? `zerograph: this repository has an index — \`${COMMAND} ask \` answers by meaning, no tokens.` + : null; +} + +function riskLine(payload, root, sessionKey) { + const file = String((payload.tool_input || {}).file_path || ''); + if (!/\.tsx?$/.test(file)) return null; + let r; + try { r = JSON.parse(run(root, ['changes', '--depth', '1', '--json'])); } catch { return null; } + if (!r || !['MEDIUM', 'HIGH', 'CRITICAL'].includes(r.risk)) return null; + // `changes` covers the whole working diff: keep only what the edited file contributes, or an edit to + // a leaf file is told about the callers of symbols some other file in the diff touched. + const rel = relative(root, file).split('\\').join('/'); + const name = (id) => String(id || '').split('::').pop(); + const touched = (r.touched || []).filter((t) => t.indexed !== false); + const mine = touched.filter((t) => String(t.at || '').startsWith(`${rel}:`)); + // A caller that imports through a barrel is reached by the barrel's alias, `sym:::`, + // so a symbol is matched by name as well — unless another file in the diff touched the same name. + const theirs = new Set(touched.filter((t) => !mine.includes(t)).map((t) => name(t.id))); + const names = mine.map((t) => name(t.id)).filter((n) => !theirs.has(n)); + const direct = (r.affected || []).filter((d) => d.depth === 1 && (String(d.via || '').startsWith(`sym:${rel}::`) + || names.some((n) => name(d.via) === n || name(d.via).startsWith(`${n}.`)))); + if (!direct.length) return null; + const files = new Set(direct.map((d) => String(d.at || '').split(':')[0])).size; + // `r.risk` scores the whole diff; the level is this file's own direct callers, on the thresholds + // `impact::risk` prints beside its counts. + const n = direct.length; + const risk = n >= 30 || files >= 25 ? 'CRITICAL' : n >= 15 || files >= 10 ? 'HIGH' : n >= 5 || files >= 3 ? 'MEDIUM' : null; + if (!risk) return null; + if (!once(sessionKey, 'risk', `${file}:${risk}`)) return null; + const called = [...new Set(direct.map((d) => name(d.via)))].slice(0, 3).join(', '); + return `zerograph changes: risk ${risk} — ${n} direct callers of ${called} in ${files} files; \`${COMMAND} changes --depth 1\` lists them.`; +} + +function brief(root) { + const text = run(root, ['prime']); + return text ? text.trimEnd() : null; +} + +/** + * Starts a resident `serve` for this repository, detached, and never waits on it. No probe first: + * a second `serve` refuses to bind while one answers and exits by itself, so the start *is* the + * check. What it buys is the difference between a gate that fires and one that times out — a fused + * ask is ~0.4 s against a cold process and ~80 ms through the socket, inside a 5 s hook budget on a + * machine that is also running a build. `--idle-model` is what keeps that cheap between questions. + */ +function autostartServe(root) { + if (env('HOOK_SERVE') === '0') return; + try { + const { file, args, opts } = spawnable(binary(root), + ['--repo', root, 'serve', '--idle', String(SERVE_IDLE), '--idle-model', String(SERVE_IDLE_MODEL)]); + const child = spawn(file, args, { detached: true, stdio: 'ignore', windowsHide: true, ...opts }); + // A spawn that fails reports it as an event, not as a throw, and an unheard `error` event ends + // this process — which would turn "no binary on PATH" into a hook that dies on stderr at every + // session start. The listener is what keeps the contract: exit 0, and say nothing. + child.on('error', (e) => debug('serve', e.message)); + child.unref(); + } catch (e) { debug('serve', e.message); } +} + +const handlers = { + SessionStart: (p, root) => { + if (!built(root)) return null; + autostartServe(root); + return brief(root); + }, + SubagentStart: (p, root) => (built(root) ? rule() : null), + PreToolUse: (p, root, key) => { + if (p.tool_name === 'Agent') { + if (env('HOOK_AGENT_INPUT') !== '1') return null; + const prompt = String((p.tool_input || {}).prompt || ''); + return { updatedInput: { ...(p.tool_input || {}), prompt: `${prompt}\n\n${rule()}` } }; + } + if (!built(root)) { + return isWatchedCall(p.tool_name, p.tool_input) && once(key, 'build', 'once') ? BUILD_NOTICE : null; + } + return intercept(p, root, key) ?? reminder(p, key); + }, + PostToolUse: (p, root, key) => (built(root) ? riskLine(p, root, key) : null), +}; + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +async function main() { + let payload; + try { payload = JSON.parse(await readStdin()); } catch { return; } + const event = payload.hook_event_name; + const handler = handlers[event]; + if (!handler) return; + // The harness's cwd follows a `cd` into a subdirectory, which has no `.zerograph/` of its own. + const root = (payload.cwd && indexRoot(payload.cwd)) || payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + // The subagent's own id is in the key: a subagent shares its parent's session_id, so a counter + // keyed by session alone finds the count already past zero on a subagent's first call and says + // nothing — which is a second reason 140 unprimed subagents went on grepping. + const sessionKey = `${payload.session_id || 'nosession'}-${payload.agent_id || 'main'}`; + let out; + try { out = handler(payload, root, sessionKey); } catch (e) { debug('handler', e); return; } + if (!out) return; + const body = typeof out === 'string' + ? { hookEventName: event, additionalContext: out } + : { hookEventName: event, ...out }; + // Written without process.exit: on Windows the pipe may still be draining when exit would cut it. + process.stdout.write(JSON.stringify({ hookSpecificOutput: body })); +} + +// Importable for the tests, which exercise the pure parts directly; only the real run reads stdin. +// `import.meta.url` is the resolved path and argv[1] keeps a symlink in the project path, so both are +// resolved: compared as given, a project reached through a link ran the hook and it said nothing. +function invokedDirectly() { + try { return Boolean(process.argv[1]) && fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); } catch { return false; } +} +if (invokedDirectly()) { + main(); +} + +export { rule, RULE_FALLBACK, COMMAND, binary, intercept, riskLine, handlers, MAX_INJECTIONS }; diff --git a/docs/install.md b/docs/install.md index 968cfaf4..017da42a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,9 +6,10 @@ npm install -g zerograph # or globally; pnpm add -g zerograph works th cargo install --path . # from source; Rust 1.98, pinned in rust-toolchain.toml ``` -The session hooks `install-agent` writes run the `zerograph` on `PATH` and never one inside the -checkout, so a global install is the simplest route. With only a project-local install (`pnpm add --D`), set `ZEROGRAPH_BIN` to that binary's absolute path, written out +The plugins `install-agent --claude` and `--codex` install run the `zerograph` on `PATH` and never +one inside the checkout, so a global install is the simplest route. With only a project-local +install (`pnpm add -D`), name that binary's absolute path: in the Claude Code plugin's `binary` +option (`/config`), or in `ZEROGRAPH_BIN`, written out (`export ZEROGRAPH_BIN=/home/me/work/app/node_modules/.bin/zerograph`). A relative path is ignored, and `$PWD` in a shell profile names whichever checkout a shell opens in, so a cloned repository would supply the binary again. See [the session hook](keeping-fresh.md#the-session-hook). @@ -50,15 +51,26 @@ npmjs.com links the workflow run that built it, and `npm audit signatures` check ## Upgrading -Re-run `zerograph install-agent` with the flags you used before. It replaces its own instructions -stanza and settings entries rather than adding second ones, and takes the -`post-merge`/`post-checkout` block an earlier release wrote out of the git hooks, deleting a hook -that held nothing else. - -Since 0.6.4 the skill is `zerograph`, in `.claude/skills/zerograph/` and -`.codex/skills/zerograph/`, with the release that wrote it as `metadata.version`. The install removes -the `repo-query` directory beside it when it holds only the `SKILL.md` 0.6.0–0.6.3 wrote; an edited -one, a link or one with files of its own is left and named. +Re-run `zerograph install-agent` with the flags you used before. For the editors it replaces its +own instructions stanza rather than adding a second one. For Claude Code and Codex it installs the +plugin, then takes out what releases before 0.7.0 wrote, which the plugin now does: + +- `--claude`: the `zerograph` server registered for the checkout and its worktrees in + `~/.claude.json`, the hook entries in `.claude/settings.json` (the file goes when nothing else is + left in it), `.claude/hooks/zerograph-hook.mjs` and its `rule.txt`, the `zerograph` and + `repo-query` skills, the `repo-scout` subagent, and the zerograph stanza in `CLAUDE.md`. +- `--codex`: the zerograph hook entries in `~/.codex/hooks.json` (or under `$CODEX_HOME`), + `.codex/hooks/zerograph-hook.mjs`, the `.codex/skills/zerograph/` and `repo-query` skills, and + the stanza in `AGENTS.md` unless `--opencode` still reads it there. + +A hook script goes only after the entries that run it are gone. A hook script, skill or subagent +somebody edited, a link, or a directory with files of its own is left and named. A file git tracks +is left too, and listed in one line: it is the team's, and serves every teammate who has no plugin +yet. Once all of them have it, `zerograph install-agent --claude --retire-repo` (or `--codex +--retire-repo`) takes those out as well, for a commit of their own. Both take the `post-merge`/`post-checkout` block an earlier release wrote out of the git +hooks, deleting a hook that held nothing else. The install runs only once the plugin installed: when +the `claude` or `codex` CLI is missing or a step fails, it prints the commands to run by hand and +leaves the older install in place. @@ -99,13 +111,19 @@ directory above holding a `.zerograph/`, else the project directory. Claude Code by name from its own `PATH`, which the plugin cannot pin; a checkout whose directory is on that `PATH`, or on Windows the directory the server starts in, could put its own `node` there. -**With `install-agent --claude`.** The plugin's server replaces the one `install-agent --claude` -registers. Claude Code connects a plugin's server beside a local-scope one of the same name when -their commands differ, so the two would both run, and the model would see every tool twice, as -`mcp__zerograph__*` and as `mcp__plugin_zerograph_zerograph__*`. With the plugin installed, remove -the local one in each checkout that has it: `claude mcp remove zerograph -s local`. The session -hook and the skill `install-agent --claude` wrote can stay; only the server doubles. A later -`install-agent --claude` registers the server again, so remove it again after one. +**With `install-agent --claude`.** It installs this plugin through the `claude` CLI (`claude plugin +marketplace add devmaxxx/zerograph`, `claude plugin marketplace update zerograph`, `claude plugin +install zerograph@zerograph`, then `claude plugin update zerograph@zerograph`, so a re-run after +upgrading the binary moves the plugin to the same release) and removes +the server, hook, skill and stanza an older `install-agent --claude` wrote (see +[Upgrading](#upgrading)), so the model never sees the tools twice, as `mcp__zerograph__*` and as +`mcp__plugin_zerograph_zerograph__*`. + +The plugin's hooks are the [session hook](keeping-fresh.md#the-session-hook): at session start the +index brief, for each subagent the rule to ask the graph first, before a concept grep the graph's +answer, and after an edit to a TypeScript file the +callers outside it that the edit can break. A checkout hears from them only once it has a +`zerograph.toml` or an index of its own, or is a worktree of a main checkout that has one. It asks the `zerograph` on `PATH` by its absolute path, and never a binary inside the project: a cloned repository that ships `node_modules/.bin/zerograph` or `target/*/zerograph` does not get it @@ -131,8 +149,39 @@ killed without removing its socket reads as answering until the next full report The marketplace pins the mod to a release tag and the mod carries that release's version, so it moves with zerograph: `claude plugin update zerograph` then `/reload-plugins` takes the mod of the latest release, and nothing merged to `main` reaches an install before its tag. A version bump -changes `Cargo.toml`, `agent/claude-code/.claude-plugin/plugin.json` and the `ref` in -`.claude-plugin/marketplace.json` together; CI fails when they disagree. +changes `Cargo.toml`, `agent/claude-code/.claude-plugin/plugin.json`, +`agent/codex/.codex-plugin/plugin.json` and the `ref` in `.claude-plugin/marketplace.json` +together, and the `--ref` in the Codex command below; CI fails when the first four disagree. A fix +to `agent/claude-code/scripts/` reaches the Codex plugin's copies through +`scripts/sync-plugin-scripts.sh`, and CI fails until it does. + +## Codex plugin + +`agent/codex/` is the same integration as a Codex plugin, listed by `.agents/plugins/marketplace.json`: + +```bash +codex plugin marketplace add devmaxxx/zerograph --ref v0.7.0 +codex plugin add zerograph@zerograph +``` + +`zerograph install-agent --codex` runs those two and the clean-up in [Upgrading](#upgrading). Codex +refuses to re-add a marketplace under another ref, so after a binary upgrade the run removes the +`zerograph` marketplace and adds it at the new tag; the plugin stays enabled. The +plugin starts the MCP server (`scripts/mcp.mjs`, the Claude Code plugin's, found under +`$CODEX_HOME/plugins/cache/zerograph/zerograph//`), runs the session hook at session start, +for each subagent and after an `apply_patch`, and carries the commands as skills: `$zerograph:ask`, +`$zerograph:impact`, `$zerograph:trace`, `$zerograph:changes`, `$zerograph:explain`, +`$zerograph:status` and `$zerograph:refresh` run only when named, and `use-the-graph` is the one the +model picks up by itself. Codex asks once before it trusts a plugin's hooks. Codex hands the server +its own short list (`PATH`, `HOME`, `LANG`, `TMPDIR` and a few more) plus what `.mcp.json` names: +`CODEX_HOME`, the `ZEROGRAPH_*` settings, the model cache and Hugging Face variables, the +`HTTP(S)_PROXY`/`NO_PROXY`/`ALL_PROXY` pair in both cases, the certificate paths and `LC_*`. A +project-local binary goes in `ZEROGRAPH_BIN`. Codex has no hook before a shell command, so nothing +answers a grep before it runs. + +On Windows the hooks run their `commandWindows` form, which reads `PLUGIN_ROOT` inside `node` +instead of through a POSIX shell's `${PLUGIN_ROOT}`; it is checked under a POSIX shell in CI but has +not run under Codex on a Windows machine yet. ## Windows notes diff --git a/docs/keeping-fresh.md b/docs/keeping-fresh.md index 3fcd063c..188d82c1 100644 --- a/docs/keeping-fresh.md +++ b/docs/keeping-fresh.md @@ -128,17 +128,21 @@ worktree) and of a block a hook manager rewrote away. A re-run of `install-agent `# zerograph:begin` … `# zerograph:end` block an earlier release wrote into `post-merge` and `post-checkout`, leaves the rest of each hook as it is, and deletes a hook that held nothing else. -The agent's session hook does that job per checkout instead. At SessionStart: +The agent's session hook does that job per checkout instead. It ships in the +[Claude Code plugin](install.md#claude-code-plugin) and the [Codex plugin](install.md#codex-plugin) +as `scripts/hook.mjs`. At SessionStart: - **No index in this checkout** — a new worktree, typically — and the checkout asked for one (it - holds the hook `install-agent` writes, or a `zerograph.toml`, or its main worktree does): it starts `update --detach` at the - checkout's root and says in one line that a build is running. A checkout that asked for nothing - stays silent, which matters for Codex, whose one hook runs in every repository. + holds a `zerograph.toml` or the hook an earlier `install-agent` wrote, or its main worktree does + or has an index): it starts `update --detach` at the checkout's root and says in one line that a + build is running. A checkout that asked for nothing stays silent, which matters because a + plugin's hook runs in every repository a session opens. - **An index** — it starts the resident `serve`, which catches the store up as it starts; with `ZEROGRAPH_HOOK_SERVE=0` it starts `update --detach` instead, which writes nothing when nothing moved. -The hook runs the binary `ZEROGRAPH_BIN` names, else the `zerograph` on `PATH`, and never one +The hook runs the binary `ZEROGRAPH_BIN` names, else the Claude Code plugin's `binary` option, +else the `zerograph` on `PATH`, and never one inside the checkout: a cloned repository that commits a `zerograph.toml` and an executable `node_modules/.bin/zerograph` would otherwise have that file started as soon as a session opened in it. A project-local install (`pnpm add -D zerograph`) puts no `zerograph` on `PATH`, so set diff --git a/docs/mcp.md b/docs/mcp.md index e5ae4272..9f017599 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,34 +2,26 @@ ```bash zerograph mcp # a stdio MCP server (protocol 2025-06-18) over the current repository -claude mcp add zerograph -- zerograph --repo /path/to/checkout mcp # what `install-agent --claude` runs, per checkout +zerograph --repo /path/to/checkout mcp ``` The [Claude Code plugin](install.md#claude-code-plugin) starts this server itself, once per -session, as `mcp__plugin_zerograph_zerograph__*`, and its slash commands call these tools. With the -plugin installed, `install-agent --claude`'s registration below is a second copy of the same -server: remove it with `claude mcp remove zerograph -s local` -([why](install.md#claude-code-plugin)). - -`install-agent --claude` registers the server through the `claude` CLI, at local scope, once for -every checkout of the clone that `git worktree list` names, each with its own `--repo`. Local scope -is keyed by the project's path, so a session opened in a worktree sees only what was registered for -that worktree; a worktree added later gets its server from the next `install-agent` run. A -registration that already says the same is left alone, and the command to run by hand is printed -where `claude` is not on `PATH`. One that says something else is replaced only in the local scope: a -differing `zerograph` in the user scope or a tracked `.mcp.json` is left as its owner wrote it, the -local one is added beside it and wins in that project, and the run says so in one note. - -The same run prunes `~/.claude.json` (or `.claude.json` in `CLAUDE_CONFIG_DIR`): a `zerograph` server -it registered for a checkout that is no longer on disk — a removed worktree — is taken out. -`claude mcp remove` cannot do this, since it finds -the project by a working directory that is gone. Nothing else in the file is changed, though its keys -are written back in sorted order. - -Why not one project-scope `.mcp.json` with no `--repo`: Claude Code's -[MCP documentation](https://code.claude.com/docs/en/mcp) does not say which working directory a -stdio server starts in, so a server that found its checkout from its working directory would be -relying on something undocumented. +session, as `mcp__plugin_zerograph_zerograph__*`, and its slash commands call these tools. The +[Codex plugin](install.md#codex-plugin) starts it the same way for a Codex session. +`install-agent --claude` and `--codex` install those plugins and register no server of their own. + +Both start it through the plugin's `scripts/mcp.mjs`, which finds the checkout around the session's +project directory (`CLAUDE_PROJECT_DIR` under Claude Code, the working directory Codex starts it +in) and runs `zerograph --repo mcp`, so a session opened in a package of a monorepo +serves the whole repository. + +Releases before 0.7.0 registered the server through `claude mcp add` at local scope, once per +worktree. A re-run of `install-agent --claude` removes those registrations from `~/.claude.json` +(or `.claude.json` in `CLAUDE_CONFIG_DIR`) for the checkout and its worktrees, and for a checkout +no longer on disk — a removed worktree, which `claude mcp remove` cannot reach, since it finds the +project by a working directory that is gone. A differing `zerograph` in the user scope or a tracked +`.mcp.json` is left as its owner wrote it. Nothing else in the file is changed, though its keys are +written back in sorted order. The protocol is newline-delimited JSON-RPC on stdout and nothing else; diagnostics go to stderr. A tool that fails returns an `isError` result and the server carries on. diff --git a/docs/releases/0.7.0.md b/docs/releases/0.7.0.md index e1035c2e..5a1f1cfa 100644 --- a/docs/releases/0.7.0.md +++ b/docs/releases/0.7.0.md @@ -17,6 +17,22 @@ links to. started a full embed. A re-run deletes the block and keeps the rest of each hook file; a file left with only its shebang is deleted. Every read already refreshes, and `serve` catches up when it starts. Git hooks remain an option, written by hand ([keeping fresh](../keeping-fresh.md)). +- **`install-agent --claude` and `--codex` install a plugin, and take out what earlier releases + wrote.** The plugin is now the only way zerograph wires into Claude Code and Codex: it starts the + MCP server, runs the session hook and carries the commands. `--claude` runs `claude plugin + marketplace add devmaxxx/zerograph`, `claude plugin marketplace update zerograph`, `claude plugin + install zerograph@zerograph` and `claude plugin update zerograph@zerograph`; `--codex` runs + `codex plugin marketplace add devmaxxx/zerograph --ref v0.7.0` and `codex plugin add + zerograph@zerograph`, re-adding the marketplace when it was pinned to an older tag. A re-run after + a binary upgrade moves either plugin to the binary's release. + Once the plugin is in, the run removes the local-scope MCP servers in `~/.claude.json` for the + checkout and its worktrees, the hook entries in `.claude/settings.json` and `$CODEX_HOME/hooks.json`, + the copied hook script, the `zerograph` and `repo-query` skills, the `repo-scout` subagent and the + instructions stanza in `CLAUDE.md` or `AGENTS.md`; an edited file is left and named, a hook + script stays until the entries that run it are gone, and a file git tracks is left for the team + until `--retire-repo` asks for it. Where the CLI + is missing it prints the commands and changes nothing. `--command` no longer reaches Claude Code + or Codex: the plugins run `zerograph` ([install](../install.md#upgrading)). - **Nothing from before the repograph rename is read any more**: no `.repograph/` store, `repograph.toml`, `~/.config/repograph/`, `~/.cache/repograph/` or `REPOGRAPH_*` variable, and `install-agent` no longer retires the `repograph` hook, stanza or MCP server. - **The session hook no longer runs a binary from the checkout it starts in.** It used `/node_modules/.bin/zerograph` when nothing else was found, so a cloned repository that @@ -60,9 +76,13 @@ links to. `ZEROGRAPH_VECTOR_CACHE` moves the cache for one run; on Unix it is refused under a directory another user could change. - **The session hook builds a checkout that has no index**, in the background, when the checkout - opted in: it holds the installed hook or a `zerograph.toml`, or its main worktree does. With an + opted in: it holds a `zerograph.toml` or the hook an earlier release installed, or its main + worktree does or has an index. With an index, the `serve` the hook starts catches up as it starts; with `ZEROGRAPH_HOOK_SERVE=0`, a detached `update` does it instead ([keeping fresh](../keeping-fresh.md#the-session-hook)). +- **A Codex plugin**, `agent/codex/`: the MCP server, the session hook on `SessionStart`, + `SubagentStart` and after an edit, and the commands as skills, `$zerograph:ask` to + `$zerograph:refresh` ([install](../install.md#codex-plugin)). - **A store records the release that wrote it.** `manifest.json` carries `release`, `status` shows it, and a store a newer release wrote has a `newer_grammar` state of its own, which the Claude Code mod displays ([commands](../commands.md)). @@ -77,11 +97,6 @@ links to. ([configuration](../configuration.md)). The session hook resolves the same root, so inside a git checkout it asks about the top level whatever subdirectory the session is in ([keeping fresh](../keeping-fresh.md#the-session-hook)). -- **The Claude MCP server is registered once per checkout.** `install-agent --claude` registers it - at local scope for each checkout `git worktree list` names, each with its own `--repo`, and prunes - from `~/.claude.json` the entries it registered for a checkout that no longer exists. A - `zerograph` entry at user scope or in a tracked `.mcp.json` is left alone: only the local entry is - replaced, and a note says when another scope holds the name ([MCP server](../mcp.md)). - **The Claude Code mod reads files instead of polling `status --json`.** Every 10 s it reads `background.log` and stats the socket the last report named; the full report runs at session start, every 5 minutes, once after a refresh ends and after `/zerograph` @@ -100,9 +115,6 @@ links to. would run, redirect or expand; such a checkout's line comes from the logs instead ([install](../install.md#claude-code-status-mod)). - **`import-legacy` takes the writer lock** and refuses a store a newer release wrote, like every other writer. -- **The printed Codex hooks block quotes the hook path.** A checkout directory whose name held a - `'` broke out of the quoting once pasted into `~/.codex/hooks.json`; the path is now quoted for - the shell, and with double quotes on Windows, where `cmd` reads no single quote as quoting. - **A `serve` started by 0.6.4 can be stopped after the upgrade.** It listens at the old socket, where no client of 0.7.0 looks, and writes without the writer lock. `zerograph stop` now sends the stop line there as well, and a new `serve` does so as soon as it binds, on a thread of its own: a @@ -122,9 +134,6 @@ links to. on gets `ZEROGRAPH_READER_BUDGET=2`, or a smaller value the environment or `zerograph.toml` sets, and a refresh that would not fit goes to a detached `update` instead of being killed ([keeping fresh](../keeping-fresh.md#the-session-hook)). -- **The Codex hook path survives removing the worktree it was installed from.** The path - `install-agent --codex` prints is the main worktree's copy, written there when the main worktree - has none. - **Worktree notices.** The build notice no longer promises "a couple of minutes", and it says that rows another checkout on this machine already embedded come from the vector cache. The docs say each worktree keeps its own store and that an APFS clone stops sharing the graph at its first refresh @@ -144,8 +153,9 @@ links to. - **A store built in a subdirectory of a git checkout** by an earlier release is no longer read by the session hook, which asks about the checkout's top level as the CLI does. Build at the top level (`zerograph build` from anywhere in the checkout) and delete the old `.zerograph/`. -- **Re-run `install-agent`**: it removes the git-hook block and registers the MCP server for every - worktree. +- **Re-run `install-agent`** with the flags you used: it removes the git-hook block, and with + `--claude` or `--codex` installs the plugin and removes the older install + ([install](../install.md#upgrading)). - **Replace `zerograph watch`** in scripts and services with `zerograph serve --idle `. - **A running 0.6.4 `serve`** is sent the stop line by the first `zerograph stop` or `zerograph serve` of 0.7.0; otherwise it runs until its own `--idle` timeout. One that does not diff --git a/scripts/sync-plugin-scripts.sh b/scripts/sync-plugin-scripts.sh new file mode 100755 index 00000000..cad40838 --- /dev/null +++ b/scripts/sync-plugin-scripts.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Copies the Claude Code plugin's launcher scripts into the Codex plugin. +# +# Each plugin is installed from its own directory, and neither agent's installer is known to carry a +# symlink out of it (a Windows checkout without core.symlinks turns one into a text file), so the +# Codex plugin ships copies. agent/codex/plugin.test.mjs fails while they differ; run this after +# editing anything in agent/claude-code/scripts. +set -eu +repo=$(cd "$(dirname "$0")/.." && pwd) +src="$repo/agent/claude-code/scripts" +dst="$repo/agent/codex/scripts" +for f in "$src"/*.mjs; do + case "$f" in *.test.mjs) continue ;; esac + cp "$f" "$dst/" +done +echo "synced $(ls "$src"/*.mjs | grep -vc '\.test\.mjs$') scripts into agent/codex/scripts" diff --git a/src/install_agent.rs b/src/install_agent.rs index 5a86e74e..228deefd 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -1,25 +1,32 @@ -//! `zerograph install-agent`: writes the agent-facing surface into a consuming repository, for -//! Claude Code or for Codex, and an MCP entry and the stanza for Cursor, VS Code with Copilot, -//! Gemini CLI and opencode. The texts are embedded with `include_str!` rather than copied from a -//! checkout, so a binary and the surface it installs cannot disagree about what the tool can do. +//! `zerograph install-agent`: wires a coding agent to this repository's index. Claude Code and +//! Codex get the zerograph plugin, installed through their own `plugin` commands — the MCP server, +//! the session hook, the commands and the skill all ship in it — and an older install's files in +//! the repository are taken out once the plugin is in. Cursor, VS Code with Copilot, Gemini CLI and +//! opencode, which have no plugin to carry them, get an MCP entry and the stanza. //! //! Idempotent by construction: a second run rewrites the same bytes and reports nothing written. -//! The settings file is merged rather than replaced — a repository's own hooks survive, and this -//! tool's entries are replaced by command match, which is what makes an upgrade a re-run. use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; -const HOOK: &str = include_str!("../agent/hook.mjs"); -const RULE: &str = include_str!("../agent/rule.txt"); const STANZA: &str = include_str!("../agent/stanza.md"); -const SKILL: &str = include_str!("../agent/skill/SKILL.md"); -/// The skill as 0.6.0–0.6.3 wrote it, under the name `repo-query`: what an upgrade may remove. -const LEGACY_SKILL: &str = include_str!("../agent/skill/repo-query.md"); const VERSION: &str = env!("CARGO_PKG_VERSION"); -const SCOUT: &str = include_str!("../agent/subagent.md"); -/// Where the surface is being installed. The two harnesses read different files for the same four -/// jobs, and the only thing that differs between them is which path each job goes to. +/// What an earlier release wrote into a repository for Claude Code and Codex, kept so a re-run can +/// tell its own files from a team's before it takes them out. +const LEGACY_RULE: &str = include_str!("../agent/legacy/rule.txt"); +const LEGACY_SKILL: &str = include_str!("../agent/legacy/skill.md"); +/// The skill as 0.6.0–0.6.3 wrote it, under the name `repo-query`. +const LEGACY_REPO_QUERY: &str = include_str!("../agent/legacy/repo-query.md"); +const LEGACY_SCOUT: &str = include_str!("../agent/legacy/repo-scout.md"); +const LEGACY_HOOK: &str = "zerograph-hook.mjs"; +/// The hook script 0.6.0–0.6.4 wrote, one text across all five. +const LEGACY_HOOK_TEXT: &str = include_str!("../agent/legacy/zerograph-hook.mjs"); + +/// The repository both harnesses read the marketplace from, and the plugin's id in it. +const MARKETPLACE: &str = "devmaxxx/zerograph"; +const PLUGIN: &str = "zerograph@zerograph"; +const MARKETPLACE_NAME: &str = "zerograph"; + #[derive(Clone, Copy, Debug, PartialEq)] pub enum Target { Claude, Codex } @@ -28,47 +35,143 @@ impl Target { match self { Target::Claude => ".claude", Target::Codex => ".codex" } } - /// The file each harness reads for its always-on instructions. Codex reads the repository's - /// **root** `AGENTS.md` and has no `.codex/AGENTS.md` in its spec, so its stanza is written at - /// the root whether or not one is already there; Claude Code reads either, and a repository - /// that already keeps a root `CLAUDE.md` should not grow a second file beside it. - fn instructions(self) -> &'static str { - match self { Target::Claude => "CLAUDE.md", Target::Codex => "AGENTS.md" } + pub fn cli(self) -> &'static str { + match self { Target::Claude => "claude", Target::Codex => "codex" } + } + + /// The commands that install the plugin, or bring an installed one to this release, each safe + /// to repeat. Claude Code's marketplace pins the plugin to a release tag itself, so the catalog + /// is refreshed and the plugin updated to what it names now; an install alone leaves an older + /// release in place. Codex reads the plugin out of the marketplace's own checkout, so the + /// release is pinned on the marketplace, to the one this binary is. + fn plugin_steps(self) -> Vec> { + let s = |args: &[&str]| args.iter().map(|a| a.to_string()).collect::>(); + match self { + Target::Claude => vec![ + s(&["plugin", "marketplace", "add", MARKETPLACE]), + s(&["plugin", "marketplace", "update", MARKETPLACE_NAME]), + s(&["plugin", "install", PLUGIN]), + s(&["plugin", "update", PLUGIN]), + ], + Target::Codex => vec![ + s(&["plugin", "marketplace", "add", MARKETPLACE, "--ref", &format!("v{VERSION}")]), + s(&["plugin", "add", PLUGIN]), + ], + } } - /// Whether the harness reads a settings file inside the repository. Claude Code does - /// (`.claude/settings.json`); Codex's hooks are `CODEX_HOME`-relative — `"hooks": - /// "./hooks.json"` — so an entry added from inside one repository would fire in every other - /// repository on the machine. `agent/codex.md` records how both were read off the CLI. - fn hooks_are_repository_scoped(self) -> bool { self == Target::Claude } + /// What to run instead when `step` failed saying `said`. Codex refuses to re-add a marketplace + /// under another ref — which is every upgrade of the binary — until the old source is removed; + /// removing it keeps the plugin enabled, and the `plugin add` after it installs the new release. + fn recovery(self, step: &[String], said: &str) -> Option>> { + let repinning = self == Target::Codex && step.get(1).is_some_and(|a| a == "marketplace") + && said.contains("already added from a different source"); + repinning.then(|| vec![ + ["plugin", "marketplace", "remove", MARKETPLACE_NAME].iter().map(|a| a.to_string()).collect(), + step.to_vec(), + ]) + } } #[derive(Debug, Default)] -pub struct Report { pub written: usize, pub paths: Vec, pub notes: Vec } +pub struct Report { + pub written: usize, + pub paths: Vec, + pub notes: Vec, + /// Files an earlier install wrote that git tracks, left for the team to retire. + pub committed: Vec, +} -/// A text with `{{command}}` resolved to how this repository invokes the binary — `zerograph` -/// unless a caller says otherwise, `pnpm exec zerograph` in a workspace that installs it as a -/// dependency. Every text that names a command carries it: a line naming the bare binary where -/// PATH has none tells an agent to run something that is not there. -fn with_command(template: &str, command: &str) -> String { template.replace("{{command}}", command) } +/// How far a retirement reaches. +#[derive(Clone, Copy, Debug, Default)] +pub struct Retire { + /// Take out files git tracks too. Off by default: a committed hook or stanza serves every + /// teammate who has no plugin yet, and removing it here puts that removal in someone's commit. + pub committed: bool, + /// Whether the machine's hook entries that run a script in the repository are gone. Codex's + /// live in `$CODEX_HOME/hooks.json`, outside the repository, and a script they still run + /// stays. + pub machine_hooks_gone: bool, +} -/// The hook's one declaration of the command its hints name. Replaced whole rather than through -/// `{{command}}`, because this text lands in JavaScript: a JSON string is a JavaScript string -/// literal, so no quote, backslash or `${` in a command can end the literal or open an expression. -/// The template declares the default, which is what lets the hook's own tests run it as it ships. -const HOOK_COMMAND: &str = "const COMMAND = \"zerograph\";"; +/// Whether git tracks `path` in the checkout that holds it. +fn tracked(path: &Path) -> bool { + let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else { return false }; + std::process::Command::new("git").arg("-C").arg(dir).args(["ls-files", "--error-unmatch", "--"]).arg(name) + .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()) + .status().is_ok_and(|s| s.success()) +} -fn hook(command: &str) -> String { - let script = HOOK.replacen(HOOK_COMMAND, &format!("const COMMAND = {};", serde_json::Value::from(command)), 1); - let (shebang, rest) = script.split_once('\n').unwrap(); - format!("{shebang}\n{}{rest}", stamp()) +/// Whether `path` stays because git tracks it, recording it when so. +fn keep_committed(path: &Path, retire: Retire, report: &mut Report) -> bool { + if retire.committed || !tracked(path) { return false; } + report.committed.push(path.display().to_string()); + true } -/// The line under the shebang records the matchers this release wrote, so a re-run can tell one it -/// wrote (moved to the new default) from one a person changed (kept). -fn stamp() -> String { - let wrote: Vec = hook_entries("").iter().map(|(e, m, _)| format!("{e}={m}")).collect(); - format!("// zerograph {} wrote: {}\n", env!("CARGO_PKG_VERSION"), wrote.join(" ")) +/// How a CLI is run: `program`, in `cwd`, with `args`; `None` when it is not installed. A test +/// passes its own. +pub type Run<'a> = &'a dyn Fn(&str, &Path, &[String]) -> Option; + +pub fn run_cli(program: &str, cwd: &Path, args: &[String]) -> Option { + command_for(program)?.args(args).current_dir(cwd).stdin(std::process::Stdio::null()).output().ok() +} + +/// `program` as a command to start. On Windows `Command` finds only a `.exe` by a bare name, and +/// npm installs `claude` and `codex` as `.cmd` shims, which only `cmd` starts; the arguments +/// passed here are fixed words with nothing `cmd` would act on. +fn command_for(program: &str) -> Option { + if !cfg!(windows) { return Some(std::process::Command::new(program)); } + let path = std::env::var_os("PATH")?; + for ext in ["exe", "cmd"] { + for dir in std::env::split_paths(&path).filter(|d| d.is_absolute()) { + let file = dir.join(format!("{program}.{ext}")); + if !file.is_file() { continue; } + if ext == "exe" { return Some(std::process::Command::new(file)); } + let comspec = std::env::var_os("ComSpec").unwrap_or_else(|| "cmd.exe".into()); + let mut cmd = std::process::Command::new(comspec); + cmd.args(["/d", "/c", "call"]).arg(file); + return Some(cmd); + } + } + None +} + +/// Installs the plugin through the harness's own CLI, and says whether it is in. A missing CLI or +/// a failed step is a note naming the commands to run by hand, not an error: the other targets +/// and the git clean-up still run. +pub fn install_plugin(root: &Path, target: Target, run: Run) -> (bool, Report) { + let mut report = Report::default(); + let cli = target.cli(); + let steps = target.plugin_steps(); + let mut by_hand = steps.iter().map(|s| format!(" {cli} {}", s.iter().map(|a| shell_word(a)).collect::>().join(" "))).collect::>().join("\n"); + if target == Target::Codex { + by_hand.push_str(&format!("\n(when Codex says the marketplace is already added from a different source, run `codex plugin marketplace remove {MARKETPLACE_NAME}` first)")); + } + let said = |out: &std::process::Output| [&out.stderr[..], &out.stdout[..]].iter() + .map(|b| String::from_utf8_lossy(b).trim().to_string()).find(|s| !s.is_empty()).unwrap_or_default(); + for step in &steps { + let mut out = run(cli, root, step); + if let Some(fallback) = out.as_ref().filter(|o| !o.status.success()).and_then(|o| target.recovery(step, &said(o))) { + for again in &fallback { + out = run(cli, root, again); + if !out.as_ref().is_some_and(|o| o.status.success()) { break; } + } + } + match out { + None => { + report.notes.push(format!("`{cli}` is not on PATH, so the plugin was not installed; run these where it is, then run this again to take out the older install:\n{by_hand}")); + return (false, report); + } + Some(out) if !out.status.success() => { + report.notes.push(format!("`{cli} {}` failed ({}), so the plugin was not installed; the commands are:\n{by_hand}", step.join(" "), said(&out))); + return (false, report); + } + Some(_) => {} + } + } + report.notes.push(format!("the {PLUGIN} plugin is installed for this user; it starts with the next session")); + (true, report) } const BEGIN: &str = ""; @@ -85,6 +188,12 @@ fn skip_break(text: &str) -> &str { text.strip_prefix("\r\n").or_else(|| text.strip_prefix('\n')).unwrap_or(text) } +/// A text with `{{command}}` resolved to how this repository invokes the binary — `zerograph` +/// unless a caller says otherwise, `pnpm exec zerograph` in a workspace that installs it as a +/// dependency. A line naming the bare binary where PATH has none tells an agent to run something +/// that is not there. +fn with_command(template: &str, command: &str) -> String { template.replace("{{command}}", command) } + /// The instructions file with our stanza in it: replacing what is between the markers when they are /// there, appending when they are not. Everything outside the markers is the repository's own and /// is never touched — an installer that rewrote CLAUDE.md would be a worse citizen than no @@ -109,55 +218,24 @@ pub fn merged_instructions(existing: &str, command: &str) -> String { } } -/// The four hook entries this tool owns, as the harness's settings file spells them. -fn hook_entries(hook_path: &str) -> Vec<(&'static str, &'static str, serde_json::Value)> { - let entry = |timeout: u64| serde_json::json!({ - "hooks": [{ "type": "command", "command": format!("node \"{hook_path}\""), "timeout": timeout }] - }); - vec![ - ("SessionStart", "startup|resume|clear|compact", entry(10)), - ("SubagentStart", ".*", entry(5)), - ("PreToolUse", "Bash|Grep|Glob|Agent", entry(10)), - ("PostToolUse", "Edit|Write|MultiEdit", entry(10)), - ] -} - -/// The settings file with this tool's entries in it and everything else left alone. An entry whose -/// command names our hook script is replaced rather than added, so a re-run after the hook changed -/// is an upgrade and not a second copy firing twice per event. -/// -/// `prior_hook` is the hook script on disk before this run; its stamp says which matchers we wrote. -pub fn merged_settings(existing: &str, hook_path: &str, prior_hook: &str) -> Result { - let mut root: serde_json::Value = match existing.trim().is_empty() { - true => serde_json::json!({}), - false => serde_json::from_str(existing).context("the settings file is not JSON")?, - }; - if !root.is_object() { anyhow::bail!("the settings file is not a JSON object"); } - let hooks = root.as_object_mut().unwrap().entry("hooks").or_insert_with(|| serde_json::json!({})); - if !hooks.is_object() { anyhow::bail!("`hooks` in the settings file is not an object"); } - // A hook from before the stamp has none, and its release wrote today's defaults. - let stamp = prior_hook.lines().nth(1).unwrap_or_default(); - for (event, matcher, mut ours) in hook_entries(hook_path) { - let list = hooks.as_object_mut().unwrap().entry(event).or_insert_with(|| serde_json::json!([])); - let Some(arr) = list.as_array_mut() else { anyhow::bail!("`hooks.{event}` is not an array") }; - let theirs = arr.iter() - .filter(|e| e.to_string().contains("zerograph-hook")) - .find_map(|e| e["matcher"].as_str()) - .filter(|m| *m != matcher && !stamp.split(' ').any(|t| t == format!("{event}={m}"))); - ours["matcher"] = serde_json::json!(theirs.unwrap_or(matcher)); - // Ours by the script it runs, never by position: a repository's own entries keep theirs. - arr.retain(|e| !e.to_string().contains("zerograph-hook")); - arr.push(ours); - } - Ok(serde_json::to_string_pretty(&root)? + "\n") +/// `existing` without our stanza and the blank line that set it off, or `None` when it holds none. +pub fn without_stanza(existing: &str) -> Option { + let (a, b) = marked(existing, BEGIN, END)?; + let head = existing[..a].trim_end_matches(['\n', '\r']); + let tail = skip_break(&existing[b..]); + Some(match (head.is_empty(), tail.trim().is_empty()) { + (true, _) => tail.trim_start_matches(['\n', '\r']).to_string(), + (false, true) => format!("{head}\n"), + (false, false) => format!("{head}\n\n{}", tail.trim_start_matches(['\n', '\r'])), + }) } fn is_symlink(path: &Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) } -/// A repository can ship `.claude/settings.json` or `zerograph.toml` as a link to a file elsewhere -/// on the machine, and a write through it would overwrite that file with ours. +/// A repository can ship a file as a link to one elsewhere on the machine, and a write through it +/// would overwrite that file with ours. fn refuse_symlink(path: &Path) -> Result<()> { if is_symlink(path) { anyhow::bail!("{} is a symlink; refusing to write through it — replace it with a regular file and run again", path.display()); @@ -178,73 +256,32 @@ fn write_if_changed(path: &Path, text: &str, report: &mut Report) -> Result<()> Ok(()) } -/// The block a reader pastes into `~/.codex/hooks.json` to wire the hook on a machine, printed -/// rather than written: that file is machine-level, and a `--repo` command does not get to change -/// how every other repository on the machine behaves. Three events, not four — the Bash -/// interceptor's verdict is being read on Claude Code and is not carried over blind. -pub fn codex_hooks_block(hook_abs: &str) -> String { - let entry = |matcher: &str, timeout: u64| serde_json::json!({ - "matcher": matcher, - "hooks": [{ "type": "command", "command": node_command(hook_abs, cfg!(windows)), "timeout": timeout }] - }); - serde_json::to_string_pretty(&serde_json::json!({ - "hooks": { - "SessionStart": [entry("startup|resume|clear|compact", 10)], - "SubagentStart": [entry(".*", 5)], - "PostToolUse": [entry("apply_patch|Edit|Write", 10)], - } - })).unwrap_or_default() -} - -/// `node ` as the shell Codex hands it to reads it. A POSIX shell takes the path as one -/// word through `shell_word`; `cmd` knows only double quotes, which a Windows path cannot contain. -fn node_command(hook_abs: &str, windows: bool) -> String { - if windows { format!("node \"{hook_abs}\"") } else { format!("node {}", shell_word(hook_abs)) } -} - -/// The hook `~/.codex/hooks.json` should run, for an install in `root`. Codex's hooks are per user, -/// so the path has to outlive the checkout it was printed from: it is the main worktree's copy, -/// which stays while linked worktrees come and go. When the install runs in a linked worktree and -/// the main one has no hook yet, the hook and its rule are written there; one already there is -/// left as it is. A bare clone, or no git, is `root`'s own copy. -pub fn codex_hook(root: &Path, command: &str) -> Result<(PathBuf, Report)> { - let mut report = Report::default(); - let here = canonical(root).unwrap_or_else(|| root.to_path_buf()); - let home = main_worktree(root).unwrap_or_else(|| here.clone()); - let hooks = home.join(Target::Codex.dir()).join("hooks"); - let path = hooks.join("zerograph-hook.mjs"); - if home != here { - match std::fs::read_to_string(&path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound && std::fs::symlink_metadata(&path).is_err() => { - write_if_changed(&path, &hook(command), &mut report)?; - write_if_changed(&hooks.join("rule.txt"), &with_command(RULE, command), &mut report)?; - report.notes.push(format!("wrote the hook into the main worktree, {}, which outlives this one", home.display())); - } - Ok(text) if text == hook(command) => {} - _ => report.notes.push(format!("{} is left as it is; `install-agent --codex` run there updates it", path.display())), +/// Removes `path`, counted and named, or says why it stayed. +fn remove(path: &Path, why: &str, retire: Retire, report: &mut Report) { + if std::fs::symlink_metadata(path).is_err() { return; } + // Unlinking would leave the file it points at, which another checkout or a dotfile manager + // shares, still holding what is being retired, and nothing would say so. + if is_symlink(path) { + report.notes.push(format!("{} is a symlink, so it was left; {why}, so remove it and what it points at by hand", path.display())); + return; + } + if keep_committed(path, retire, report) { return; } + match std::fs::remove_file(path) { + Ok(()) => { + report.written += 1; + report.paths.push(path.display().to_string()); + report.notes.push(format!("removed {}: {why}", path.display())); } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => report.notes.push(format!("{} could not be removed ({e}); {why}, so remove it by hand", path.display())), } - Ok((path, report)) } -/// The skill under `skills/zerograph/`, stamped with the release that wrote it — the -/// `metadata.version` the Agent Skills format keeps for that — so a re-run by the same release -/// writes nothing and one by another says what it replaced. The `repo-query` skill an earlier -/// release wrote goes with it, when it is still the text that release wrote. -fn write_skill(skills: &Path, command: &str, report: &mut Report) -> Result<()> { - let path = skills.join("zerograph").join("SKILL.md"); - let before = std::fs::read_to_string(&path).ok().and_then(|t| skill_version(&t).map(String::from)); - let written = report.written; - write_if_changed(&path, &skill(command), report)?; - if let Some(old) = before.filter(|v| v != VERSION && report.written > written) { - report.notes.push(format!("{}: rewritten from zerograph {old} to {VERSION}", path.display())); - } - retire_legacy_skill(&skills.join("repo-query"), report); - Ok(()) +/// Removes `dir` when nothing is left in it. +fn remove_if_empty(dir: &Path) { + if std::fs::read_dir(dir).is_ok_and(|mut d| d.next().is_none()) { let _ = std::fs::remove_dir(dir); } } -fn skill(command: &str) -> String { with_command(SKILL, command).replace("{{version}}", VERSION) } - /// The release a generated skill names in its front matter. fn skill_version(text: &str) -> Option<&str> { let front = text.strip_prefix("---\n")?.split("\n---\n").next()?; @@ -261,81 +298,232 @@ fn generated_from(template: &str, text: &str) -> bool { with_command(template, &rest[..end]) == text } -/// Removes the `repo-query` skill an earlier release wrote, now that `zerograph` replaces it. Only -/// a directory holding nothing but the `SKILL.md` that release generated is ours: an edited one, or -/// one with files of its own, is a team's skill under that name, and is left and named. -fn retire_legacy_skill(dir: &Path, report: &mut Report) { +/// Removes a skill directory an earlier release wrote: only one holding nothing but the `SKILL.md` +/// `ours` recognises. An edited one, or one with files of its own, is a team's skill under that +/// name, and is left and named. +fn retire_skill(dir: &Path, ours: &dyn Fn(&str) -> bool, retire: Retire, report: &mut Report) { let Ok(meta) = std::fs::symlink_metadata(dir) else { return }; let file = dir.join("SKILL.md"); let only_skill = meta.is_dir() && std::fs::read_dir(dir).is_ok_and(|entries| { entries.filter_map(|e| e.ok()).all(|e| e.file_name() == "SKILL.md" && e.file_type().is_ok_and(|t| t.is_file())) }); - let ours = only_skill && std::fs::read_to_string(&file).is_ok_and(|t| generated_from(LEGACY_SKILL, &t)); - if !ours { - report.notes.push(format!("{} is not the skill an earlier zerograph wrote, so it was left; the `zerograph` skill beside it replaces it", dir.display())); + if !(only_skill && std::fs::read_to_string(&file).is_ok_and(|t| ours(&t))) { + report.notes.push(format!("{} is not the skill an earlier zerograph wrote, so it was left; the plugin's skill replaces it", dir.display())); return; } - if let Err(e) = std::fs::remove_file(&file).and_then(|_| std::fs::remove_dir(dir)) { - report.notes.push(format!("{} could not be removed ({e}); the `zerograph` skill replaces it, so remove it by hand", dir.display())); + remove(&file, "the plugin carries the skill now", retire, report); + let _ = std::fs::remove_dir(dir); +} + +/// The settings file without the hook entries an earlier release merged in, found by the script +/// they run, or `None` when it holds none. An event left with no entry goes, and `hooks` with it. +pub fn without_hook_entries(existing: &str) -> Result> { + if existing.trim().is_empty() { return Ok(None); } + let mut root: serde_json::Value = serde_json::from_str(existing).context("not JSON")?; + let Some(hooks) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else { return Ok(None) }; + let mut found = false; + let ours = |v: &serde_json::Value| v.to_string().contains(LEGACY_HOOK); + for list in hooks.values_mut() { + let Some(groups) = list.as_array_mut() else { continue }; + // By command, not by group: `/hooks` and a pasted block both add a command to an existing + // matcher's group, and the commands beside ours there are someone else's. + for group in groups.iter_mut() { + let Some(commands) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else { continue }; + let before = commands.len(); + commands.retain(|c| !ours(c)); + found |= commands.len() < before; + } + let before = groups.len(); + groups.retain(|g| match g.get("hooks").and_then(|h| h.as_array()) { + Some(commands) => !commands.is_empty(), + None => !ours(g), + }); + found |= groups.len() < before; + } + if !found { return Ok(None); } + hooks.retain(|_, list| list.as_array().is_none_or(|a| !a.is_empty())); + if hooks.is_empty() { root.as_object_mut().unwrap().remove("hooks"); } + Ok(Some(serde_json::to_string_pretty(&root)? + "\n")) +} + +/// Takes our hook entries out of a settings file, removing the file when they were all it held, and +/// says whether none are left in it. +fn retire_hook_entries(path: &Path, retire: Retire, report: &mut Report) -> bool { + let Ok(existing) = std::fs::read_to_string(path) else { return true }; + match without_hook_entries(&existing) { + Ok(None) => true, + Ok(Some(_)) if is_symlink(path) => { + report.notes.push(format!("{} is a symlink, so the hook entries an earlier release wrote were left in it; take out the ones that run {LEGACY_HOOK}", path.display())); + false + } + Ok(Some(_)) if keep_committed(path, retire, report) => false, + Ok(Some(rest)) if rest.trim() == "{}" => { + remove(path, "it held only the hook entries an earlier release wrote, and the plugin runs the hook now", retire, report); + !path.exists() + } + Ok(Some(rest)) => match std::fs::write(path, rest) { + Ok(()) => { + report.written += 1; + report.paths.push(path.display().to_string()); + report.notes.push(format!("{}: took out the hook entries an earlier release wrote; the plugin runs the hook now", path.display())); + true + } + Err(e) => { + report.notes.push(format!("{} could not be rewritten ({e}); take out the entries that run {LEGACY_HOOK}", path.display())); + false + } + }, + Err(e) => { + report.notes.push(format!("{} is left as it is ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())); + false + } + } +} + +/// Takes our stanza out of an instructions file, removing the file when it was all it held. +fn retire_stanza(path: &Path, retire: Retire, report: &mut Report) { + let Ok(existing) = std::fs::read_to_string(path) else { return }; + let Some(rest) = without_stanza(&existing) else { return }; + if is_symlink(path) { + report.notes.push(format!("{} is a symlink, so the stanza an earlier release wrote was left in what it points to; the plugin's skill replaces it", path.display())); return; } - report.written += 1; - report.notes.push(format!("removed {}, the skill's name before 0.6.4; `zerograph` replaces it", dir.display())); + if keep_committed(path, retire, report) { return; } + if rest.trim().is_empty() { + remove(path, "it held only the stanza an earlier release wrote, and the plugin's skill replaces it", retire, report); + return; + } + match std::fs::write(path, rest) { + Ok(()) => { + report.written += 1; + report.paths.push(path.display().to_string()); + report.notes.push(format!("{}: took out the stanza an earlier release wrote; the plugin's skill replaces it", path.display())); + } + Err(e) => report.notes.push(format!("{} could not be rewritten ({e}); remove the lines between `{BEGIN}` and `{END}` by hand", path.display())), + } } -/// Installs the surface for one harness under `root`. Returns what it actually wrote: a second run -/// writes nothing, which is how a caller can tell an upgrade from a no-op. -pub fn install(root: &Path, target: Target, command: &str) -> Result { - let mut report = Report::default(); - let base: PathBuf = root.join(target.dir()); - let hook_file = base.join("hooks").join("zerograph-hook.mjs"); - let prior_hook = std::fs::read_to_string(&hook_file).unwrap_or_default(); - write_if_changed(&hook_file, &hook(command), &mut report)?; - write_if_changed(&base.join("hooks").join("rule.txt"), &with_command(RULE, command), &mut report)?; - write_skill(&base.join("skills"), command, &mut report)?; - if target == Target::Claude { - write_if_changed(&base.join("agents").join("repo-scout.md"), &with_command(SCOUT, command), &mut report)?; - } - - // A path the harness can resolve from wherever it runs the hook. Claude Code exports the - // project directory; Codex runs with the repository as its working directory and exports - // nothing, so the path is relative there. - let hook_path = match target { - Target::Claude => "$CLAUDE_PROJECT_DIR/.claude/hooks/zerograph-hook.mjs".to_string(), - Target::Codex => ".codex/hooks/zerograph-hook.mjs".to_string(), +/// Whether opencode in `root` still runs our server, which reads the stanza from `AGENTS.md`. +fn opencode_wants_stanza(root: &Path) -> bool { + std::fs::read_to_string(root.join(Editor::Opencode.mcp_file())).ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .is_some_and(|v| v[Editor::Opencode.servers_key()].get(MCP_NAME).is_some()) +} + +/// Whether `text` is the hook 0.6.0–0.6.4 wrote: the template with its command line resolved to +/// whatever command the install was given, and, from 0.6.4, a stamp under the shebang. +fn generated_hook(text: &str) -> bool { + const TEMPLATE_COMMAND: &str = "const COMMAND = \"zerograph\";"; + let Some((shebang, rest)) = text.split_once('\n') else { return false }; + let rest = match rest.split_once('\n') { + Some((stamp, after)) if stamp.starts_with("// zerograph ") && stamp.contains(" wrote: ") => after, + _ => rest, }; - if target.hooks_are_repository_scoped() { - let settings = base.join("settings.json"); - let existing = std::fs::read_to_string(&settings).unwrap_or_default(); - let merged = merged_settings(&existing, &hook_path, &prior_hook)?; - write_if_changed(&settings, &merged, &mut report)?; - } - - // Codex's is the repository root or nowhere; Claude Code reads either, and a repository that - // already keeps a root `CLAUDE.md` should not grow a second file beside it. A stanza already in - // `.claude/CLAUDE.md` stays the one to update, though, or a root file made since would hold a - // second stanza beside the first. - let root_instructions = root.join(target.instructions()); - let inner_instructions = base.join(target.instructions()); - let instructions = match target == Target::Codex || holds_stanza(&root_instructions) - || (root_instructions.exists() && !holds_stanza(&inner_instructions)) - { - true => root_instructions, - false => inner_instructions, + let Some(a) = rest.find("const COMMAND = ") else { return false }; + let end = rest[a..].find('\n').map_or(rest.len(), |n| a + n); + format!("{shebang}\n{}{TEMPLATE_COMMAND}{}", &rest[..a], &rest[end..]) == LEGACY_HOOK_TEXT +} + +/// Removes the hook script an earlier release wrote into `hooks`, and its rule while it is still the +/// text that release generated — but only once `entries_gone`: a hook entry left pointing at a +/// removed script fails on every event it matches. +pub fn retire_hook_script(hooks: &Path, entries_gone: bool, retire: Retire, report: &mut Report) { + let script = hooks.join(LEGACY_HOOK); + let Ok(text) = std::fs::read_to_string(&script) else { return }; + if !entries_gone { + report.notes.push(format!("{} was left: hook entries that run it are still in place", script.display())); + return; + } + if !generated_hook(&text) { + report.notes.push(format!("{} is not the hook an earlier zerograph wrote, so it was left; the plugin runs its own", script.display())); + return; + } + remove(&script, "the plugin runs the session hook now", retire, report); + let rule = hooks.join("rule.txt"); + match std::fs::read_to_string(&rule) { + Ok(t) if generated_from(LEGACY_RULE, &t) => remove(&rule, "the plugin's hook carries the rule", retire, report), + Ok(_) => report.notes.push(format!("{} was edited, so it was left; the plugin's hook carries its own rule and never reads it", rule.display())), + Err(_) => {} + } + remove_if_empty(hooks); +} + +/// Takes out of `root` what an earlier `install-agent` wrote for `target`, now that the plugin +/// carries it: the hook script and its rule, the hook entries in `.claude/settings.json`, the skill +/// and the subagent where they are still the text that release generated, and the stanza. Run only +/// once the plugin is in, so a session is never left with neither. +pub fn retire_repo_install(root: &Path, target: Target, retire: Retire) -> Report { + let mut report = Report::default(); + let base = root.join(target.dir()); + let entries_gone = match target { + Target::Claude => retire_hook_entries(&base.join("settings.json"), retire, &mut report), + Target::Codex => retire.machine_hooks_gone, }; - let existing = std::fs::read_to_string(&instructions).unwrap_or_default(); - let merged = merged_instructions(&existing, command); - // Never written through a link, and not an error either: `CLAUDE.md` linked to `AGENTS.md` is - // common, and failing here would abort an install that has already rewritten the settings and - // removed the old hook, before the MCP server is reached. - match merged != existing && is_symlink(&instructions) { - true => report.notes.push(format!( - "{} is a symlink, so the stanza was not written through it; `install-agent --codex` writes it \ - when it points at AGENTS.md, and otherwise the file it points to needs the update by hand", - instructions.display())), - false => write_if_changed(&instructions, &merged, &mut report)?, + retire_hook_script(&base.join("hooks"), entries_gone, retire, &mut report); + + let skills = base.join("skills"); + let generated = |t: &str| skill_version(t).is_some_and(|v| generated_from(&LEGACY_SKILL.replace("{{version}}", v), t)); + if skills.join("zerograph").exists() { retire_skill(&skills.join("zerograph"), &generated, retire, &mut report); } + if skills.join("repo-query").exists() { retire_skill(&skills.join("repo-query"), &|t| generated_from(LEGACY_REPO_QUERY, t), retire, &mut report); } + remove_if_empty(&skills); + let scout = base.join("agents").join("repo-scout.md"); + match std::fs::read_to_string(&scout) { + Ok(t) if generated_from(LEGACY_SCOUT, &t) => remove(&scout, "the plugin carries the subagent now", retire, &mut report), + Ok(_) => report.notes.push(format!("{} is not the subagent an earlier zerograph wrote, so it was left; the plugin's repo-scout replaces it", scout.display())), + Err(_) => {} + } + remove_if_empty(&base.join("agents")); + + match target { + Target::Claude => for file in [root.join("CLAUDE.md"), base.join("CLAUDE.md")] { retire_stanza(&file, retire, &mut report) }, + Target::Codex if opencode_wants_stanza(root) => {} + Target::Codex => retire_stanza(&root.join("AGENTS.md"), retire, &mut report), + } + remove_if_empty(&base); + report +} + +/// Codex's home: `CODEX_HOME` when set, `~/.codex` otherwise. +pub fn codex_home() -> Option { + match std::env::var_os("CODEX_HOME").filter(|v| !v.is_empty()) { + Some(dir) => Some(PathBuf::from(dir)), + None => Some(std::env::home_dir()?.join(".codex")), } - Ok(report) +} + +/// Takes out of Codex's machine hooks file the entries an earlier release printed for a person to +/// paste in: with the plugin's hook beside them, the hook would answer every event twice. Says +/// whether none are left, which is when the script they ran can go. +pub fn retire_codex_hooks(home: &Path) -> (bool, Report) { + let mut report = Report::default(); + // Written through a link to where it points, so a dotfile manager's link stays one. + let path = home.join("hooks.json"); + let path = path.canonicalize().unwrap_or(path); + let Ok(seen) = std::fs::metadata(&path) else { return (true, report) }; + let Ok(existing) = std::fs::read_to_string(&path) else { + report.notes.push(format!("{} could not be read; take out the entries that run {LEGACY_HOOK}", path.display())); + return (false, report); + }; + let gone = match without_hook_entries(&existing) { + Ok(None) => true, + Ok(Some(rest)) => match replace_config(&path, &seen, &rest) { + Ok(()) => { + report.written += 1; + report.paths.push(path.display().to_string()); + report.notes.push(format!("{}: took out the entries that ran {LEGACY_HOOK}; the plugin runs the hook now", path.display())); + true + } + Err(e) => { + report.notes.push(format!("{} could not be rewritten ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())); + false + } + }, + Err(e) => { + report.notes.push(format!("{} is left as it is ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())); + false + } + }; + (gone, report) } /// An editor or CLI that reads a project MCP file and an instructions file, and none of the @@ -454,10 +642,6 @@ pub fn install_editor(root: &Path, editor: Editor, command: &str) -> Result bool { - std::fs::read_to_string(path).is_ok_and(|t| t.contains(BEGIN)) -} - const MCP_NAME: &str = "zerograph"; /// The words of a command line the way a shell would split it, for the quotes and backslashes a @@ -479,57 +663,6 @@ fn split_words(command: &str) -> Vec { words } -/// Registers `zerograph --repo mcp` with Claude Code for the project at `root`, through -/// `claude mcp add` so the harness keeps its own registry's format. `--repo` is explicit because -/// the server's working directory is the harness's choice. `run` is how `claude` is invoked: a -/// test passes its own, and `None` from it means the binary is not on PATH. A registration that -/// already says this is left alone, which is what makes a re-run write nothing; one that says -/// something else (another command, another repository) is removed from the local scope and made -/// again; one in another scope is left and the local one added beside it. A missing -/// `claude` is a note and not a failure, since the files this install wrote are useful without it. -pub fn register_mcp(root: &Path, command: &str, run: &dyn Fn(&Path, &[&str]) -> Option) -> Report { - let mut report = Report::default(); - let words = split_words(command); - let Some((program, rest)) = words.split_first() else { - report.notes.push("--command is empty, so the MCP server was not registered".to_string()); - return report; - }; - let root_arg = root.display().to_string(); - let tail: Vec<&str> = rest.iter().map(String::as_str).chain(["--repo", &root_arg, "mcp"]).collect(); - let by_hand = format!("claude mcp add {MCP_NAME} -- {}", words.iter().map(String::as_str).chain(["--repo", &root_arg, "mcp"]).map(shell_word).collect::>().join(" ")); - let Some(existing) = run(root, &["mcp", "get", MCP_NAME]) else { - report.notes.push(format!("`claude` is not on PATH, so the MCP server was not registered; run `{by_hand}` where it is")); - return report; - }; - let mut beside = false; - if existing.status.success() { - let shown = String::from_utf8_lossy(&existing.stdout); - if said_in_order(&shown, program.as_str(), &tail) { - report.notes.push("MCP server already registered, left alone".to_string()); - return report; - } - // Only the local scope is this install's: without `-s` the CLI removes whichever scope - // holds the name, so a user-level entry would go machine-wide and a tracked `.mcp.json` - // entry would go in every worktree, leaving each one dirty. A local entry added beside - // one in another scope wins in this project, and the other stays as its owner left it. - beside = !run(root, &["mcp", "remove", MCP_NAME, "-s", "local"]).is_some_and(|o| o.status.success()); - } - let mut args = vec!["mcp", "add", MCP_NAME, "--", program.as_str()]; - args.extend(&tail); - match run(root, &args) { - Some(added) if added.status.success() => { - report.written = 1; - report.paths.push(by_hand); - if beside { - report.notes.push(format!("a `{MCP_NAME}` MCP server registered in another scope differs from this one and was left alone; the local one added beside it answers in this project, and `claude mcp remove {MCP_NAME} -s user` (or `-s project`) takes the other out if it is stale")); - } - } - Some(failed) => report.notes.push(format!("`claude mcp add` failed, the MCP server was not registered: {}", String::from_utf8_lossy(&failed.stderr).trim())), - None => report.notes.push("`claude` went away between two calls; the MCP server was not registered".to_string()), - } - report -} - /// The worktrees `git worktree list --porcelain` names, as (path, bare), main worktree first. fn parse_worktrees(porcelain: &str) -> Vec<(PathBuf, bool)> { porcelain.split("\n\n").filter_map(|block| { @@ -566,11 +699,6 @@ fn canonical(path: &Path) -> Option { path.canonicalize().ok().map(crate::plain) } -/// [`register_mcp`] once in every checkout [`worktrees`] names, each answer beside its checkout. -pub fn register_mcp_everywhere(root: &Path, command: &str, run: &dyn Fn(&Path, &[&str]) -> Option) -> Vec<(PathBuf, Report)> { - worktrees(root).into_iter().map(|checkout| { let r = register_mcp(&checkout, command, run); (checkout, r) }).collect() -} - /// The main worktree of the clone `root` belongs to: the one that stays while linked worktrees /// come and go. `None` for a bare clone, or where git cannot say. pub fn main_worktree(root: &Path) -> Option { @@ -588,19 +716,20 @@ pub fn claude_config() -> Option { } } -/// Whether a registered server is one `register_mcp` made: it runs `… --repo mcp`. +/// Whether a registered server is one an earlier `install-agent` made: it runs `… --repo mcp`. fn registered_by_us(server: &serde_json::Value) -> bool { let Some(args) = server["args"].as_array() else { return false }; args.last().and_then(|a| a.as_str()) == Some("mcp") && args.iter().any(|a| a.as_str() == Some("--repo")) } -/// Drops the server this tool registered for a checkout that is no longer on disk — a removed -/// worktree, mostly — from Claude Code's `config` file. `claude mcp remove` cannot do it: it finds -/// the project by its working directory, which is gone. Only our entry goes, and only -/// where the project's path is missing; everything else in the file is kept, though its keys -/// come back in sorted order. The file is written beside itself and renamed over, with its -/// permissions, and not at all when there is nothing to prune. -pub fn prune_stale_mcp(config: &Path) -> Report { +/// Drops the local-scope server an earlier `install-agent` registered from Claude Code's `config` +/// file, for every checkout in `replaced` — the plugin's server answers there now, and two would +/// both be offered — and for any checkout no longer on disk. `claude mcp remove` cannot do either +/// in one go: it finds the project by its working directory, once per checkout, and a removed one +/// has none. Only our entry goes; everything else in the file is kept, though its keys come back in +/// sorted order. The file is written beside itself and renamed over, with its permissions, and not +/// at all when there is nothing to prune. +pub fn prune_mcp(config: &Path, replaced: &[PathBuf]) -> Report { let mut report = Report::default(); // Written through a link to where it points, so a dotfile manager's link stays one. let config = &config.canonicalize().unwrap_or_else(|_| config.to_path_buf()); @@ -615,11 +744,12 @@ pub fn prune_stale_mcp(config: &Path) -> Report { for (path, project) in projects.iter_mut() { // Only a path known to be gone: one that cannot be looked at now (a mount that is down, a // directory without permission) may still be there. - if !matches!(Path::new(path).try_exists(), Ok(false)) { continue; } + let gone = matches!(Path::new(path).try_exists(), Ok(false)); + if !gone && !replaced.iter().any(|r| r == Path::new(path)) { continue; } let Some(servers) = project.get_mut("mcpServers").and_then(|s| s.as_object_mut()) else { continue }; let before = servers.len(); servers.retain(|name, server| !(name == MCP_NAME && registered_by_us(server))); - if servers.len() < before { pruned.push(path.clone()); } + if servers.len() < before { pruned.push((path.clone(), gone)); } } if pruned.is_empty() { return report; } let written = serde_json::to_string_pretty(&root).map_err(anyhow::Error::from).and_then(|out| replace_config(config, &seen, &(out + "\n"))); @@ -627,9 +757,15 @@ pub fn prune_stale_mcp(config: &Path) -> Report { Ok(()) => { report.written = 1; report.paths.push(config.display().to_string()); - for path in pruned { report.notes.push(format!("removed the MCP server registered for {path}, which is no longer on disk")); } + for (path, gone) in pruned { + report.notes.push(match gone { + true => format!("removed the MCP server registered for {path}, which is no longer on disk"), + false => format!("removed the MCP server an earlier release registered for {path}; the plugin's server replaces it"), + }); + } } - Err(e) => report.notes.push(format!("{} could not be rewritten ({e:#}); the MCP servers registered for {} stay", config.display(), pruned.join(", "))), + Err(e) => report.notes.push(format!("{} could not be rewritten ({e:#}); the MCP servers registered for {} stay", config.display(), + pruned.iter().map(|(p, _)| p.as_str()).collect::>().join(", "))), } report } @@ -671,28 +807,6 @@ fn shell_word(word: &str) -> String { if plain { word.to_string() } else { format!("'{}'", word.replace('\'', "'\\''")) } } -/// Whether `shown`, what `claude mcp get` printed, names `program` and then every argument of -/// `tail` in order. Each is looked for on its own because the harness decides how it joins them, -/// and a path with a space in it does not survive being compared as one space-joined string. -fn said_in_order(shown: &str, program: &str, tail: &[&str]) -> bool { - // A word counts only where whitespace or an end bounds it, or `/proj/app` is read inside `/proj/app2`. - let mut rest = shown; - std::iter::once(program).chain(tail.iter().copied()).all(|word| { - let whole = |at: usize| rest[..at].chars().next_back().is_none_or(char::is_whitespace) - && rest[at + word.len()..].chars().next().is_none_or(char::is_whitespace); - match rest.match_indices(word).map(|(at, _)| at).find(|&at| whole(at)) { - Some(at) => { rest = &rest[at + word.len()..]; true } - None => false, - } - }) -} - -/// `claude` run in `root`, where its local-scope registration belongs; `None` when it is not -/// installed. -pub fn run_claude(root: &Path, args: &[&str]) -> Option { - std::process::Command::new("claude").args(args).current_dir(root).stdin(std::process::Stdio::null()).output().ok() -} - /// The git events an earlier release wrote a refresh block into. const GIT_HOOKS: [&str; 2] = ["post-merge", "post-checkout"]; const GIT_MARKERS: (&str, &str) = ("# zerograph:begin", "# zerograph:end"); @@ -752,19 +866,6 @@ mod tests { fn root() -> tempfile::TempDir { tempfile::tempdir().unwrap() } - #[cfg(unix)] - #[test] - fn a_symlinked_target_is_refused_and_what_it_points_at_is_left_alone() { - let dir = root(); - let victim = dir.path().join("elsewhere.json"); - std::fs::write(&victim, "{}").unwrap(); - std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); - std::os::unix::fs::symlink(&victim, dir.path().join(".claude/settings.json")).unwrap(); - let err = install(dir.path(), Target::Claude, "zerograph").unwrap_err().to_string(); - assert!(err.contains("symlink"), "{err}"); - assert_eq!(std::fs::read_to_string(&victim).unwrap(), "{}"); - } - fn read_json(path: &Path) -> serde_json::Value { serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() } @@ -837,12 +938,6 @@ mod tests { assert!(dir.path().join(".cursor/rules/zerograph.mdc").exists()); } - #[cfg(unix)] - fn exited(code: i32, stdout: &str) -> std::process::Output { - use std::os::unix::process::ExitStatusExt; - std::process::Output { status: std::process::ExitStatus::from_raw(code << 8), stdout: stdout.as_bytes().to_vec(), stderr: Vec::new() } - } - #[test] fn a_command_is_split_the_way_a_shell_would() { assert_eq!(split_words("zerograph"), ["zerograph"]); @@ -852,122 +947,6 @@ mod tests { assert!(split_words(" ").is_empty()); } - /// A `claude` whose registry holds one entry per name, shown the way `mcp get` prints it. - #[cfg(unix)] - fn fake_claude(calls: &std::cell::RefCell>>, registry: &std::cell::RefCell>, args: &[&str]) -> Option { - calls.borrow_mut().push(args.iter().map(|a| a.to_string()).collect()); - Some(match args { - ["mcp", "get", name] => match registry.borrow().get(*name) { Some(shown) => exited(0, shown), None => exited(1, "") }, - ["mcp", "remove", name, ..] => { registry.borrow_mut().remove(*name); exited(0, "") } - ["mcp", "add", name, "--", program, rest @ ..] => { - registry.borrow_mut().insert(name.to_string(), format!("{name}:\n Command: {program}\n Args: {}\n", rest.join(" "))); - exited(0, "") - } - _ => exited(1, ""), - }) - } - - #[cfg(unix)] - #[test] - fn the_mcp_server_is_registered_once_a_second_run_leaves_it_and_a_different_one_is_replaced() { - let calls = std::cell::RefCell::new(Vec::>::new()); - let registry = std::cell::RefCell::new(std::collections::BTreeMap::new()); - let claude = |_: &Path, args: &[&str]| fake_claude(&calls, ®istry, args); - let root = Path::new("/work/proj"); - let first = register_mcp(root, "pnpm exec zerograph", &claude); - assert_eq!(first.written, 1); - assert!(calls.borrow().iter().any(|c| c == &["mcp", "add", "zerograph", "--", "pnpm", "exec", "zerograph", "--repo", "/work/proj", "mcp"]), "{:?}", calls.borrow()); - let asked = calls.borrow().len(); - let second = register_mcp(root, "pnpm exec zerograph", &claude); - assert_eq!(second.written, 0); - assert!(calls.borrow()[asked..].iter().all(|c| c[1] == "get"), "the second run only asked: {:?}", calls.borrow()); - assert!(second.notes[0].contains("already registered")); - let moved = register_mcp(Path::new("/work/other"), "pnpm exec zerograph", &claude); - assert_eq!(moved.written, 1, "another repository replaces the entry"); - let removals: Vec<_> = calls.borrow().iter().filter(|c| c[1] == "remove").cloned().collect(); - assert_eq!(removals, [["mcp", "remove", "zerograph", "-s", "local"]], "only the local scope"); - assert!(registry.borrow()["zerograph"].contains("/work/other")); - } - - /// A `claude` that keeps each scope's entries apart, as the CLI does: `get` shows the entry - /// that wins (local over project over user), `remove` without `-s` takes whichever scope - /// holds the name, and `add` writes the local scope. - #[cfg(unix)] - fn scoped_claude(calls: &std::cell::RefCell>>, registry: &std::cell::RefCell>, args: &[&str]) -> Option { - const LOCAL: u8 = 0; - calls.borrow_mut().push(args.iter().map(|a| a.to_string()).collect()); - let winner = |name: &str| registry.borrow().iter().find(|((_, n), _)| n == name).map(|(k, v)| (k.clone(), v.clone())); - Some(match args { - ["mcp", "get", name] => match winner(name) { Some((_, shown)) => exited(0, &shown), None => exited(1, "") }, - ["mcp", "remove", name, "-s", "local"] => match registry.borrow_mut().remove(&(LOCAL, name.to_string())) { Some(_) => exited(0, ""), None => exited(1, "") }, - ["mcp", "remove", name] => match winner(name) { Some((k, _)) => { registry.borrow_mut().remove(&k); exited(0, "") } None => exited(1, "") }, - ["mcp", "add", name, "--", program, rest @ ..] => { - registry.borrow_mut().insert((LOCAL, name.to_string()), format!("{name}:\n Command: {program}\n Args: {}\n", rest.join(" "))); - exited(0, "") - } - _ => exited(1, ""), - }) - } - - #[cfg(unix)] - #[test] - fn a_differing_server_in_another_scope_is_left_and_the_local_one_added_beside_it() { - const USER: u8 = 2; - let calls = std::cell::RefCell::new(Vec::>::new()); - let registry = std::cell::RefCell::new(std::collections::BTreeMap::new()); - let theirs = "zerograph:\n Command: zerograph\n Args: --repo /elsewhere mcp\n".to_string(); - registry.borrow_mut().insert((USER, "zerograph".to_string()), theirs.clone()); - let claude = |_: &Path, args: &[&str]| scoped_claude(&calls, ®istry, args); - let r = register_mcp(Path::new("/work/proj"), "zerograph", &claude); - assert_eq!(r.written, 1, "{:?}", r.notes); - assert_eq!(registry.borrow().get(&(USER, "zerograph".to_string())), Some(&theirs), "the user-scope entry is untouched"); - assert!(registry.borrow()[&(0, "zerograph".to_string())].contains("/work/proj")); - assert!(calls.borrow().iter().filter(|c| c[1] == "remove").all(|c| c.ends_with(&["-s".to_string(), "local".to_string()])), "{:?}", calls.borrow()); - assert_eq!(r.notes.iter().filter(|n| n.contains("another scope")).count(), 1, "{:?}", r.notes); - let again = register_mcp(Path::new("/work/proj"), "zerograph", &claude); - assert_eq!(again.written, 0, "the local entry now wins, and says this: {:?}", again.notes); - } - - #[cfg(unix)] - #[test] - fn a_claude_md_linked_to_agents_md_is_not_written_through_and_does_not_end_the_install() { - let dir = root(); - let before = "# Rules\n\n\nan older stanza\n\n"; - std::fs::write(dir.path().join("AGENTS.md"), before).unwrap(); - std::os::unix::fs::symlink("AGENTS.md", dir.path().join("CLAUDE.md")).unwrap(); - let r = install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert!(r.notes.iter().any(|n| n.contains("symlink")), "{:?}", r.notes); - assert_eq!(std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(), before, "what the link points at is left alone"); - assert!(dir.path().join(".claude/settings.json").exists(), "the rest of the install ran"); - - // `install-agent --claude --codex` runs Codex first, which writes the file the link points at. - install(dir.path(), Target::Codex, "zerograph").unwrap(); - let r = install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert!(!r.notes.iter().any(|n| n.contains("symlink")), "nothing left to write: {:?}", r.notes); - let text = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); - assert_eq!(text.matches(BEGIN).count(), 1, "{text}"); - assert!(!text.contains("an older stanza"), "{text}"); - } - - #[test] - fn a_stanza_in_the_inner_claude_md_stays_the_one_updated_when_a_root_file_appears_later() { - let dir = root(); - std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); - std::fs::write(dir.path().join(".claude/CLAUDE.md"), "\nan older stanza\n\n").unwrap(); - std::fs::write(dir.path().join("CLAUDE.md"), "# House rules\n").unwrap(); - install(dir.path(), Target::Claude, "zerograph").unwrap(); - let inner = std::fs::read_to_string(dir.path().join(".claude/CLAUDE.md")).unwrap(); - assert!(inner.contains(BEGIN) && !inner.contains("an older stanza"), "{inner}"); - assert_eq!(std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(), "# House rules\n", "no second stanza beside the first"); - } - - #[test] - fn a_machine_without_claude_gets_a_note_and_the_command_to_run() { - let r = register_mcp(Path::new("."), "zerograph", &|_, _| None); - assert_eq!(r.written, 0); - assert!(r.notes[0].contains("claude mcp add zerograph -- zerograph --repo . mcp"), "{:?}", r.notes); - } - fn git_repo() -> tempfile::TempDir { let dir = root(); let ok = std::process::Command::new("git").arg("-C").arg(dir.path()).args(["init", "-q"]) @@ -1024,7 +1003,7 @@ mod tests { } }); std::fs::write(&config, serde_json::to_string(&before).unwrap()).unwrap(); - let r = prune_stale_mcp(&config); + let r = prune_mcp(&config, &[]); assert_eq!(r.written, 1, "{r:?}"); assert!(r.notes.iter().any(|n| n.contains(&gone)), "{r:?}"); let after = read_json(&config); @@ -1034,9 +1013,9 @@ mod tests { assert_eq!(after["projects"][&gone]["allowedTools"], serde_json::json!(["x"])); assert_eq!(after["projects"]["/gone/hand-made"], before["projects"]["/gone/hand-made"], "not one `register_mcp` made"); let text = std::fs::read_to_string(&config).unwrap(); - assert_eq!(prune_stale_mcp(&config).written, 0, "nothing left to prune"); + assert_eq!(prune_mcp(&config, &[]).written, 0, "nothing left to prune"); assert_eq!(std::fs::read_to_string(&config).unwrap(), text, "and nothing rewritten"); - assert_eq!(prune_stale_mcp(&dir.path().join("missing.json")).written, 0); + assert_eq!(prune_mcp(&dir.path().join("missing.json"), &[]).written, 0); } #[cfg(unix)] @@ -1047,40 +1026,10 @@ mod tests { let config = dir.path().join(".claude.json"); std::fs::write(&config, r#"{"projects":{"/gone/wt":{"mcpServers":{"zerograph":{"command":"zerograph","args":["--repo","/gone/wt","mcp"]}}}}}"#).unwrap(); std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap(); - assert_eq!(prune_stale_mcp(&config).written, 1); + assert_eq!(prune_mcp(&config, &[]).written, 1); assert_eq!(std::fs::metadata(&config).unwrap().permissions().mode() & 0o777, 0o600); } - #[cfg(unix)] - #[test] - fn install_agent_registers_the_server_in_every_checkout_each_with_its_own_repo() { - let dir = git_repo(); - let main = dir.path().join("main"); - std::fs::create_dir(&main).unwrap(); - git(&main, &["init", "-q"]); - git(&main, &["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x"]); - let wt = dir.path().join("wt"); - git(&main, &["worktree", "add", "-q", "--detach", wt.to_str().unwrap()]); - let (main, wt) = (canonical(&main).unwrap(), canonical(&wt).unwrap()); - let registries = std::cell::RefCell::new(std::collections::BTreeMap::>>::new()); - let adds = std::cell::RefCell::new(Vec::<(PathBuf, Vec)>::new()); - let calls = std::cell::RefCell::new(Vec::new()); - let claude = |cwd: &Path, args: &[&str]| { - if args.get(1) == Some(&"add") { adds.borrow_mut().push((cwd.to_path_buf(), args.iter().map(|a| a.to_string()).collect())); } - let mut all = registries.borrow_mut(); - let registry = all.entry(cwd.to_path_buf()).or_default(); - fake_claude(&calls, registry, args) - }; - let done = register_mcp_everywhere(&wt, "zerograph", &claude); - assert_eq!(done.iter().map(|(c, _)| c.clone()).collect::>(), [main.clone(), wt.clone()]); - let adds = adds.into_inner(); - assert_eq!(adds.len(), 2, "{adds:?}"); - for (cwd, args) in &adds { - let repo = args.iter().position(|a| a == "--repo").map(|i| PathBuf::from(&args[i + 1])); - assert_eq!(repo.as_ref(), Some(cwd), "each checkout's server reads that checkout: {args:?}"); - } - } - #[cfg(unix)] #[test] fn a_config_changed_while_pruning_is_left_for_the_next_run() { @@ -1103,41 +1052,11 @@ mod tests { std::fs::write(&real, r#"{"projects":{"/gone/wt":{"mcpServers":{"zerograph":{"command":"zerograph","args":["--repo","/gone/wt","mcp"]}}}}}"#).unwrap(); let link = dir.path().join(".claude.json"); std::os::unix::fs::symlink(&real, &link).unwrap(); - assert_eq!(prune_stale_mcp(&link).written, 1); + assert_eq!(prune_mcp(&link, &[]).written, 1); assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); assert_eq!(read_json(&real)["projects"]["/gone/wt"]["mcpServers"], serde_json::json!({})); } - #[test] - fn the_codex_hook_from_a_linked_worktree_is_the_main_worktrees_and_one_already_there_is_kept() { - let dir = git_repo(); - let main = dir.path().join("main"); - std::fs::create_dir(&main).unwrap(); - git(&main, &["init", "-q"]); - git(&main, &["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x"]); - let wt = dir.path().join("wt"); - git(&main, &["worktree", "add", "-q", "--detach", wt.to_str().unwrap()]); - let main = canonical(&main).unwrap(); - let (path, r) = codex_hook(&wt, "pnpm exec zerograph").unwrap(); - assert_eq!(path, main.join(".codex/hooks/zerograph-hook.mjs")); - assert_eq!(r.written, 2, "{r:?}"); - assert!(std::fs::read_to_string(&path).unwrap().contains("const COMMAND = \"pnpm exec zerograph\";")); - assert!(main.join(".codex/hooks/rule.txt").exists()); - assert_eq!(codex_hook(&wt, "pnpm exec zerograph").unwrap().1.written, 0, "a second run writes nothing"); - - std::fs::write(&path, "// the main checkout's own\n").unwrap(); - let (_, r) = codex_hook(&wt, "pnpm exec zerograph").unwrap(); - assert_eq!(r.written, 0); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "// the main checkout's own\n"); - assert!(r.notes.iter().any(|n| n.contains("left as it is")), "{r:?}"); - - let (from_main, r) = codex_hook(&main, "zerograph").unwrap(); - assert_eq!(from_main, path, "the main worktree names its own copy"); - assert_eq!(r.written, 0); - let lone = root(); - assert_eq!(codex_hook(lone.path(), "zerograph").unwrap().0, canonical(lone.path()).unwrap().join(".codex/hooks/zerograph-hook.mjs")); - } - /// The block as the last release that wrote one spelled it. const OLD_BLOCK: &str = "# zerograph:begin\n# Brings zerograph's index in line with what this pull or checkout moved, in the background.\nif [ \"$3\" != \"0\" ]; then zerograph update --detach >/dev/null 2>&1 || true; fi\n# zerograph:end\n"; @@ -1187,266 +1106,324 @@ mod tests { assert!(r.notes.is_empty(), "{r:?}"); } + /// Measured with prettier 3.9.6, under no config and under a consumer's: the one change it made + /// to the 0.5.0 stanza was a blank line after the begin marker, because a comment is a block of + /// its own. A stanza carrying that line is left alone by the formatter and by a re-run of this. #[test] - fn a_path_with_a_space_is_quoted_for_hand_use_and_matches_what_claude_printed() { - assert_eq!(shell_word("/a b/it's"), "'/a b/it'\\''s'"); - assert_eq!(shell_word("--repo"), "--repo"); - assert!(said_in_order("Command: zerograph\nArgs: --repo /a b mcp", "zerograph", &["--repo", "/a b", "mcp"])); - assert!(!said_in_order("Args: mcp --repo /a b", "zerograph", &["--repo", "/a b", "mcp"])); - assert!(!said_in_order("Command: zerograph\nArgs: --repo /a b2 mcp", "zerograph", &["--repo", "/a b", "mcp"]), "another root"); + fn the_stanza_is_what_prettier_writes_and_a_rerun_keeps_it() { + let block = with_command(STANZA, "zerograph"); + assert!(block.starts_with("\n\n## zerograph\n"), "{block}"); + + let fresh = merged_instructions("# House rules\n", "zerograph"); + assert_eq!(fresh, format!("# House rules\n\n{block}")); + assert_eq!(merged_instructions(&fresh, "zerograph"), fresh, "a re-run is a fixed point"); + + // A block 0.5.0 wrote gains the line; the text after the end marker stays the repository's. + let old = "# House rules\n\n\n## zerograph\nold\n\n\n## After\n"; + let upgraded = merged_instructions(old, "zerograph"); + assert_eq!(upgraded, format!("# House rules\n\n{}\n\n## After\n", block.trim_end())); + assert_eq!(merged_instructions(&upgraded, "zerograph"), upgraded); + } + + fn exited(code: i32, stdout: &str) -> std::process::Output { + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; + #[cfg(windows)] + use std::os::windows::process::ExitStatusExt; + #[cfg(unix)] + let status = std::process::ExitStatus::from_raw(code << 8); + #[cfg(windows)] + let status = std::process::ExitStatus::from_raw(code as u32); + std::process::Output { status, stdout: stdout.as_bytes().to_vec(), stderr: Vec::new() } } #[test] - fn installing_twice_writes_the_same_tree_and_says_it_changed_nothing() { - let dir = root(); - let first = install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert!(first.written > 0, "{first:?}"); - for rel in [".claude/hooks/zerograph-hook.mjs", ".claude/skills/zerograph/SKILL.md", - ".claude/agents/repo-scout.md", ".claude/settings.json", ".claude/CLAUDE.md"] { - assert!(dir.path().join(rel).exists(), "{rel} was not written"); + fn each_harness_installs_the_plugin_through_its_own_cli_marketplace_first() { + for (target, wanted) in [ + (Target::Claude, vec!["claude plugin marketplace add devmaxxx/zerograph".to_string(), "claude plugin marketplace update zerograph".to_string(), + "claude plugin install zerograph@zerograph".to_string(), "claude plugin update zerograph@zerograph".to_string()]), + (Target::Codex, vec![format!("codex plugin marketplace add devmaxxx/zerograph --ref v{VERSION}"), "codex plugin add zerograph@zerograph".to_string()]), + ] { + let calls = std::cell::RefCell::new(Vec::::new()); + let run = |program: &str, _: &Path, args: &[String]| { calls.borrow_mut().push(format!("{program} {}", args.join(" "))); Some(exited(0, "")) }; + let (ok, r) = install_plugin(Path::new("."), target, &run); + assert!(ok, "{r:?}"); + assert_eq!(calls.into_inner(), wanted); } - let second = install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert_eq!(second.written, 0, "a second install is a no-op: {second:?}"); } #[test] - fn the_skill_names_the_release_that_wrote_it_and_another_release_says_it_replaced_it() { - let dir = root(); - install(dir.path(), Target::Claude, "zerograph").unwrap(); - let path = dir.path().join(".claude/skills/zerograph/SKILL.md"); - let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.starts_with("---\nname: zerograph\n"), "the name is the directory's: {text}"); - assert_eq!(skill_version(&text), Some(VERSION)); - std::fs::write(&path, text.replace(&format!("version: \"{VERSION}\""), "version: \"0.0.1\"")).unwrap(); - let r = install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert_eq!(r.written, 1, "{r:?}"); - assert!(r.notes.iter().any(|n| n.contains(&format!("rewritten from zerograph 0.0.1 to {VERSION}"))), "{r:?}"); - assert_eq!(std::fs::read_to_string(&path).unwrap(), text); + fn a_machine_without_the_cli_gets_the_commands_to_run_and_no_install() { + let (ok, r) = install_plugin(Path::new("."), Target::Claude, &|_, _, _| None); + assert!(!ok); + assert!(r.notes[0].contains("`claude` is not on PATH"), "{:?}", r.notes); + assert!(r.notes[0].contains(" claude plugin marketplace add devmaxxx/zerograph\n claude plugin marketplace update zerograph\n claude plugin install zerograph@zerograph\n claude plugin update zerograph@zerograph"), "{:?}", r.notes); } - /// The skill was `repo-query` until 0.6.4. What a release wrote under that name goes, under - /// either harness and whatever command it was installed with; a skill a team keeps under the - /// name stays. #[test] - fn the_repo_query_skill_an_earlier_release_wrote_is_removed_and_one_a_team_edited_is_kept() { - for target in [Target::Claude, Target::Codex] { - let dir = root(); - let old = dir.path().join(target.dir()).join("skills/repo-query"); - std::fs::create_dir_all(&old).unwrap(); - std::fs::write(old.join("SKILL.md"), with_command(LEGACY_SKILL, "pnpm exec zerograph")).unwrap(); - let r = install(dir.path(), target, "zerograph").unwrap(); - assert!(!old.exists(), "{target:?}: {r:?}"); - assert!(r.notes.iter().any(|n| n.contains("removed") && n.contains("repo-query")), "{r:?}"); - assert!(dir.path().join(target.dir()).join("skills/zerograph/SKILL.md").exists()); - } - let dir = root(); - let old = dir.path().join(".claude/skills/repo-query"); - std::fs::create_dir_all(&old).unwrap(); - let edited = with_command(LEGACY_SKILL, "zerograph") + "\nOur own rule.\n"; - std::fs::write(old.join("SKILL.md"), &edited).unwrap(); - let r = install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert_eq!(std::fs::read_to_string(old.join("SKILL.md")).unwrap(), edited); - assert!(r.notes.iter().any(|n| n.contains("repo-query") && n.contains("left")), "{r:?}"); - std::fs::write(old.join("SKILL.md"), with_command(LEGACY_SKILL, "zerograph")).unwrap(); - std::fs::write(old.join("notes.md"), "ours\n").unwrap(); - install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert!(old.join("notes.md").exists() && old.join("SKILL.md").exists(), "a directory with files of its own is a team's"); - } - - /// An installer that rewrote a repository's settings file would be a worse citizen than no - /// installer: the hooks a team wrote are the reason the file exists. + fn a_failed_step_stops_the_install_and_says_what_the_cli_said() { + let calls = std::cell::RefCell::new(0); + let run = |_: &str, _: &Path, _: &[String]| { + *calls.borrow_mut() += 1; + Some(std::process::Output { stderr: b"network down".to_vec(), ..exited(1, "") }) + }; + let (ok, r) = install_plugin(Path::new("."), Target::Codex, &run); + assert!(!ok); + assert_eq!(calls.into_inner(), 1, "the plugin is not added from a marketplace that failed"); + assert!(r.notes[0].contains("network down") && r.notes[0].contains("codex plugin add zerograph@zerograph"), "{:?}", r.notes); + } + #[test] - fn an_existing_settings_file_keeps_its_own_hooks() { - let dir = root(); - std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); - std::fs::write(dir.path().join(".claude/settings.json"), - r#"{"model":"opus","hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo mine"}]}]}}"#).unwrap(); - install(dir.path(), Target::Claude, "zerograph").unwrap(); - let v: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap()).unwrap(); - assert_eq!(v["model"], "opus", "a key that is not ours is untouched"); - assert!(v["hooks"]["Stop"].is_array(), "the repository's own hook survived"); - assert_eq!(v["hooks"]["SessionStart"].as_array().unwrap().len(), 1); - assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], "Bash|Grep|Glob|Agent"); - } - - /// The upgrade case: an older entry of ours is replaced, not joined by a second copy that - /// would fire the hook twice on every event. + fn a_codex_marketplace_pinned_to_an_older_release_is_repinned_to_this_one() { + let calls = std::cell::RefCell::new(Vec::::new()); + let run = |_: &str, _: &Path, args: &[String]| { + let call = args.join(" "); + let first = !calls.borrow().iter().any(|c| c == &call); + calls.borrow_mut().push(call.clone()); + if call.starts_with("plugin marketplace add") && first { + return Some(std::process::Output { stderr: b"Error: marketplace 'zerograph' is already added from a different source; remove it before adding this source".to_vec(), ..exited(1, "") }); + } + Some(exited(0, "")) + }; + let (ok, r) = install_plugin(Path::new("."), Target::Codex, &run); + assert!(ok, "{r:?}"); + let add = format!("plugin marketplace add devmaxxx/zerograph --ref v{VERSION}"); + assert_eq!(calls.into_inner(), vec![add.clone(), "plugin marketplace remove zerograph".to_string(), add, "plugin add zerograph@zerograph".to_string()]); + } + #[test] - fn an_older_entry_of_ours_is_replaced_rather_than_doubled() { - let dir = root(); - std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); - std::fs::write(dir.path().join(".claude/settings.json"), r#"{"hooks":{"SessionStart":[ - {"matcher":"startup","hooks":[{"type":"command","command":"node /old/path/zerograph-hook.mjs"}]} - ]}}"#).unwrap(); - install(dir.path(), Target::Claude, "zerograph").unwrap(); - let v: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap()).unwrap(); - let arr = v["hooks"]["SessionStart"].as_array().unwrap(); - assert_eq!(arr.len(), 1, "{arr:?}"); - assert!(arr[0]["hooks"][0]["command"].as_str().unwrap().contains("CLAUDE_PROJECT_DIR")); - } - - /// A narrowed matcher is a choice someone made, such as keeping the hook off Bash calls; a - /// re-run that put the default back would undo it on every upgrade. + fn any_other_failure_removes_nothing() { + let calls = std::cell::RefCell::new(Vec::::new()); + let run = |_: &str, _: &Path, args: &[String]| { + calls.borrow_mut().push(args.join(" ")); + Some(std::process::Output { stderr: b"network down".to_vec(), ..exited(1, "") }) + }; + let (ok, _) = install_plugin(Path::new("."), Target::Codex, &run); + assert!(!ok); + assert!(!calls.into_inner().iter().any(|c| c.contains("remove"))); + } + + /// What a retirement of an older install reaches in a test: no git, so nothing is committed. + const OLD: Retire = Retire { committed: false, machine_hooks_gone: true }; + + /// The hook as 0.6.4 wrote it for `command`: the command resolved, the stamp under the shebang. + fn old_hook(command: &str) -> String { + let script = LEGACY_HOOK_TEXT.replacen("const COMMAND = \"zerograph\";", &format!("const COMMAND = {};", serde_json::Value::from(command)), 1); + let (shebang, rest) = script.split_once('\n').unwrap(); + format!("{shebang}\n// zerograph 0.6.4 wrote: SessionStart=startup|resume|clear|compact\n{rest}") + } + + /// A repository as `install-agent --claude` and `--codex` of 0.7.0's predecessors left it. + fn older_install(root: &Path, command: &str) { + let w = |rel: &str, text: &str| { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + }; + for dir in [".claude", ".codex"] { + w(&format!("{dir}/hooks/zerograph-hook.mjs"), &old_hook(command)); + w(&format!("{dir}/hooks/rule.txt"), &with_command(LEGACY_RULE, command)); + w(&format!("{dir}/skills/zerograph/SKILL.md"), &with_command(LEGACY_SKILL, command).replace("{{version}}", "0.6.4")); + } + w(".claude/agents/repo-scout.md", &with_command(LEGACY_SCOUT, command)); + let entry = |m: &str| serde_json::json!({ "matcher": m, "hooks": [{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/zerograph-hook.mjs\"", "timeout": 10 }] }); + let theirs = serde_json::json!({ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./lint.sh" }] }); + w(".claude/settings.json", &serde_json::to_string_pretty(&serde_json::json!({ + "permissions": { "allow": ["Bash(ls)"] }, + "hooks": { "SessionStart": [entry("startup|resume|clear|compact")], "PreToolUse": [entry("Bash|Grep|Glob|Agent"), theirs] }, + })).unwrap()); + w("CLAUDE.md", &format!("# House rules\n\nBe kind.\n\n{}\n## After\n", with_command(STANZA, command))); + w("AGENTS.md", &with_command(STANZA, command)); + } + #[test] - fn a_matcher_set_on_our_entry_survives_a_re_run() { + fn a_re_run_over_an_older_install_takes_out_what_it_wrote_and_keeps_the_rest() { let dir = root(); - std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); - std::fs::write(dir.path().join(".claude/settings.json"), r#"{"hooks":{"PreToolUse":[ - {"matcher":"Grep|Glob|Agent","hooks":[{"type":"command","command":"node /old/path/zerograph-hook.mjs"}]} - ]}}"#).unwrap(); - install(dir.path(), Target::Claude, "zerograph").unwrap(); - let v: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap()).unwrap(); - let arr = v["hooks"]["PreToolUse"].as_array().unwrap(); - assert_eq!(arr.len(), 1, "{arr:?}"); - assert_eq!(arr[0]["matcher"], "Grep|Glob|Agent"); - assert!(arr[0]["hooks"][0]["command"].as_str().unwrap().contains("CLAUDE_PROJECT_DIR"), "the rest of the entry is still ours to update"); - assert_eq!(v["hooks"]["SessionStart"][0]["matcher"], "startup|resume|clear|compact", "a fresh entry gets the default"); - } - - /// A matcher the stamp says we wrote is ours to move when the default changes; any other stays. + older_install(dir.path(), "pnpm exec zerograph"); + let r = retire_repo_install(dir.path(), Target::Claude, OLD); + assert!(!dir.path().join(".claude/hooks").exists(), "{r:?}"); + assert!(!dir.path().join(".claude/skills").exists(), "{r:?}"); + assert!(!dir.path().join(".claude/agents").exists(), "{r:?}"); + let settings = read_json(&dir.path().join(".claude/settings.json")); + assert_eq!(settings["permissions"]["allow"], serde_json::json!(["Bash(ls)"])); + assert_eq!(settings["hooks"], serde_json::json!({ "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./lint.sh" }] }] })); + assert_eq!(std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(), "# House rules\n\nBe kind.\n\n## After\n"); + assert!(dir.path().join("AGENTS.md").exists(), "Codex's stanza is the Codex pass's"); + + let r = retire_repo_install(dir.path(), Target::Codex, OLD); + assert!(!dir.path().join(".codex").exists(), "{r:?}"); + assert!(!dir.path().join("AGENTS.md").exists(), "a file that held only the stanza goes"); + + let again = retire_repo_install(dir.path(), Target::Claude, OLD); + assert_eq!((again.written, again.notes.len()), (0, 0), "{again:?}"); + } + #[test] - fn a_matcher_the_stamp_records_moves_to_the_new_default() { - let settings = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash|Grep","hooks":[{"type":"command","command":"node zerograph-hook.mjs"}]}]}}"#; - let ours = merged_settings(settings, "h", "#!/usr/bin/env node\n// zerograph 0.6.4 wrote: PreToolUse=Bash|Grep\n").unwrap(); - assert!(ours.contains("\"Bash|Grep|Glob|Agent\""), "{ours}"); - let theirs = merged_settings(settings, "h", "#!/usr/bin/env node\n// zerograph 0.6.4 wrote: PreToolUse=Bash\n").unwrap(); - assert!(theirs.contains("\"Bash|Grep\""), "{theirs}"); + fn settings_that_held_only_our_hooks_go_and_a_team_edited_skill_or_scout_stays() { + let dir = root(); + older_install(dir.path(), "zerograph"); + let settings = dir.path().join(".claude/settings.json"); + std::fs::write(&settings, r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node zerograph-hook.mjs"}]}]}}"#).unwrap(); + std::fs::write(dir.path().join(".claude/skills/zerograph/SKILL.md"), "---\nname: zerograph\n---\nOur own words.\n").unwrap(); + std::fs::write(dir.path().join(".claude/agents/repo-scout.md"), "a scout of our own\n").unwrap(); + let r = retire_repo_install(dir.path(), Target::Claude, OLD); + assert!(!settings.exists(), "{r:?}"); + assert!(dir.path().join(".claude/skills/zerograph/SKILL.md").exists()); + assert!(dir.path().join(".claude/agents/repo-scout.md").exists()); + assert_eq!(r.notes.iter().filter(|n| n.contains("was left")).count(), 2, "{r:?}"); } + #[cfg(unix)] #[test] - fn the_stanza_lands_in_a_root_instructions_file_when_there_is_one_and_replaces_only_itself() { + fn the_hook_script_stays_while_an_entry_still_runs_it() { let dir = root(); - std::fs::write(dir.path().join("CLAUDE.md"), "# House rules\n\nRun the tests.\n").unwrap(); - install(dir.path(), Target::Claude, "pnpm exec zerograph").unwrap(); - let text = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(); - assert!(text.starts_with("# House rules\n\nRun the tests.\n"), "{text}"); - assert!(text.contains("pnpm exec zerograph ask "), "{text}"); - assert!(!dir.path().join(".claude/CLAUDE.md").exists(), "the root file was the one to use"); - - // A re-install under a different invocation name replaces the block and nothing else. - install(dir.path(), Target::Claude, "zerograph").unwrap(); - let text = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(); - assert!(text.starts_with("# House rules\n\nRun the tests.\n"), "{text}"); - assert!(!text.contains("pnpm exec"), "the old block went: {text}"); - assert_eq!(text.matches(BEGIN).count(), 1, "one block, not two: {text}"); - } - - /// Read off the CLI rather than assumed — `agent/codex.md` records how. Codex reads the - /// repository's root `AGENTS.md`, so a `.codex/AGENTS.md` is a file nothing would ever open; - /// and its hooks live at `CODEX_HOME`, so no repository-scoped hooks file is written at all. + older_install(dir.path(), "zerograph"); + let settings = dir.path().join(".claude/settings.json"); + std::fs::rename(&settings, dir.path().join("shared.json")).unwrap(); + std::os::unix::fs::symlink(dir.path().join("shared.json"), &settings).unwrap(); + let r = retire_repo_install(dir.path(), Target::Claude, OLD); + assert!(dir.path().join(".claude/hooks/zerograph-hook.mjs").exists(), "{r:?}"); + assert!(dir.path().join(".claude/hooks/rule.txt").exists(), "{r:?}"); + assert!(r.notes.iter().any(|n| n.contains("hook entries that run it are still in place")), "{r:?}"); + + let r = retire_repo_install(dir.path(), Target::Codex, Retire { machine_hooks_gone: false, ..OLD }); + assert!(dir.path().join(".codex/hooks/zerograph-hook.mjs").exists(), "Codex's entries live in its home: {r:?}"); + } + #[test] - fn codex_gets_its_stanza_at_the_root_and_no_repository_hooks_file() { + fn codex_entries_left_in_place_keep_the_script_they_run() { let dir = root(); - install(dir.path(), Target::Codex, "zerograph").unwrap(); - assert!(dir.path().join(".codex/hooks/zerograph-hook.mjs").exists(), "something to point at"); - assert!(dir.path().join(".codex/skills/zerograph/SKILL.md").exists()); - let stanza = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); - assert!(stanza.contains("zerograph ask "), "{stanza}"); - assert!(!dir.path().join(".codex/AGENTS.md").exists(), "nothing reads that path"); - assert!(!dir.path().join(".codex/hooks.json").exists(), "hooks are CODEX_HOME-relative"); - assert!(!dir.path().join(".codex/agents").exists(), "the scout is a Claude Code definition"); + assert!(retire_codex_hooks(dir.path()).0, "no hooks file runs nothing"); + std::fs::write(dir.path().join("hooks.json"), "{ not json").unwrap(); + let (gone, r) = retire_codex_hooks(dir.path()); + assert!(!gone, "{r:?}"); } #[test] - fn the_codex_block_names_three_events_and_not_the_interceptor() { - let v: serde_json::Value = serde_json::from_str(&codex_hooks_block("/abs/hook.mjs")).unwrap(); - let events: Vec<&String> = v["hooks"].as_object().unwrap().keys().collect(); - assert_eq!(events.len(), 3, "{events:?}"); - assert!(v["hooks"]["PreToolUse"].is_null(), "the interceptor is not carried over blind"); - assert!(v["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str().unwrap().contains("/abs/hook.mjs")); + fn an_edited_hook_script_stays() { + let dir = root(); + older_install(dir.path(), "pnpm exec zerograph"); + let script = dir.path().join(".claude/hooks/zerograph-hook.mjs"); + assert!(generated_hook(&std::fs::read_to_string(&script).unwrap()), "every command the install was given"); + assert!(generated_hook(LEGACY_HOOK_TEXT), "0.6.0-0.6.3 wrote no stamp"); + std::fs::write(&script, old_hook("zerograph") + "// ours\n").unwrap(); + let r = retire_repo_install(dir.path(), Target::Claude, OLD); + assert!(script.exists(), "{r:?}"); + assert!(r.notes.iter().any(|n| n.contains("is not the hook an earlier zerograph wrote")), "{r:?}"); } - /// A checkout directory named with a `'` must not close the quoting once the block is pasted; - /// on Windows `cmd` reads no single quote as quoting at all. #[test] - fn the_codex_hook_command_quotes_the_path_for_the_shell_that_runs_it() { - let posix = "/home/o'brien/my repo/.codex/hooks/zerograph-hook.mjs"; - let cmd = node_command(posix, false); - #[cfg(unix)] - { - let word = cmd.strip_prefix("node ").unwrap(); - let out = std::process::Command::new("sh").arg("-c").arg(format!("printf %s {word}")).output().unwrap(); - assert_eq!(String::from_utf8_lossy(&out.stdout), posix, "{cmd}"); + fn files_git_tracks_stay_for_the_team_until_asked_for() { + let dir = root(); + let git = |args: &[&str]| assert!(std::process::Command::new("git").arg("-C").arg(dir.path()).args(args).output().unwrap().status.success()); + git(&["init", "-q"]); + older_install(dir.path(), "zerograph"); + git(&["add", ".claude/hooks/zerograph-hook.mjs", ".claude/settings.json", "CLAUDE.md"]); + let r = retire_repo_install(dir.path(), Target::Claude, OLD); + for rel in [".claude/settings.json", "CLAUDE.md"] { + assert!(dir.path().join(rel).exists(), "{rel}: {r:?}"); + assert!(r.committed.iter().any(|p| Path::new(p).ends_with(rel)), "{rel}: {r:?}"); } - assert!(cmd.starts_with("node "), "{cmd}"); - assert_eq!(node_command(r"C:\Users\o'brien\repo\hook.mjs", true), r#"node "C:\Users\o'brien\repo\hook.mjs""#); + assert!(dir.path().join(".claude/hooks/zerograph-hook.mjs").exists(), "the entries kept in settings still run it: {r:?}"); + assert!(std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap().contains(BEGIN), "a tracked file is not rewritten either"); + assert!(!dir.path().join(".claude/skills/zerograph").exists(), "an untracked file still goes: {r:?}"); + + let r = retire_repo_install(dir.path(), Target::Claude, Retire { committed: true, ..OLD }); + assert!(!dir.path().join(".claude/hooks").exists(), "{r:?}"); + assert!(!std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap().contains(LEGACY_HOOK), "{r:?}"); + assert!(!std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap().contains(BEGIN), "{r:?}"); + assert!(r.committed.is_empty()); } #[test] - fn a_settings_file_that_is_not_json_is_refused_by_name_rather_than_overwritten() { + fn opencode_keeps_the_agents_md_stanza_it_still_reads() { let dir = root(); - std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); - std::fs::write(dir.path().join(".claude/settings.json"), "not json").unwrap(); - let err = install(dir.path(), Target::Claude, "zerograph").unwrap_err().to_string(); - assert!(err.contains("not JSON"), "{err}"); - assert_eq!(std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap(), "not json"); + older_install(dir.path(), "zerograph"); + install_editor(dir.path(), Editor::Opencode, "zerograph").unwrap(); + retire_repo_install(dir.path(), Target::Codex, OLD); + assert!(std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap().contains(BEGIN)); } - /// `--command` is how this repository runs the binary, and a pnpm workspace has no bare - /// `zerograph` on PATH: a file that still says the bare name tells an agent to run nothing. - /// So every text either harness installs carries it, and not only the stanza. + #[cfg(unix)] #[test] - fn the_command_reaches_every_file_either_harness_installs() { - for (target, texts) in [ - (Target::Claude, &[".claude/hooks/rule.txt", ".claude/skills/zerograph/SKILL.md", - ".claude/agents/repo-scout.md", ".claude/CLAUDE.md"][..]), - (Target::Codex, &[".codex/hooks/rule.txt", ".codex/skills/zerograph/SKILL.md", "AGENTS.md"][..]), - ] { - let dir = root(); - install(dir.path(), target, "pnpm exec zerograph").unwrap(); - for rel in texts { - let text = std::fs::read_to_string(dir.path().join(rel)).unwrap(); - assert!(text.contains("`pnpm exec zerograph "), "{rel} names the command: {text}"); - assert!(!text.contains("`zerograph ") && !text.contains("{{command}}") && !text.contains("{{version}}"), "{rel} still names the bare binary: {text}"); - } - let script = std::fs::read_to_string(dir.path().join(target.dir()).join("hooks/zerograph-hook.mjs")).unwrap(); - assert!(script.contains("\nconst COMMAND = \"pnpm exec zerograph\";\n"), "{target:?}: the hook's hints read one declaration"); - } + fn a_linked_claude_md_is_not_written_through() { + let dir = root(); + older_install(dir.path(), "zerograph"); + std::fs::remove_file(dir.path().join("CLAUDE.md")).unwrap(); + std::os::unix::fs::symlink("AGENTS.md", dir.path().join("CLAUDE.md")).unwrap(); + let r = retire_repo_install(dir.path(), Target::Claude, OLD); + assert!(r.notes.iter().any(|n| n.contains("is a symlink")), "{r:?}"); + assert!(std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap().contains(BEGIN)); } - /// A command is whatever a person typed, and the hook is JavaScript: it goes in as one JSON - /// string, which JavaScript reads as a string literal, so nothing in it can run. #[test] - fn the_hook_takes_the_command_as_one_json_string_and_the_default_as_it_ships() { - assert_eq!(HOOK.matches(HOOK_COMMAND).count(), 1, "the declaration the installer replaces is in the hook once"); - assert_eq!(hook("zerograph").replacen(&stamp(), "", 1), HOOK, "the default installs the template byte for byte, under the stamp"); - let script = hook(r#"pnpm exec "re'po\graph" `x` ${y}"#); - assert!(script.contains(r#"const COMMAND = "pnpm exec \"re'po\\graph\" `x` ${y}";"#), "the command is one string literal"); - assert_eq!(script.matches("const COMMAND = ").count(), 1); + fn the_stanza_comes_out_whole_wherever_it_sits() { + let block = with_command(STANZA, "zerograph"); + assert_eq!(without_stanza(&block).unwrap(), ""); + assert_eq!(without_stanza(&format!("# A\n\n{block}")).unwrap(), "# A\n"); + assert_eq!(without_stanza(&format!("{block}\n# B\n")).unwrap(), "# B\n"); + assert_eq!(without_stanza(&format!("# A\n\n{}\n\n# B\n", block.trim_end())).unwrap(), "# A\n\n# B\n"); + assert_eq!(without_stanza("# nothing of ours\n"), None); } - /// An upgrade is a re-run, and a re-run under the same command writes nothing — for a command - /// other than the default, and for both harnesses. #[test] - fn a_second_install_under_a_named_command_writes_nothing_for_either_harness() { - for target in [Target::Claude, Target::Codex] { - let dir = root(); - std::fs::write(dir.path().join(target.instructions()), "# House rules\n").unwrap(); - assert!(install(dir.path(), target, "pnpm exec zerograph").unwrap().written > 0); - let again = install(dir.path(), target, "pnpm exec zerograph").unwrap(); - assert_eq!(again.written, 0, "{target:?}: {again:?}"); - } + fn the_codex_hooks_file_loses_only_the_entries_that_ran_our_script() { + let dir = root(); + let path = dir.path().join("hooks.json"); + std::fs::write(&path, r#"{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"node '/r/.codex/hooks/zerograph-hook.mjs'"}]},{"hooks":[{"type":"command","command":"gitnexus"}]}],"PostToolUse":[{"hooks":[{"type":"command","command":"node /r/.codex/hooks/zerograph-hook.mjs"}]}]}}"#).unwrap(); + let r = retire_codex_hooks(dir.path()).1; + assert_eq!(r.written, 1, "{r:?}"); + assert_eq!(read_json(&path), serde_json::json!({ "hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "gitnexus" }] }] } })); + assert_eq!(retire_codex_hooks(dir.path()).1.written, 0); + assert_eq!(retire_codex_hooks(&dir.path().join("none")).1.written, 0); } - /// Measured with prettier 3.9.6, under no config and under a consumer's: the one change it made - /// to the 0.5.0 stanza was a blank line after the begin marker, because a comment is a block of - /// its own. A stanza carrying that line is left alone by the formatter and by a re-run of this. #[test] - fn the_stanza_is_what_prettier_writes_and_a_rerun_keeps_it() { - let block = with_command(STANZA, "zerograph"); - assert!(block.starts_with("\n\n## zerograph\n"), "{block}"); + fn a_command_beside_ours_in_one_matcher_group_stays() { + let shared = r#"{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"gitnexus"},{"type":"command","command":"node /r/.codex/hooks/zerograph-hook.mjs"}]}]}}"#; + let rest: serde_json::Value = serde_json::from_str(&without_hook_entries(shared).unwrap().unwrap()).unwrap(); + assert_eq!(rest, serde_json::json!({ "hooks": { "SessionStart": [{ "matcher": "startup", "hooks": [{ "type": "command", "command": "gitnexus" }] }] } })); + } - let fresh = merged_instructions("# House rules\n", "zerograph"); - assert_eq!(fresh, format!("# House rules\n\n{block}")); - assert_eq!(merged_instructions(&fresh, "zerograph"), fresh, "a re-run is a fixed point"); + #[cfg(unix)] + #[test] + fn a_linked_hooks_file_is_rewritten_where_it_points_and_a_linked_settings_file_is_left() { + let dir = root(); + let dotfiles = dir.path().join("dotfiles.json"); + std::fs::write(&dotfiles, r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node /r/.codex/hooks/zerograph-hook.mjs"}]}]},"x":1}"#).unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir(&home).unwrap(); + std::os::unix::fs::symlink(&dotfiles, home.join("hooks.json")).unwrap(); + assert_eq!(retire_codex_hooks(&home).1.written, 1); + assert!(is_symlink(&home.join("hooks.json")), "the link stays a link"); + assert_eq!(read_json(&dotfiles), serde_json::json!({ "x": 1 })); + + let repo = dir.path().join("repo"); + std::fs::create_dir_all(repo.join(".claude")).unwrap(); + let shared = dir.path().join("shared.json"); + std::fs::write(&shared, r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node zerograph-hook.mjs"}]}]}}"#).unwrap(); + std::os::unix::fs::symlink(&shared, repo.join(".claude").join("settings.json")).unwrap(); + let r = retire_repo_install(&repo, Target::Claude, OLD); + assert!(is_symlink(&repo.join(".claude").join("settings.json")), "{r:?}"); + assert!(r.notes.iter().any(|n| n.contains("is a symlink")), "{r:?}"); + } - // A block 0.5.0 wrote gains the line; the text after the end marker stays the repository's. - let old = "# House rules\n\n\n## zerograph\nold\n\n\n## After\n"; - let upgraded = merged_instructions(old, "zerograph"); - assert_eq!(upgraded, format!("# House rules\n\n{}\n\n## After\n", block.trim_end())); - assert_eq!(merged_instructions(&upgraded, "zerograph"), upgraded); + #[test] + fn the_server_an_earlier_release_registered_for_a_live_checkout_goes_when_the_plugin_replaces_it() { + let dir = root(); + let live = dir.path().to_str().unwrap().to_string(); + let other = dir.path().join("other"); + std::fs::create_dir(&other).unwrap(); + let other = other.to_str().unwrap().to_string(); + let ours = |p: &str| serde_json::json!({ "command": "zerograph", "args": ["--repo", p, "mcp"] }); + let config = dir.path().join(".claude.json"); + std::fs::write(&config, serde_json::to_string(&serde_json::json!({ + "projects": { live.clone(): { "mcpServers": { "zerograph": ours(&live) } }, other.clone(): { "mcpServers": { "zerograph": ours(&other) } } } + })).unwrap()).unwrap(); + let r = prune_mcp(&config, &[PathBuf::from(&live)]); + assert!(r.notes.iter().any(|n| n.contains("the plugin's server replaces it")), "{r:?}"); + let after = read_json(&config); + assert_eq!(after["projects"][&live]["mcpServers"], serde_json::json!({})); + assert_eq!(after["projects"][&other]["mcpServers"]["zerograph"], ours(&other), "another repository's is not this install's"); } } diff --git a/src/lib.rs b/src/lib.rs index 690961dd..eed358ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,13 +153,13 @@ enum Cmd { Verify { #[arg(long)] json: bool, }, - /// Writes the agent-facing surface into this repository: one hook dispatched on four events, a - /// skill, a subagent definition and a ten-line stanza in the instructions file. Idempotent — - /// a second run rewrites the same bytes and says it changed nothing. + /// Wires a coding agent to this repository's index. Claude Code and Codex get the zerograph + /// plugin through their own `plugin` commands, and an older install's files are taken out once + /// it is in; the editors get an MCP entry and the stanza. Idempotent. InstallAgent { - /// Claude Code's `.claude/`. + /// Claude Code: the zerograph plugin, through `claude plugin install`. #[arg(long)] claude: bool, - /// Codex's `.codex/`. + /// Codex: the zerograph plugin, through `codex plugin add`. #[arg(long)] codex: bool, /// Cursor: `.cursor/mcp.json` and an always-on rule. #[arg(long)] cursor: bool, @@ -169,8 +169,11 @@ enum Cmd { #[arg(long)] gemini: bool, /// opencode: `opencode.json` and `AGENTS.md`. #[arg(long)] opencode: bool, - /// How this repository invokes the binary, in every file the install writes. + /// How this repository invokes the binary, in the editors' MCP entries and stanzas. #[arg(long, default_value = "zerograph")] command: String, + /// With --claude or --codex: also take out the older install's files that git tracks. Left + /// by default, since they serve every teammate who has no plugin yet. + #[arg(long)] retire_repo: bool, }, /// What a coding agent should be told about this repository at the start of a session: node /// counts, which embedder the vectors belong to, how many families the documents define, and the five commands. Reads the store; never refreshes. @@ -1290,7 +1293,7 @@ fn run(cli: Cli) -> anyhow::Result<()> { if graph.nodes.is_empty() { anyhow::bail!("graph is empty — run `zerograph build`"); } Ok(()) } - Cmd::InstallAgent { claude, codex, cursor, copilot, gemini, opencode, command } => { + Cmd::InstallAgent { claude, codex, cursor, copilot, gemini, opencode, command, retire_repo } => { use install_agent::Editor; let editors: Vec = [(cursor, Editor::Cursor), (copilot, Editor::Copilot), (gemini, Editor::Gemini), (opencode, Editor::Opencode)] .into_iter().filter(|(on, _)| *on).map(|(_, e)| e).collect(); @@ -1299,13 +1302,13 @@ fn run(cli: Cli) -> anyhow::Result<()> { } let targets: Vec = match (claude, codex) { (false, false) => Vec::new(), - // Codex first: its stanza goes into the root `AGENTS.md`, which a `CLAUDE.md` link - // usually points at, so the Claude pass finds nothing left to write through the link. + // Codex first: it takes the stanza out of the root `AGENTS.md`, which a `CLAUDE.md` + // link usually points at, so the Claude pass finds nothing left behind the link. (c, x) => [(x, install_agent::Target::Codex), (c, install_agent::Target::Claude)] .into_iter().filter(|(on, _)| *on).map(|(_, t)| t).collect(), }; - // Editors first: opencode writes the root `AGENTS.md`, which a `CLAUDE.md` link usually - // points at, so the Claude pass below finds it written rather than a link to refuse. + // Editors first: opencode reads the stanza in the root `AGENTS.md`, and the Codex pass + // leaves it there once opencode's entry is written. for editor in editors { let r = install_agent::install_editor(&repo, editor, &command)?; match r.written { @@ -1315,39 +1318,51 @@ fn run(cli: Cli) -> anyhow::Result<()> { for note in &r.notes { println!("{editor:?}: {note}"); } } for target in targets { - let r = install_agent::install(&repo, target, &command)?; - match r.written { - 0 => println!("{target:?}: already installed, nothing written"), - n => println!("{target:?}: wrote {n} files\n {}", r.paths.join("\n ")), - } - for note in &r.notes { println!("{target:?}: {note}"); } - // Per checkout: a local-scope server is keyed by the project's path, so a session in - // a worktree sees only what was registered for that worktree. - if target == install_agent::Target::Claude { - let registered = install_agent::register_mcp_everywhere(&repo, &command, &install_agent::run_claude); - for (checkout, m) in ®istered { - let at = match registered.len() { 1 => String::new(), _ => format!(" for {}", checkout.display()) }; - for path in &m.paths { println!("Claude: registered the MCP server{at}\n {path}"); } - for note in &m.notes { println!("Claude: {note}{at}"); } - } - if let Some(config) = install_agent::claude_config() { - for note in &install_agent::prune_stale_mcp(&config).notes { println!("Claude: {note}"); } + let name = format!("{target:?}"); + let (installed, r) = install_agent::install_plugin(&repo, target, &install_agent::run_cli); + for note in &r.notes { println!("{name}: {note}"); } + // An older install is the only thing serving sessions until the plugin is in. + if !installed { continue; } + let mut retire = install_agent::Retire { committed: retire_repo, machine_hooks_gone: true }; + let mut retired = Vec::new(); + // Hook entries before the script they run: an entry left pointing at a removed + // script fails on every event it matches. + if target == install_agent::Target::Codex { + if let Some(home) = install_agent::codex_home() { + let (gone, r) = install_agent::retire_codex_hooks(&home); + retire.machine_hooks_gone = gone; + retired.push(r); } } - // Codex reads its hooks from `~/.codex/hooks.json`, which is the machine's and not - // this repository's. Printed for a person to paste; see `agent/codex.md`. - if target == install_agent::Target::Codex { - let (hook, r) = install_agent::codex_hook(&repo, &command)?; - for note in &r.notes { println!("Codex: {note}"); } - println!("\nTo run the hook on this machine, add to ~/.codex/hooks.json \ - (merging with what is already there):\n{}\n\ - Codex trusts a hook by hash: the first session after this is added \ - asks once, and editing the script later asks again. The path is the \ - main worktree's copy, which stays when a linked worktree is removed. \ - The hook reads each session's own working directory and stays silent \ - where no index was asked for, so one copy serves every repository.", - install_agent::codex_hooks_block(&hook.display().to_string())); + retired.push(install_agent::retire_repo_install(&repo, target, retire)); + match target { + install_agent::Target::Claude => if let Some(config) = install_agent::claude_config() { + retired.push(install_agent::prune_mcp(&config, &install_agent::worktrees(&repo))); + }, + // An earlier release wrote the Codex hook into the main worktree, for the + // machine's hooks file to point at. + install_agent::Target::Codex => if let Some(main) = install_agent::main_worktree(&repo) { + let mut r = install_agent::Report::default(); + install_agent::retire_hook_script(&main.join(".codex").join("hooks"), retire.machine_hooks_gone, retire, &mut r); + retired.push(r); + }, + } + for r in &retired { for note in &r.notes { println!("{name}: {note}"); } } + let committed: Vec<&String> = retired.iter().flat_map(|r| &r.committed).collect(); + if !committed.is_empty() { + let flag = match target { install_agent::Target::Claude => "--claude", install_agent::Target::Codex => "--codex" }; + println!("{name}: left what an earlier release wrote that git tracks, for teammates without the plugin: {}; \ + once every teammate has it, `zerograph install-agent {flag} --retire-repo` takes them out", + committed.iter().map(|p| p.as_str()).collect::>().join(", ")); } + if command != "zerograph" { + println!("{name}: the plugin's hints name `zerograph`, and it starts the one on PATH outside the checkout; \ + `--command` reaches only the editors' files. A binary elsewhere is named by its absolute path \ + in ZEROGRAPH_BIN{}.", match target { install_agent::Target::Claude => " or `/plugin configure zerograph@zerograph`", install_agent::Target::Codex => "" }); + } + } + if !repo.join(".zerograph").join("manifest.json").exists() { + println!("This repository has no index yet: `zerograph build` writes one, and from then on the session hook builds every new worktree of it in the background."); } // Once whatever the harnesses: an earlier release wrote these, and nothing writes them now. for note in &install_agent::retire_git_hooks(&repo).notes { println!("git: {note}"); }