From bb5df6702c32cc8eb5011b65dcd908dbb1bdec4c Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 02:40:28 +0200 Subject: [PATCH 01/10] feat(plugin): add MCP-backed slash commands to the zerograph plugin The plugin now starts zerograph's MCP server itself and ships seven slash commands that call its tools: ask, impact, trace, changes, explain, status and refresh. Each pre-approves only the mcp__plugin_zerograph_zerograph__* tools it calls and tells the model how to read the answer: open the top path:line, and pair a "nothing uses this" with a text search. A use-the-graph skill tells the model when to reach for those tools instead of grep. The server is declared in plugin.json and started by scripts/mcp.mjs, which finds the binary the way the status mod does: the binary option when it is absolute, else zerograph on PATH outside the checkout. plugin.json can substitute the option but not branch on it. The MCP server gains cache_status, the report `cache status` prints, and reindex now starts a first build where no store stands rather than an update, each taking the writer lock in its own process. install-agent --claude's local-scope server would run beside the plugin's, so the docs say to remove it when the plugin is installed. --- .github/workflows/ci.yml | 7 +- README.md | 5 +- agent/claude-code/.claude-plugin/plugin.json | 11 +- agent/claude-code/scripts/mcp.mjs | 97 ++++++++++++++++ agent/claude-code/scripts/mcp.test.mjs | 106 ++++++++++++++++++ agent/claude-code/skills/ask/SKILL.md | 21 ++++ agent/claude-code/skills/changes/SKILL.md | 20 ++++ agent/claude-code/skills/explain/SKILL.md | 16 +++ agent/claude-code/skills/impact/SKILL.md | 19 ++++ agent/claude-code/skills/refresh/SKILL.md | 17 +++ agent/claude-code/skills/status/SKILL.md | 18 +++ agent/claude-code/skills/trace/SKILL.md | 18 +++ .../claude-code/skills/use-the-graph/SKILL.md | 28 +++++ docs/install.md | 44 +++++++- docs/mcp.md | 9 +- src/lib.rs | 4 +- src/mcp.rs | 37 +++++- tests/mcp.rs | 2 +- 18 files changed, 462 insertions(+), 17 deletions(-) create mode 100644 agent/claude-code/scripts/mcp.mjs create mode 100644 agent/claude-code/scripts/mcp.test.mjs create mode 100644 agent/claude-code/skills/ask/SKILL.md create mode 100644 agent/claude-code/skills/changes/SKILL.md create mode 100644 agent/claude-code/skills/explain/SKILL.md create mode 100644 agent/claude-code/skills/impact/SKILL.md create mode 100644 agent/claude-code/skills/refresh/SKILL.md create mode 100644 agent/claude-code/skills/status/SKILL.md create mode 100644 agent/claude-code/skills/trace/SKILL.md create mode 100644 agent/claude-code/skills/use-the-graph/SKILL.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 900ef68a..9254c9da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,15 +77,16 @@ jobs: [ "$mod" = "$crate" ] || { echo "::error::agent/claude-code is at $mod, Cargo.toml at $crate"; exit 1; } [ "$ref" = "v$crate" ] || { echo "::error::the marketplace pins $ref, Cargo.toml is at $crate"; exit 1; } - # The agent hook is JavaScript and ships with the binary that installs it, so it is tested on - # the same three platforms: a hook that throws on Windows is a hook that fires on nobody. + # The agent hook ships with the binary that installs it, and the plugin starts its MCP server + # through a Node launcher, so both are tested on the same three platforms: a hook or a + # launcher that throws on Windows fires on nobody. # `!cancelled()` for the same reason as the suites above, and with no OS clause beside it: a # red Python suite must not skip the hook on the one platform it also runs on. - if: ${{ !cancelled() }} uses: actions/setup-node@v4 with: { node-version: '20' } - if: ${{ !cancelled() }} - run: node --test agent/hook.test.mjs npm/zerograph/test/launcher.test.js + run: node --test agent/hook.test.mjs agent/claude-code/scripts/mcp.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 1b164fec..36b95f49 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,10 @@ prints the full `zerograph status` report. ``` It is pinned to the latest release, so `claude plugin update zerograph` moves it with zerograph. -See [the status mod](docs/install.md#claude-code-status-mod). +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). ## In action diff --git a/agent/claude-code/.claude-plugin/plugin.json b/agent/claude-code/.claude-plugin/plugin.json index b394346d..33a9252c 100644 --- a/agent/claude-code/.claude-plugin/plugin.json +++ b/agent/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "zerograph", "version": "0.7.0", - "description": "zerograph's state at the end of the prompt's hint line: refresh progress, files behind, serve and warnings", + "description": "zerograph in Claude Code: its MCP server, slash commands that call it (ask, impact, trace, changes, explain, status, refresh), a skill that reaches for the graph before grep, and the index's state at the end of the prompt's hint line", "author": { "name": "devmaxxx" }, @@ -13,5 +13,14 @@ "description": "Absolute path of the zerograph to ask; a relative one is ignored. Empty asks the zerograph on PATH outside the project, so a binary inside the project runs only when named here.", "default": "" } + }, + "mcpServers": { + "zerograph": { + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/scripts/mcp.mjs", + "${user_config.binary}" + ] + } } } diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs new file mode 100644 index 00000000..09a93aa2 --- /dev/null +++ b/agent/claude-code/scripts/mcp.mjs @@ -0,0 +1,97 @@ +#!/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. +// +// Usage: node mcp.mjs '' +import { spawn } from 'node:child_process'; +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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) { + 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}/`); +} + +/** + * The checkout around `root`: a session opened in a package of a monorepo still has the whole + * checkout around it, and that is what zerograph indexes and what a PATH entry must stay out of. + */ +export function checkoutOf(root) { + for (let d = root; ;) { + if (existsSync(join(d, '.git'))) return d; + const up = dirname(d); + if (up === d) return root; + d = up; + } +} + +/** + * 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 }) { + 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 = (path ?? '').split(win ? ';' : ':').filter(dir => dir && isAbsolute(dir) && !inside(checkout, dir, win)); + // 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); + if (statSync(file, { throwIfNoEntry: false })?.isFile()) 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' }; +} + +/** + * The argv that starts `bin`. 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. + */ +export function argvOf(bin, checkout, comspec) { + const head = /\.(cmd|bat)$/i.test(bin) ? [comspec || 'C:\\Windows\\System32\\cmd.exe', '/d', '/c', 'call', bin] : [bin]; + return [...head, '--repo', checkout, 'mcp']; +} + +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 found = binaryOf(process.argv[2] ?? '', { path: process.env.PATH ?? process.env.Path, checkout, win }); + if (found.error) { + // stderr is the server log the harness shows; stdout belongs to the protocol. + process.stderr.write(`zerograph: ${found.error}\n`); + process.exit(1); + } + const [cmd, ...args] = argvOf(found.bin, checkout, process.env.ComSpec); + // Never from the checkout: npm's `.cmd` shim starts a bare `node`, and `cmd` looks for that in + // the working directory before PATH. + const child = spawn(cmd, args, { stdio: 'inherit', cwd: dirname(found.bin), windowsHide: true }); + 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, run by `.claude-plugin/plugin.json`; a plugin cache reached through a +// link names this file by another spelling than the one Node resolved. +const real = p => { try { return realpathSync(p); } catch { return p; } }; +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 new file mode 100644 index 00000000..c592baa6 --- /dev/null +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -0,0 +1,106 @@ +// The plugin's MCP launcher, exercised the way `.claude-plugin/plugin.json` runs it: the binary +// option as its one argument, the project in CLAUDE_PROJECT_DIR, and a fake `zerograph` that +// 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 } 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 } from './mcp.mjs'; + +const LAUNCHER = join(dirname(fileURLToPath(import.meta.url)), 'mcp.mjs'); +const WIN = process.platform === 'win32'; + +/** A checkout that ships a zerograph where a local install puts one, and one on the machine. */ +function world() { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); + const checkout = join(base, 'checkout'); + mkdirSync(join(checkout, '.git'), { recursive: true }); + mkdirSync(join(checkout, 'packages', 'app'), { recursive: true }); + const local = join(checkout, 'node_modules', '.bin'); + const machine = join(base, 'machine'); + for (const dir of [local, machine]) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, WIN ? 'zerograph.exe' : 'zerograph'), ''); + } + return { base, checkout, local, machine }; +} + +const sep = WIN ? ';' : ':'; + +test('an unset option, substituted or not, is the PATH lookup', () => { + const w = world(); + const path = [w.machine].join(sep); + for (const option of ['', '${user_config.binary}', 'zerograph']) { + assert.strictEqual(binaryOf(option, { path, checkout: w.checkout, win: WIN }).bin, join(w.machine, WIN ? 'zerograph.exe' : 'zerograph'), option); + } +}); + +test('a PATH entry inside the checkout is passed over, relative or absolute', () => { + const w = world(); + const path = ['node_modules/.bin', w.local, w.machine].join(sep); + assert.strictEqual(binaryOf('', { path, checkout: w.checkout, win: WIN }).bin, join(w.machine, WIN ? 'zerograph.exe' : 'zerograph')); +}); + +test('an absolute option is the one started, even inside the checkout', () => { + const w = world(); + const named = join(w.local, 'zerograph'); + assert.strictEqual(binaryOf(named, { path: w.machine, checkout: w.checkout, win: WIN }).bin, named); +}); + +test('a relative option is refused by name rather than read against some directory', () => { + const w = world(); + const found = binaryOf('node_modules/.bin/zerograph', { path: w.machine, checkout: w.checkout, win: WIN }); + assert.strictEqual(found.bin, undefined); + assert.match(found.error, /node_modules\/\.bin\/zerograph\) is not an absolute path/); +}); + +test('nothing on PATH outside the checkout is an error that says how to fix it', () => { + const w = world(); + assert.match(binaryOf('', { path: w.local, checkout: w.checkout, win: WIN }).error, /no zerograph on PATH outside this checkout/); +}); + +test('the server is told the checkout around the project, not a package inside it', () => { + const w = world(); + assert.strictEqual(checkoutOf(join(w.checkout, 'packages', 'app')), w.checkout); + assert.deepStrictEqual(argvOf('/usr/local/bin/zerograph', w.checkout), ['/usr/local/bin/zerograph', '--repo', w.checkout, 'mcp']); +}); + +test('an npm .cmd shim is started through cmd, an .exe directly', () => { + assert.deepStrictEqual(argvOf('C:\\npm\\zerograph.cmd', 'C:\\work', 'C:\\Windows\\cmd.exe'), ['C:\\Windows\\cmd.exe', '/d', '/c', 'call', 'C:\\npm\\zerograph.cmd', '--repo', 'C:\\work', 'mcp']); + assert.deepStrictEqual(argvOf('C:\\bin\\zerograph.exe', 'C:\\work'), ['C:\\bin\\zerograph.exe', '--repo', 'C:\\work', 'mcp']); +}); + +// The fake is a Node script so the protocol round trip reads the same on every platform. +test('the launcher hands stdin and stdout to the server untouched, and the server its checkout', { skip: WIN && 'a POSIX launcher for the fake' }, () => { + const w = world(); + const fake = join(w.machine, 'zerograph'); + writeFileSync(fake, `#!/usr/bin/env node +let input = ''; +process.stdin.on('data', d => { input += d; }); +process.stdin.on('end', () => { + const req = JSON.parse(input.trim()); + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result: { argv: process.argv.slice(2) } }) + '\\n'); +}); +`); + chmodSync(fake, 0o755); + const r = spawnSync(process.execPath, [LAUNCHER, ''], { + cwd: w.base, + input: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) + '\n', + env: { PATH: [dirname(process.execPath), w.machine].join(sep), CLAUDE_PROJECT_DIR: join(w.checkout, 'packages', 'app') }, + encoding: 'utf8', + }); + assert.strictEqual(r.status, 0, r.stderr); + assert.deepStrictEqual(JSON.parse(r.stdout), { jsonrpc: '2.0', id: 1, result: { argv: ['--repo', w.checkout, 'mcp'] } }); +}); + +test('no binary is a line in the server log and a failed start, never a word on stdout', () => { + const w = world(); + const r = spawnSync(process.execPath, [LAUNCHER, 'relative/zerograph'], { cwd: w.base, input: '', env: { PATH: w.machine, CLAUDE_PROJECT_DIR: w.checkout }, encoding: 'utf8' }); + assert.strictEqual(r.status, 1); + assert.strictEqual(r.stdout, ''); + assert.match(r.stderr, /relative\/zerograph\) is not an absolute path/); +}); diff --git a/agent/claude-code/skills/ask/SKILL.md b/agent/claude-code/skills/ask/SKILL.md new file mode 100644 index 00000000..a8ee49de --- /dev/null +++ b/agent/claude-code/skills/ask/SKILL.md @@ -0,0 +1,21 @@ +--- +description: Ask the zerograph index a question, an id or a symbol, then read the top hits +argument-hint: +allowed-tools: mcp__plugin_zerograph_zerograph__ask +disable-model-invocation: true +--- + +Call `mcp__plugin_zerograph_zerograph__ask` with `question` set to: $ARGUMENTS + +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` with Read, 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 Grep. diff --git a/agent/claude-code/skills/changes/SKILL.md b/agent/claude-code/skills/changes/SKILL.md new file mode 100644 index 00000000..5adb7f31 --- /dev/null +++ b/agent/claude-code/skills/changes/SKILL.md @@ -0,0 +1,20 @@ +--- +description: What the current diff touches and who reaches it — run before a commit or a PR +argument-hint: "[base] [--depth N]" +allowed-tools: mcp__plugin_zerograph_zerograph__changes +disable-model-invocation: true +--- + +Call `mcp__plugin_zerograph_zerograph__changes` for: $ARGUMENTS + +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 Grep the changed names before calling a change contained. diff --git a/agent/claude-code/skills/explain/SKILL.md b/agent/claude-code/skills/explain/SKILL.md new file mode 100644 index 00000000..4ea34371 --- /dev/null +++ b/agent/claude-code/skills/explain/SKILL.md @@ -0,0 +1,16 @@ +--- +description: One node and every edge attached to it — an id, a symbol or a label +argument-hint: +allowed-tools: mcp__plugin_zerograph_zerograph__explain +disable-model-invocation: true +--- + +Call `mcp__plugin_zerograph_zerograph__explain` with `node` set to: $ARGUMENTS + +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/claude-code/skills/impact/SKILL.md b/agent/claude-code/skills/impact/SKILL.md new file mode 100644 index 00000000..6ee732bf --- /dev/null +++ b/agent/claude-code/skills/impact/SKILL.md @@ -0,0 +1,19 @@ +--- +description: Blast radius of a symbol before you change it — callers by depth, importing files and a risk line +argument-hint: [--depth N] [--down] +allowed-tools: mcp__plugin_zerograph_zerograph__impact +disable-model-invocation: true +--- + +Call `mcp__plugin_zerograph_zerograph__impact` for: $ARGUMENTS + +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 a Grep for the name (files with + matches) before calling anything dead or safe to delete. diff --git a/agent/claude-code/skills/refresh/SKILL.md b/agent/claude-code/skills/refresh/SKILL.md new file mode 100644 index 00000000..ce3421c8 --- /dev/null +++ b/agent/claude-code/skills/refresh/SKILL.md @@ -0,0 +1,17 @@ +--- +description: Bring the zerograph index in line with the tree — a first build where there is no store, else an update +allowed-tools: mcp__plugin_zerograph_zerograph__reindex mcp__plugin_zerograph_zerograph__status +disable-model-invocation: true +--- + +Call `mcp__plugin_zerograph_zerograph__reindex` 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 `mcp__plugin_zerograph_zerograph__status` 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; the line under the prompt follows +the run, and `/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/claude-code/skills/status/SKILL.md b/agent/claude-code/skills/status/SKILL.md new file mode 100644 index 00000000..60d2dad3 --- /dev/null +++ b/agent/claude-code/skills/status/SKILL.md @@ -0,0 +1,18 @@ +--- +description: zerograph's index state, refresh, serve and the machine's vector cache +allowed-tools: mcp__plugin_zerograph_zerograph__status mcp__plugin_zerograph_zerograph__cache_status +disable-model-invocation: true +--- + +Call `mcp__plugin_zerograph_zerograph__status` and `mcp__plugin_zerograph_zerograph__cache_status`, +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/claude-code/skills/trace/SKILL.md b/agent/claude-code/skills/trace/SKILL.md new file mode 100644 index 00000000..6da91626 --- /dev/null +++ b/agent/claude-code/skills/trace/SKILL.md @@ -0,0 +1,18 @@ +--- +description: Shortest chain of calls from one symbol to another +argument-hint: [--depth N] +allowed-tools: mcp__plugin_zerograph_zerograph__trace +disable-model-invocation: true +--- + +Call `mcp__plugin_zerograph_zerograph__trace` for: $ARGUMENTS + +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 a Grep for a link the graph cannot see (a callback, an event, a string + key). diff --git a/agent/claude-code/skills/use-the-graph/SKILL.md b/agent/claude-code/skills/use-the-graph/SKILL.md new file mode 100644 index 00000000..26e277ed --- /dev/null +++ b/agent/claude-code/skills/use-the-graph/SKILL.md @@ -0,0 +1,28 @@ +--- +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 `mcp__plugin_zerograph_zerograph__*`. + +| The question | Tool | Not | +| --- | --- | --- | +| what is `FR-PAY-22`, `BE-M10/T05`, `asGrosze` | `ask` with the id or symbol | Grep for the id | +| where is this concept written | `ask` with four words | reading directories | +| who calls `X` | `impact` with `symbol: X`, `depth: 1` | Grep 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 | Grep | the graph: it does not index literals | + +- Every answer line starts with `path:line`. Open those with Read; 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 a Grep 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/docs/install.md b/docs/install.md index 0fbbcfeb..f87a4961 100644 --- a/docs/install.md +++ b/docs/install.md @@ -98,10 +98,12 @@ What zerograph reads from the old 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. -## Claude Code status mod + -`agent/claude-code/` is a Claude Code mod that puts the index's state at the end of the line under -the prompt: `◇ zerograph · in sync`, a bar and the time left while a refresh runs, the files +## Claude Code plugin + +`agent/claude-code/` is a Claude Code plugin. Its mod puts the index's state at the end of the line +under the prompt: `◇ zerograph · in sync`, a bar and the time left while a refresh runs, the files behind, vectors still to embed, a warning count and whether `serve` answers. `/zerograph` prints the full `zerograph status` report. Install it at a Claude Code prompt: @@ -109,13 +111,47 @@ the full `zerograph status` report. Install it at a Claude Code prompt: /plugin install zerograph --marketplace devmaxxx/zerograph ``` +It also starts zerograph's [MCP server](mcp.md) for the session, always: the plugin declares it in +`.claude-plugin/plugin.json`, and Claude Code connects it whenever the plugin is enabled. Its slash +commands call that server's tools, never a shell, and tell the model how to read what came back: + +| command | MCP tools | then | +| --- | --- | --- | +| `/zerograph:ask ` | `ask` | opens the top seeds at their `path:line` and answers from them | +| `/zerograph:impact [--depth N] [--down]` | `impact` | leads with the risk line and the depth-1 callers; checks a "nothing uses this" with a text search | +| `/zerograph:trace ` | `trace` | walks the chain step by step; no path is an answer | +| `/zerograph:changes [base]` | `changes` | the callers outside the diff a change can break, before a commit or a PR | +| `/zerograph:explain ` | `explain` | the node and the edges the question is about | +| `/zerograph:status` | `status`, `cache_status` | what, if anything, wants doing | +| `/zerograph:refresh` | `reindex`, `status` | a first `build` where there is no store, else an `update`, in the background | + +Each command pre-approves only the tools it calls, as `mcp__plugin_zerograph_zerograph__`. A +skill, `use-the-graph`, tells the model to reach for those tools rather than grep when the question +is an exact id, who calls a symbol, or what a change would break. + +The server is `zerograph --repo mcp`, started by `scripts/mcp.mjs` under `node`: the +`node` that `npm i -g zerograph` needed, which an install from a release archive or `cargo` has to +put on `PATH` itself. The checkout is the one around `CLAUDE_PROJECT_DIR`, so a session opened in a +package of a monorepo indexes the whole repository. + +**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. + 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 run, whether `PATH` names that directory relatively or absolutely. For a project-local install, name its absolute path in the `binary` option in `/config`; a relative path is ignored, and a bare name such as `zerograph` means the `PATH` lookup. On Windows a `zerograph.exe` on `PATH` is asked before npm's `zerograph.cmd` shim, which the mod runs through `cmd`. A zerograph without `status` -shows nothing on the line, and `/zerograph` falls back to the tail of `background.log`. +shows nothing on the line, and `/zerograph` falls back to the tail of `background.log`. The MCP +server's binary is found by the same rules, and a binary option that is not an absolute path, or no +zerograph on `PATH` outside the checkout, fails its start with the reason in the server's log +(`/mcp` shows it). `zerograph status --json` walks the whole tree to count the files behind, so the mod runs it at session start, every five minutes, once after a refresh ends and after `/zerograph`. Between those diff --git a/docs/mcp.md b/docs/mcp.md index 7c68bffd..7b20ea45 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5,6 +5,12 @@ zerograph mcp # a stdio MCP server (protocol 2025-06-18) over claude mcp add zerograph -- zerograph --repo /path/to/checkout mcp # what `install-agent --claude` runs, per checkout ``` +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 @@ -56,7 +62,8 @@ machine-wide file, so it is not covered; `zerograph mcp` is the command to give | `ask` | `question` (plus `seeds`, `bodies`, `rerank`, `rerank_local`, `depth`, `stale`) to the answer `ask` prints, with the low-confidence line when the retrievers disagree. `rerank` spends tokens on the agent's word, so one server process runs at most `mcp_reranks` of them (25 by default, set in the machine config) and answers the rest from the fused order, saying so | | `explain`, `impact`, `trace`, `changes` | the commands of those names, with their flags; an answer given while files are behind the tree says so | | `status` | nodes and edges, files behind the tree, the store's model against the configured one, vector rows owed, and whether a background refresh is running with its last progress line from `.zerograph/background.log` and what the run said besides, and whether a `serve` answers with its last log lines. The same report as `zerograph status` | -| `reindex` | starts a detached `update` and returns at once; says so when one is already running | +| `reindex` | starts a detached `build` where no store stands, else a detached `update`, and returns at once; says so when one is already running. The child takes the writer lock as the command line does, so readers keep answering from the store as it stands | +| `cache_status` | the machine's vector cache: where it is, its cap, and the rows and bytes it holds per model. The same report as `zerograph cache status`; it reads and writes nothing else | | `switch_model` | `model ` in the background. Only catalogued models and ones trusted on this machine with `zerograph model `; any other id is refused, because the caller is an agent and a model is code this machine downloads | `status` reports progress while the writer lock is held and nothing once the refresh is over. Each diff --git a/src/lib.rs b/src/lib.rs index b2adc07a..892ef8b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -232,8 +232,8 @@ enum Cmd { action: CacheAction, }, /// A stdio MCP server: `ask`, `explain`, `impact`, `trace` and `changes` as tools, plus - /// `status` (what the index is behind by and how far a background refresh has got), `reindex` - /// and `switch_model`. Protocol on stdout, diagnostics on stderr; `install-agent` registers it. + /// `status` (what the index is behind by and how far a background refresh has got), `reindex`, + /// `cache_status` and `switch_model`. Protocol on stdout, diagnostics on stderr; `install-agent` registers it. Mcp, } diff --git a/src/mcp.rs b/src/mcp.rs index a913c616..dc5cda0e 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -153,6 +153,7 @@ impl Server { "changes" => self.changes(args), "status" => self.status(), "reindex" => self.reindex(), + "cache_status" => crate::cache_command(crate::CacheAction::Status), "switch_model" => self.switch_model(args), _ => bail!("no tool {name:?}"), } @@ -264,6 +265,8 @@ impl Server { fn reindex(&self) -> Result { let store = store::Store::new(&self.repo); + // Asked before the lock, which makes the directory it would be asking about. + let run = refresh_command(&store); match (store.try_lock_writer()?, self.spawn_in_flight()) { (None, _) => Ok(format!("a refresh is already running ({}); `status` follows it", progress(&self.repo))), (Some(_), true) => Ok("a refresh is already running (starting); `status` follows it".to_string()), @@ -271,8 +274,8 @@ impl Server { // Released first: the child takes the lock itself, and waits on this process for it // if it is still held when the child asks. drop(lock); - self.remember(refresh::spawn_detached(&self.repo, self.no_dense, &["update"])?); - Ok("refresh started in the background; `status` shows how far it has got".to_string()) + self.remember(refresh::spawn_detached(&self.repo, self.no_dense, &[run])?); + Ok(format!("`{run}` started in the background; `status` shows how far it has got")) } } } @@ -319,6 +322,12 @@ fn with_notices(mut text: String, notices: Vec) -> String { text } +/// What a `reindex` runs: the first `build` where no store stands, else an `update`, each taking +/// the writer lock in its own process as it does on the command line. +fn refresh_command(store: &store::Store) -> &'static str { + if store.stands() { "update" } else { "build" } +} + /// How far the running refresh has got, for a reply that says one is already running. fn progress(repo: &Path) -> String { status::progress(&status::log_tail(repo, "background.log")) @@ -388,7 +397,8 @@ fn tools() -> Vec { "stale": stale, }), &[]), tool("status", "The index's state: size, files behind the tree, the model that wrote the vectors against the configured one, and whether a background refresh is running and how far it has got.", json!({}), &[]), - tool("reindex", "Starts a background refresh of the index and returns at once; says so when one is already running. Follow it with `status`.", json!({}), &[]), + tool("reindex", "Starts a background refresh of the index and returns at once: a first build where there is no index, else an update. Says so when one is already running. Follow it with `status`.", json!({}), &[]), + tool("cache_status", "The machine's vector cache, which every checkout reads before it embeds: where it is, its cap, and the rows and bytes it holds per model. Reads only.", json!({}), &[]), tool("switch_model", "Re-embeds the index under another embedding model, in the background. Only catalogued models and ones the user has trusted with `zerograph model `; any other id is refused.", json!({ "model": { "type": "string", "description": "A hub id." }, }), &["model"]), @@ -450,7 +460,7 @@ mod tests { let (_dir, server) = served(); let reply = send(&server, json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" })); let names: Vec<&str> = reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["ask", "explain", "impact", "trace", "changes", "status", "reindex", "switch_model"]); + assert_eq!(names, ["ask", "explain", "impact", "trace", "changes", "status", "reindex", "cache_status", "switch_model"]); for t in reply["result"]["tools"].as_array().unwrap() { assert_eq!(t["inputSchema"]["type"], "object", "{t}"); } @@ -613,6 +623,25 @@ mod tests { assert!(!dir.path().join(".zerograph/background.log").exists(), "nothing was spawned to write a log"); } + #[test] + fn reindex_builds_where_no_store_stands_and_updates_where_one_does() { + let bare = tempfile::tempdir().unwrap(); + assert_eq!(refresh_command(&store::Store::new(bare.path())), "build"); + let (dir, _server) = served(); + assert_eq!(refresh_command(&store::Store::new(dir.path())), "update"); + } + + #[test] + fn cache_status_is_the_report_the_command_line_prints() { + let (_dir, server) = served(); + let (is_error, text) = call(&server, "cache_status", json!({})); + let printed = crate::cache_command(crate::CacheAction::Status).map_err(|e| format!("{e:#}")); + match printed { + Ok(printed) => assert!(!is_error && text == crate::terminal_safe(&printed), "{text}"), + Err(err) => assert!(is_error && text == err, "{text}"), + } + } + #[test] fn switch_model_refuses_an_id_nobody_catalogued_or_trusted() { let (dir, server) = served(); diff --git a/tests/mcp.rs b/tests/mcp.rs index 2e45e884..649e7920 100644 --- a/tests/mcp.rs +++ b/tests/mcp.rs @@ -44,7 +44,7 @@ fn a_session_over_stdio_reaches_the_read_tools_and_stdout_is_only_protocol() { assert!(server.wait().unwrap().success(), "end of input is a clean exit"); assert_eq!(replies.len(), 6, "the notification gets none, the malformed line gets an error: {replies:?}"); assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18"); - assert_eq!(replies[1]["result"]["tools"].as_array().unwrap().len(), 8); + assert_eq!(replies[1]["result"]["tools"].as_array().unwrap().len(), 9); let text = |i: usize| replies[i]["result"]["content"][0]["text"].as_str().unwrap().to_string(); assert!(text(2).contains("refresh: none running"), "{}", text(2)); assert!(text(3).contains("FR-PAY-1"), "{}", text(3)); From 646f8aaaccc50c182bba95fb49187d497f18c646 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 02:49:41 +0200 Subject: [PATCH 02/10] fix(plugin): harden the MCP launcher and keep cache_status read-only - Refuse to start npm's zerograph.cmd shim through cmd when its path or the checkout's holds & | < > ^ " % !, which cmd would act on, and say so on stderr. - Hand the server a PATH cut to the entries outside the checkout. - Compare PATH entries with the checkout both as spelled and with links followed, so a symlinked alias does not let a checkout binary through. - Require the exec bit on a PATH candidate on POSIX. - Outside git, take the nearest ancestor holding .zerograph/ as the repository, as the CLI does, before falling back to the project dir. - cache_status no longer creates the cache directory: a cache not made yet reports as empty. - Document the bare node the plugin config starts, the .cmd refusal and the cut PATH. --- agent/claude-code/scripts/mcp.mjs | 88 +++++++++++++++++++------- agent/claude-code/scripts/mcp.test.mjs | 55 ++++++++++++++-- docs/install.md | 11 +++- docs/mcp.md | 2 +- src/index/cache.rs | 16 +++++ src/lib.rs | 6 +- src/mcp.rs | 2 +- 7 files changed, 146 insertions(+), 34 deletions(-) diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs index 09a93aa2..b4381861 100644 --- a/agent/claude-code/scripts/mcp.mjs +++ b/agent/claude-code/scripts/mcp.mjs @@ -25,17 +25,38 @@ export function inside(root, dir, win) { 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. */ +function nearest(start, name) { + for (let d = start; ; d = dirname(d)) { + if (existsSync(join(d, name))) return d; + if (dirname(d) === d) return null; + } +} + /** - * The checkout around `root`: a session opened in a package of a monorepo still has the whole - * checkout around it, and that is what zerograph indexes and what a PATH entry must stay out of. + * 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) { - for (let d = root; ;) { - if (existsSync(join(d, '.git'))) return d; - const up = dirname(d); - if (up === d) return root; - d = up; - } + 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) { + const resolved = real(checkout); + return (path ?? '').split(win ? ';' : ':') + .filter(dir => dir && isAbsolute(dir) && !inside(checkout, dir, win) && !inside(resolved, real(dir), win)); } /** @@ -49,26 +70,46 @@ export function binaryOf(configured, { path, checkout, win }) { 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 = (path ?? '').split(win ? ';' : ':').filter(dir => dir && isAbsolute(dir) && !inside(checkout, dir, win)); + const dirs = machineDirs(path, checkout, win); // 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); - if (statSync(file, { throwIfNoEntry: false })?.isFile()) return { bin: file }; + 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`. 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. + * 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. */ export function argvOf(bin, checkout, comspec) { - const head = /\.(cmd|bat)$/i.test(bin) ? [comspec || 'C:\\Windows\\System32\\cmd.exe', '/d', '/c', 'call', bin] : [bin]; - return [...head, '--repo', checkout, 'mcp']; + 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) { + const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; + return { ...env, [key]: machineDirs(env[key], checkout, win).join(win ? ';' : ':') }; } function main() { @@ -76,16 +117,20 @@ function main() { // 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 found = binaryOf(process.argv[2] ?? '', { path: process.env.PATH ?? process.env.Path, checkout, win }); - if (found.error) { + const env = process.env; + const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; + const found = binaryOf(process.argv[2] ?? '', { path: env[pathKey], 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: ${found.error}\n`); + process.stderr.write(`zerograph: ${launch.error}\n`); process.exit(1); } - const [cmd, ...args] = argvOf(found.bin, checkout, process.env.ComSpec); + 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. - const child = spawn(cmd, args, { stdio: 'inherit', cwd: dirname(found.bin), windowsHide: true }); + // 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)); @@ -93,5 +138,4 @@ function main() { // 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. -const real = p => { try { return realpathSync(p); } catch { return p; } }; 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 c592baa6..340ee9cc 100644 --- a/agent/claude-code/scripts/mcp.test.mjs +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -3,13 +3,13 @@ // 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 } from 'node:fs'; +import { mkdtempSync, mkdirSync, 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 } from './mcp.mjs'; +import { binaryOf, argvOf, checkoutOf, childEnv } from './mcp.mjs'; const LAUNCHER = join(dirname(fileURLToPath(import.meta.url)), 'mcp.mjs'); const WIN = process.platform === 'win32'; @@ -24,7 +24,9 @@ function world() { const machine = join(base, 'machine'); for (const dir of [local, machine]) { mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, WIN ? 'zerograph.exe' : 'zerograph'), ''); + const file = join(dir, WIN ? 'zerograph.exe' : 'zerograph'); + writeFileSync(file, ''); + chmodSync(file, 0o755); } return { base, checkout, local, machine }; } @@ -45,6 +47,29 @@ test('a PATH entry inside the checkout is passed over, relative or absolute', () assert.strictEqual(binaryOf('', { path, checkout: w.checkout, win: WIN }).bin, join(w.machine, WIN ? 'zerograph.exe' : 'zerograph')); }); +test('a symlinked alias of the checkout on PATH is the checkout', { skip: WIN && 'symlinks need a privilege' }, () => { + const w = world(); + const alias = join(w.base, 'alias'); + symlinkSync(w.checkout, alias); + const path = [join(alias, 'node_modules', '.bin'), w.machine].join(sep); + assert.strictEqual(binaryOf('', { path, checkout: w.checkout, win: WIN }).bin, join(w.machine, 'zerograph')); + assert.strictEqual(binaryOf('', { path, checkout: alias, win: WIN }).bin, join(w.machine, 'zerograph'), 'a session opened through the alias'); +}); + +test('a file on PATH that is not executable is not a zerograph', { skip: WIN && 'no exec bit' }, () => { + const w = world(); + const plain = join(w.base, 'plain'); + mkdirSync(plain); + writeFileSync(join(plain, 'zerograph'), ''); + assert.strictEqual(binaryOf('', { path: [plain, w.machine].join(sep), checkout: w.checkout, win: WIN }).bin, join(w.machine, 'zerograph')); +}); + +test('the server PATH is cut to the machine entries', () => { + const w = world(); + const env = { [WIN ? 'Path' : 'PATH']: ['node_modules/.bin', w.local, w.machine].join(sep), HOME: '/h' }; + assert.deepStrictEqual(childEnv(env, w.checkout, WIN), { [WIN ? 'Path' : 'PATH']: w.machine, HOME: '/h' }); +}); + test('an absolute option is the one started, even inside the checkout', () => { const w = world(); const named = join(w.local, 'zerograph'); @@ -66,12 +91,30 @@ test('nothing on PATH outside the checkout is an error that says how to fix it', test('the server is told the checkout around the project, not a package inside it', () => { const w = world(); assert.strictEqual(checkoutOf(join(w.checkout, 'packages', 'app')), w.checkout); - assert.deepStrictEqual(argvOf('/usr/local/bin/zerograph', w.checkout), ['/usr/local/bin/zerograph', '--repo', w.checkout, 'mcp']); + assert.deepStrictEqual(argvOf('/usr/local/bin/zerograph', w.checkout).argv, ['/usr/local/bin/zerograph', '--repo', w.checkout, 'mcp']); +}); + +test('outside git the repository is the nearest directory above holding a store, else the project', () => { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); + const project = join(base, 'repo', 'packages', 'app'); + mkdirSync(project, { recursive: true }); + assert.strictEqual(checkoutOf(project), project); + mkdirSync(join(base, 'repo', '.zerograph')); + assert.strictEqual(checkoutOf(project), join(base, 'repo')); }); test('an npm .cmd shim is started through cmd, an .exe directly', () => { - assert.deepStrictEqual(argvOf('C:\\npm\\zerograph.cmd', 'C:\\work', 'C:\\Windows\\cmd.exe'), ['C:\\Windows\\cmd.exe', '/d', '/c', 'call', 'C:\\npm\\zerograph.cmd', '--repo', 'C:\\work', 'mcp']); - assert.deepStrictEqual(argvOf('C:\\bin\\zerograph.exe', 'C:\\work'), ['C:\\bin\\zerograph.exe', '--repo', 'C:\\work', 'mcp']); + assert.deepStrictEqual(argvOf('C:\\npm\\zerograph.cmd', 'C:\\work', 'C:\\Windows\\cmd.exe').argv, ['C:\\Windows\\cmd.exe', '/d', '/c', 'call', 'C:\\npm\\zerograph.cmd', '--repo', 'C:\\work', 'mcp']); + assert.deepStrictEqual(argvOf('C:\\bin\\zerograph.exe', 'C:\\work').argv, ['C:\\bin\\zerograph.exe', '--repo', 'C:\\work', 'mcp']); +}); + +test('a .cmd shim is refused when cmd would act on a character in its path or the checkout', () => { + for (const [bin, checkout] of [['C:\\npm\\zerograph.cmd', 'C:\\src\\R&D'], ['C:\\npm\\zerograph.cmd', 'C:\\x&calc'], ['C:\\100%\\zerograph.cmd', 'C:\\work'], ['C:\\npm\\zerograph.cmd', 'C:\\a^b|ce"f!g']]) { + const launch = argvOf(bin, checkout, 'cmd.exe'); + assert.strictEqual(launch.argv, undefined, checkout); + assert.match(launch.error, /character cmd would act on/); + } + assert.ok(argvOf('C:\\bin\\zerograph.exe', 'C:\\src\\R&D').argv, 'an .exe takes no cmd, so nothing is refused'); }); // The fake is a Node script so the protocol round trip reads the same on every platform. diff --git a/docs/install.md b/docs/install.md index f87a4961..d2a6eca8 100644 --- a/docs/install.md +++ b/docs/install.md @@ -131,8 +131,11 @@ is an exact id, who calls a symbol, or what a change would break. The server is `zerograph --repo mcp`, started by `scripts/mcp.mjs` under `node`: the `node` that `npm i -g zerograph` needed, which an install from a release archive or `cargo` has to -put on `PATH` itself. The checkout is the one around `CLAUDE_PROJECT_DIR`, so a session opened in a -package of a monorepo indexes the whole repository. +put on `PATH` itself. The repository is the checkout around `CLAUDE_PROJECT_DIR`, so a session +opened in a package of a monorepo indexes the whole repository; outside git it is the nearest +directory above holding a `.zerograph/`, else the project directory. Claude Code starts that `node` +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 @@ -151,7 +154,9 @@ before npm's `zerograph.cmd` shim, which the mod runs through `cmd`. A zerograph shows nothing on the line, and `/zerograph` falls back to the tail of `background.log`. The MCP server's binary is found by the same rules, and a binary option that is not an absolute path, or no zerograph on `PATH` outside the checkout, fails its start with the reason in the server's log -(`/mcp` shows it). +(`/mcp` shows it). So does a `zerograph.cmd` shim when its path or the checkout's holds a character +`cmd` would act on (`& | < > ^ " % !`): name the `zerograph.exe` the npm package ships in the +`binary` option instead. The server runs with `PATH` cut to the entries outside the checkout. `zerograph status --json` walks the whole tree to count the files behind, so the mod runs it at session start, every five minutes, once after a refresh ends and after `/zerograph`. Between those diff --git a/docs/mcp.md b/docs/mcp.md index 7b20ea45..3cdadeb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,7 +63,7 @@ machine-wide file, so it is not covered; `zerograph mcp` is the command to give | `explain`, `impact`, `trace`, `changes` | the commands of those names, with their flags; an answer given while files are behind the tree says so | | `status` | nodes and edges, files behind the tree, the store's model against the configured one, vector rows owed, and whether a background refresh is running with its last progress line from `.zerograph/background.log` and what the run said besides, and whether a `serve` answers with its last log lines. The same report as `zerograph status` | | `reindex` | starts a detached `build` where no store stands, else a detached `update`, and returns at once; says so when one is already running. The child takes the writer lock as the command line does, so readers keep answering from the store as it stands | -| `cache_status` | the machine's vector cache: where it is, its cap, and the rows and bytes it holds per model. The same report as `zerograph cache status`; it reads and writes nothing else | +| `cache_status` | the machine's vector cache: where it is, its cap, and the rows and bytes it holds per model. The same report as `zerograph cache status`; it writes nothing, and a cache not made yet reads as empty | | `switch_model` | `model ` in the background. Only catalogued models and ones trusted on this machine with `zerograph model `; any other id is refused, because the caller is an agent and a model is code this machine downloads | `status` reports progress while the writer lock is held and nothing once the refresh is over. Each diff --git a/src/index/cache.rs b/src/index/cache.rs index 9c474ed7..4a63dbb9 100644 --- a/src/index/cache.rs +++ b/src/index/cache.rs @@ -255,6 +255,13 @@ impl VectorCache { Ok(VectorCache { root, max, detached: false }) } + /// The cache at `root` for a report, which must not make the directory it reports on: one + /// that is not there yet is an empty cache, and `at` takes over once one stands. + pub fn for_status(root: PathBuf, max: u64) -> Result { + if root.exists() { return VectorCache::at(root, max); } + Ok(VectorCache { root, max, detached: false }) + } + /// What a prune trims to: under the cap by enough that the next few writes do not prune again, /// and to nothing when the cap is 0. pub fn prune_target(&self) -> u64 { self.max / 100 * PRUNE_TO_PERCENT } @@ -538,6 +545,15 @@ mod tests { c.root.join(Layout::new(model, dims).dir_name()).join(format!("{shard:02x}0.rows")) } + #[test] + fn a_status_of_a_cache_not_made_yet_is_empty_and_makes_nothing() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("vectors"); + let c = VectorCache::for_status(root.clone(), 1 << 20).unwrap(); + assert!(c.status().is_empty()); + assert!(!root.exists(), "a report does not create the directory it reports on"); + } + #[test] fn a_row_put_comes_back_under_its_model_and_width_alone() { let (_d, c) = cache(); diff --git a/src/lib.rs b/src/lib.rs index 892ef8b0..263d23f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -740,7 +740,11 @@ impl Meter { fn cache_command(action: CacheAction) -> anyhow::Result { let max = config::Config::machine_cache_max()?; - let cache = index::cache::VectorCache::at(index::cache::default_root()?, max)?; + let root = index::cache::default_root()?; + let cache = match action { + CacheAction::Status => index::cache::VectorCache::for_status(root, max)?, + CacheAction::Prune => index::cache::VectorCache::at(root, max)?, + }; Ok(match action { CacheAction::Status if max == 0 => format!("{}cache: off (vector_cache_max = 0): not read or written; `zerograph cache prune` empties it\n", cache_status(&cache)), CacheAction::Status => cache_status(&cache), diff --git a/src/mcp.rs b/src/mcp.rs index dc5cda0e..bf687d40 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -398,7 +398,7 @@ fn tools() -> Vec { }), &[]), tool("status", "The index's state: size, files behind the tree, the model that wrote the vectors against the configured one, and whether a background refresh is running and how far it has got.", json!({}), &[]), tool("reindex", "Starts a background refresh of the index and returns at once: a first build where there is no index, else an update. Says so when one is already running. Follow it with `status`.", json!({}), &[]), - tool("cache_status", "The machine's vector cache, which every checkout reads before it embeds: where it is, its cap, and the rows and bytes it holds per model. Reads only.", json!({}), &[]), + tool("cache_status", "The machine's vector cache, which every checkout reads before it embeds: where it is, its cap, and the rows and bytes it holds per model. Writes nothing; a cache not made yet reads as empty.", json!({}), &[]), tool("switch_model", "Re-embeds the index under another embedding model, in the background. Only catalogued models and ones the user has trusted with `zerograph model `; any other id is refused.", json!({ "model": { "type": "string", "description": "A hub id." }, }), &["model"]), From f82773a249e43643774be45fd0a5a3b64ccbc3b3 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 10:54:59 +0200 Subject: [PATCH 03/10] test(cache): compare the empty status against main's Status shape Main's Status now carries the models and the unreadable bytes, so the report of a cache not made yet is compared whole instead of through a Vec method it no longer is. --- src/index/cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index/cache.rs b/src/index/cache.rs index e8453f9e..d9c8e247 100644 --- a/src/index/cache.rs +++ b/src/index/cache.rs @@ -704,7 +704,7 @@ mod tests { let d = tempfile::tempdir().unwrap(); let root = d.path().join("vectors"); let c = VectorCache::for_status(root.clone(), 1 << 20).unwrap(); - assert!(c.status().is_empty()); + assert_eq!(c.status(), Status { models: vec![], other: 0 }); assert!(!root.exists(), "a report does not create the directory it reports on"); } From a7e09e0b75fcf50607f2723b9c4ab16ed23db40c Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 11:26:20 +0200 Subject: [PATCH 04/10] feat(agent)!: wire Claude Code and Codex through their plugins only The Claude Code plugin now carries the session hook (moved from agent/hook.mjs to agent/claude-code/scripts/hook.mjs) beside its MCP server, and both find and start the binary through one shared launcher.mjs, so they cannot disagree about which zerograph is safe to run. The hook takes the plugin's binary option, and a checkout that never asked for an index hears nothing from it. agent/codex/ is a new Codex plugin listed by .agents/plugins/marketplace.json: the MCP server through a bootstrap that finds the installed plugin under CODEX_HOME (Codex substitutes nothing in MCP args), the hook on SessionStart, SubagentStart and after an apply_patch, and the commands as explicit-only skills. Its scripts are copies of the Claude Code plugin's, held identical by plugin.test.mjs, which CI now runs. install-agent --claude and --codex install the plugin through the claude or codex CLI, print the commands when the CLI is missing, and only once it is in take out what earlier releases wrote: the local-scope MCP servers for the checkout and its worktrees, the hook entries and script, the skills and subagent while still generated, and the instructions stanza (kept in AGENTS.md while opencode reads it). BREAKING CHANGE: install-agent --claude/--codex no longer register an MCP server, write hooks, skills or a stanza; they install the plugin and remove those files from an earlier install. --command no longer reaches Claude Code or Codex. --- .agents/plugins/marketplace.json | 12 + .github/workflows/ci.yml | 2 +- README.md | 29 +- agent/claude-code/agents/repo-scout.md | 27 + agent/claude-code/hooks/hooks.json | 10 +- agent/{ => claude-code/scripts}/hook.mjs | 174 +-- agent/{ => claude-code/scripts}/hook.test.mjs | 98 +- agent/claude-code/scripts/launcher.mjs | 121 ++ agent/claude-code/scripts/mcp.mjs | 128 +- agent/codex.md | 41 +- agent/codex/.codex-plugin/plugin.json | 18 + agent/codex/.mcp.json | 16 + agent/codex/hooks/hooks.json | 7 + agent/codex/plugin.test.mjs | 84 ++ agent/codex/scripts/hook.mjs | 571 +++++++++ agent/codex/scripts/launcher.mjs | 121 ++ agent/codex/scripts/mcp.mjs | 47 + agent/codex/skills/ask/SKILL.md | 19 + agent/codex/skills/ask/agents/openai.yaml | 3 + agent/codex/skills/changes/SKILL.md | 18 + agent/codex/skills/changes/agents/openai.yaml | 3 + agent/codex/skills/explain/SKILL.md | 14 + agent/codex/skills/explain/agents/openai.yaml | 3 + agent/codex/skills/impact/SKILL.md | 17 + agent/codex/skills/impact/agents/openai.yaml | 3 + agent/codex/skills/refresh/SKILL.md | 15 + agent/codex/skills/refresh/agents/openai.yaml | 3 + agent/codex/skills/status/SKILL.md | 17 + agent/codex/skills/status/agents/openai.yaml | 3 + agent/codex/skills/trace/SKILL.md | 16 + agent/codex/skills/trace/agents/openai.yaml | 3 + agent/codex/skills/use-the-graph/SKILL.md | 29 + .../skills/use-the-graph/agents/openai.yaml | 2 + agent/{skill => legacy}/repo-query.md | 0 agent/{subagent.md => legacy/repo-scout.md} | 0 agent/{ => legacy}/rule.txt | 0 agent/{skill/SKILL.md => legacy/skill.md} | 0 docs/install.md | 67 +- docs/keeping-fresh.md | 14 +- docs/mcp.md | 42 +- docs/releases/0.7.0.md | 33 +- src/install_agent.rs | 1127 ++++++----------- src/lib.rs | 77 +- 43 files changed, 1897 insertions(+), 1137 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 agent/claude-code/agents/repo-scout.md rename agent/{ => claude-code/scripts}/hook.mjs (77%) rename agent/{ => claude-code/scripts}/hook.test.mjs (91%) create mode 100644 agent/claude-code/scripts/launcher.mjs create mode 100644 agent/codex/.codex-plugin/plugin.json create mode 100644 agent/codex/.mcp.json create mode 100644 agent/codex/hooks/hooks.json create mode 100644 agent/codex/plugin.test.mjs create mode 100644 agent/codex/scripts/hook.mjs create mode 100644 agent/codex/scripts/launcher.mjs create mode 100644 agent/codex/scripts/mcp.mjs create mode 100644 agent/codex/skills/ask/SKILL.md create mode 100644 agent/codex/skills/ask/agents/openai.yaml create mode 100644 agent/codex/skills/changes/SKILL.md create mode 100644 agent/codex/skills/changes/agents/openai.yaml create mode 100644 agent/codex/skills/explain/SKILL.md create mode 100644 agent/codex/skills/explain/agents/openai.yaml create mode 100644 agent/codex/skills/impact/SKILL.md create mode 100644 agent/codex/skills/impact/agents/openai.yaml create mode 100644 agent/codex/skills/refresh/SKILL.md create mode 100644 agent/codex/skills/refresh/agents/openai.yaml create mode 100644 agent/codex/skills/status/SKILL.md create mode 100644 agent/codex/skills/status/agents/openai.yaml create mode 100644 agent/codex/skills/trace/SKILL.md create mode 100644 agent/codex/skills/trace/agents/openai.yaml create mode 100644 agent/codex/skills/use-the-graph/SKILL.md create mode 100644 agent/codex/skills/use-the-graph/agents/openai.yaml rename agent/{skill => legacy}/repo-query.md (100%) rename agent/{subagent.md => legacy/repo-scout.md} (100%) rename agent/{ => legacy}/rule.txt (100%) rename agent/{skill/SKILL.md => legacy/skill.md} (100%) 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 dbd6875d..7102149b 100644 --- a/README.md +++ b/README.md @@ -32,27 +32,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 keeps the graph fresh after edits. 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 77% rename from agent/hook.mjs rename to agent/claude-code/scripts/hook.mjs index de3bbc4a..be511baa 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 { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; +import { binaryOf, 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 HERE = dirname(fileURLToPath(import.meta.url)); +const COMMAND = 'zerograph'; -/** - * 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,98 +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 checkout = root && (checkoutRoot(root) ?? root); - const resolved = checkout && real(checkout); - return (process.env.PATH || '').split(delimiter) - .filter((d) => d && isAbsolute(d) && !inside(checkout, d) && !inside(resolved, real(d))); -} +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 && (checkoutRoot(root) ?? 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 = {}) { - const key = Object.keys(process.env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH'; - return { ...process.env, [key]: machineDirs(root).join(delimiter), ...extra }; +function onPath(root, platform = process.platform) { + return binaryOf('', { path: process.env[pathKey(process.env)], 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)], 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; } /** @@ -192,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); @@ -207,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 }); @@ -220,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`. @@ -229,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. @@ -265,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]; @@ -537,8 +469,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. @@ -585,9 +519,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); }, @@ -632,4 +568,4 @@ if (invokedDirectly()) { main(); } -export { rule, RULE_FALLBACK, COMMAND, binary, onPath, bounded, intercept, riskLine, handlers, MAX_INJECTIONS }; +export { 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 729f73f6..acc58b54 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'); }); @@ -654,46 +654,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..b2787b93 --- /dev/null +++ b/agent/claude-code/scripts/launcher.mjs @@ -0,0 +1,121 @@ +// 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 { 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; } +} + +/** The nearest directory at or above `start` holding `name`, or null. */ +export function nearest(start, name) { + for (let d = 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 under whatever case the environment spells it in. */ +export function pathKey(env) { + return 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) { + const resolved = checkout && real(checkout); + return (path ?? '').split(win ? ';' : ':') + .filter(dir => dir && isAbsolute(dir) && !inside(checkout, dir, win) && !inside(resolved, real(dir), 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 }) { + 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); + // 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 = {}) { + const key = pathKey(env); + return { ...env, [key]: machineDirs(env[key], checkout, win).join(win ? ';' : ':'), ...extra }; +} diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs index b4381861..c0a61ac6 100644 --- a/agent/claude-code/scripts/mcp.mjs +++ b/agent/claude-code/scripts/mcp.mjs @@ -1,125 +1,31 @@ #!/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 { 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. */ -function nearest(start, name) { - for (let d = 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) { - const resolved = real(checkout); - return (path ?? '').split(win ? ';' : ':') - .filter(dir => dir && isAbsolute(dir) && !inside(checkout, dir, win) && !inside(resolved, real(dir), 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 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 }) { - 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); - // 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) { - const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; - return { ...env, [key]: machineDirs(env[key], checkout, win).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 pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; - const found = binaryOf(process.argv[2] ?? '', { path: env[pathKey], 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()); + // The option comes as the one argument under Claude Code; Codex has no options and passes + // ZEROGRAPH_BIN through instead. + const found = binaryOf(process.argv[2] || env.ZEROGRAPH_BIN || '', { path: env[pathKey(env)], 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. @@ -136,6 +42,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/codex.md b/agent/codex.md index d461db78..e81072ea 100644 --- a/agent/codex.md +++ b/agent/codex.md @@ -64,23 +64,46 @@ 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 what `env_vars` names. With no `cwd` it starts in the project. So + `.mcp.json` runs `node -e` with a bootstrap that imports `scripts/mcp.mjs` from the cache path + under `CODEX_HOME` (or `~/.codex`), and the version in that path is held equal to + `plugin.json`'s by `plugin.test.mjs`. +- 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 `plugin.test.mjs` fails when they differ. ## 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..1513b898 --- /dev/null +++ b/agent/codex/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "zerograph": { + "command": "node", + "env_vars": [ + "CODEX_HOME", + "ZEROGRAPH_BIN" + ], + "args": [ + "--input-type=module", + "-e", + "import{homedir}from'node:os';import{join}from'node:path';import{pathToFileURL}from'node:url';const m=await import(pathToFileURL(join(process.env.CODEX_HOME||join(homedir(),'.codex'),'plugins','cache','zerograph','zerograph','0.7.0','scripts','mcp.mjs')).href);m.main();" + ] + } + } +} diff --git a/agent/codex/hooks/hooks.json b/agent/codex/hooks/hooks.json new file mode 100644 index 00000000..bfe81512 --- /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\"", "timeout": 10 }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "timeout": 5 }] }], + "PostToolUse": [{ "matcher": "apply_patch|Edit|Write", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "timeout": 10 }] }] + } +} diff --git a/agent/codex/plugin.test.mjs b/agent/codex/plugin.test.mjs new file mode 100644 index 00000000..30d3510e --- /dev/null +++ b/agent/codex/plugin.test.mjs @@ -0,0 +1,84 @@ +// 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; copy it from agent/claude-code/scripts`); + } +}); + +test('one version: both plugins, the bootstrap\'s cache path 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); + // Codex installs a plugin under its version, and the bootstrap names that directory. + assert.ok(json(CODEX, '.mcp.json').mcpServers.zerograph.args.join(' ').includes(`'${version}'`)); +}); + +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"'); + } + 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 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 version = json(CODEX, '.codex-plugin', 'plugin.json').version; + const home = join(base, 'codex-home'); + cpSync(CODEX, join(home, 'plugins', 'cache', 'zerograph', 'zerograph', version), { recursive: true }); + 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`); +}); diff --git a/agent/codex/scripts/hook.mjs b/agent/codex/scripts/hook.mjs new file mode 100644 index 00000000..be511baa --- /dev/null +++ b/agent/codex/scripts/hook.mjs @@ -0,0 +1,571 @@ +#!/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, 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 && (checkoutRoot(root) ?? 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)], 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)], 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; +} + +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 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(); +} + +export { 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..b2787b93 --- /dev/null +++ b/agent/codex/scripts/launcher.mjs @@ -0,0 +1,121 @@ +// 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 { 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; } +} + +/** The nearest directory at or above `start` holding `name`, or null. */ +export function nearest(start, name) { + for (let d = 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 under whatever case the environment spells it in. */ +export function pathKey(env) { + return 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) { + const resolved = checkout && real(checkout); + return (path ?? '').split(win ? ';' : ':') + .filter(dir => dir && isAbsolute(dir) && !inside(checkout, dir, win) && !inside(resolved, real(dir), 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 }) { + 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); + // 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 = {}) { + const key = pathKey(env); + return { ...env, [key]: machineDirs(env[key], checkout, win).join(win ? ';' : ':'), ...extra }; +} diff --git a/agent/codex/scripts/mcp.mjs b/agent/codex/scripts/mcp.mjs new file mode 100644 index 00000000..c0a61ac6 --- /dev/null +++ b/agent/codex/scripts/mcp.mjs @@ -0,0 +1,47 @@ +#!/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()); + // The option comes as the one argument under Claude Code; Codex has no options and passes + // ZEROGRAPH_BIN through instead. + const found = binaryOf(process.argv[2] || env.ZEROGRAPH_BIN || '', { path: env[pathKey(env)], 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/docs/install.md b/docs/install.md index 968cfaf4..ebaa0596 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,23 @@ 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. +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: -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. +- `--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 skill or subagent somebody edited, a link, or a directory with files of its own is left and +named. 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 +108,16 @@ 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`, then `claude plugin install zerograph@zerograph`) 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 a background refresh. 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 @@ -134,6 +146,25 @@ latest release, and nothing merged to `main` reaches an install before its tag. changes `Cargo.toml`, `agent/claude-code/.claude-plugin/plugin.json` and the `ref` in `.claude-plugin/marketplace.json` together; CI fails when they disagree. +## 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). 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 +only `CODEX_HOME` and `ZEROGRAPH_BIN` from the environment, so a project-local binary goes in +`ZEROGRAPH_BIN`. Codex has no hook before a shell command, so nothing answers a grep before it runs. + ## Windows notes Install through npm; nothing else is required. What can still trip a Windows user: 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..cf0978a2 100644 --- a/docs/releases/0.7.0.md +++ b/docs/releases/0.7.0.md @@ -17,6 +17,17 @@ 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` and `claude plugin install zerograph@zerograph`; `--codex` + runs `codex plugin marketplace add devmaxxx/zerograph` and `codex plugin add zerograph@zerograph`. + 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. 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 +71,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 +92,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 +110,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 +129,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 +148,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/src/install_agent.rs b/src/install_agent.rs index 5a86e74e..91647fde 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -1,25 +1,29 @@ -//! `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 repository both harnesses read the marketplace from, and the plugin's id in it. +const MARKETPLACE: &str = "devmaxxx/zerograph"; +const PLUGIN: &str = "zerograph@zerograph"; + #[derive(Clone, Copy, Debug, PartialEq)] pub enum Target { Claude, Codex } @@ -28,47 +32,60 @@ 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" } } - /// 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 } + /// The commands that install the plugin, each safe to repeat. Claude Code's marketplace pins + /// the plugin to a release tag itself; 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", "install", PLUGIN])], + Target::Codex => vec![ + s(&["plugin", "marketplace", "add", MARKETPLACE, "--ref", &format!("v{VERSION}")]), + s(&["plugin", "add", PLUGIN]), + ], + } + } } #[derive(Debug, Default)] pub struct Report { pub written: usize, pub paths: Vec, pub notes: 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) } - -/// 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\";"; +/// 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; -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()) +pub fn run_cli(program: &str, cwd: &Path, args: &[String]) -> Option { + std::process::Command::new(program).args(args).current_dir(cwd).stdin(std::process::Stdio::null()).output().ok() } -/// 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(" ")) +/// 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 by_hand = steps.iter().map(|s| format!(" {cli} {}", s.iter().map(|a| shell_word(a)).collect::>().join(" "))).collect::>().join("\n"); + for step in &steps { + match run(cli, root, step) { + 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() => { + let said = [&out.stderr[..], &out.stdout[..]].iter().map(|b| String::from_utf8_lossy(b).trim().to_string()).find(|s| !s.is_empty()).unwrap_or_default(); + report.notes.push(format!("`{cli} {}` failed ({said}), so the plugin was not installed; the commands are:\n{by_hand}", step.join(" "))); + 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 +102,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 +132,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 +170,24 @@ 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, report: &mut Report) { + 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 +204,163 @@ 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, 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", 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; + for list in hooks.values_mut() { + let Some(arr) = list.as_array_mut() else { continue }; + let before = arr.len(); + arr.retain(|e| !e.to_string().contains(LEGACY_HOOK)); + found |= arr.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. +fn retire_hook_entries(path: &Path, report: &mut Report) { + let Ok(existing) = std::fs::read_to_string(path) else { return }; + match without_hook_entries(&existing) { + Ok(None) => {} + 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", report), + 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())), + 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())); + } + Err(e) => report.notes.push(format!("{} could not be rewritten ({e}); take out the entries that run {LEGACY_HOOK}", path.display())), + }, + Err(e) => report.notes.push(format!("{} is left as it is ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())), + } +} + +/// Takes our stanza out of an instructions file, removing the file when it was all it held. +fn retire_stanza(path: &Path, 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 rest.trim().is_empty() { + remove(path, "it held only the stanza an earlier release wrote, and the plugin's skill replaces it", 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())), + } +} + +/// 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()) } -/// 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 { +/// Removes the hook script an earlier release wrote into `hooks`, and its rule while it is still the +/// text that release generated. +pub fn retire_hook_script(hooks: &Path, report: &mut Report) { + remove(&hooks.join(LEGACY_HOOK), "the plugin runs the session hook now", 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", 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) -> Report { 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)?; - } + let base = root.join(target.dir()); + retire_hook_script(&base.join("hooks"), &mut report); + if target == Target::Claude { retire_hook_entries(&base.join("settings.json"), &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, &mut report); } + if skills.join("repo-query").exists() { retire_skill(&skills.join("repo-query"), &|t| generated_from(LEGACY_REPO_QUERY, t), &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", &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, &mut report) }, + Target::Codex if opencode_wants_stanza(root) => {} + Target::Codex => retire_stanza(&root.join("AGENTS.md"), &mut report), + } + remove_if_empty(&base); + 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(), - }; - 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 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")), } +} - // 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 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)?, +/// 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. +pub fn retire_codex_hooks(home: &Path) -> Report { + let mut report = Report::default(); + let path = home.join("hooks.json"); + let Ok(seen) = std::fs::metadata(&path) else { return report }; + let Ok(existing) = std::fs::read_to_string(&path) else { return report }; + match without_hook_entries(&existing) { + Ok(None) => {} + 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())); + } + Err(e) => report.notes.push(format!("{} could not be rewritten ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())), + }, + Err(e) => report.notes.push(format!("{} is left as it is ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())), } - Ok(report) + report } /// An editor or CLI that reads a project MCP file and an instructions file, and none of the @@ -454,10 +479,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 +500,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 +536,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 +553,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 +581,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 +594,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 +644,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 +703,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 +775,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 +784,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 +840,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 +850,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 +863,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 +889,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 +943,191 @@ 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}"); - #[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"); - } - let second = install(dir.path(), Target::Claude, "zerograph").unwrap(); - assert_eq!(second.written, 0, "a second install is a no-op: {second:?}"); + 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); } - #[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 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() } } - /// 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()); + 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 install 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 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. #[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"); + 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 install zerograph@zerograph"), "{:?}", r.notes); } - /// 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. #[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")); + 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); + } + + /// 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"), "#!/usr/bin/env node\n"); + 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)); } - /// 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. #[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"); - } + older_install(dir.path(), "pnpm exec zerograph"); + let r = retire_repo_install(dir.path(), Target::Claude); + 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"); - /// A matcher the stamp says we wrote is ours to move when the default changes; any other stays. - #[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}"); - } + let r = retire_repo_install(dir.path(), Target::Codex); + assert!(!dir.path().join(".codex").exists(), "{r:?}"); + assert!(!dir.path().join("AGENTS.md").exists(), "a file that held only the stanza goes"); - #[test] - fn the_stanza_lands_in_a_root_instructions_file_when_there_is_one_and_replaces_only_itself() { - 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}"); + let again = retire_repo_install(dir.path(), Target::Claude); + assert_eq!((again.written, again.notes.len()), (0, 0), "{again:?}"); } - /// 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. #[test] - fn codex_gets_its_stanza_at_the_root_and_no_repository_hooks_file() { + fn settings_that_held_only_our_hooks_go_and_a_team_edited_skill_or_scout_stays() { 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"); - } - - #[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")); - } - - /// 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}"); - } - 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""#); + 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); + 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:?}"); } #[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); + 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); + 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()); + 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()).written, 0); + assert_eq!(retire_codex_hooks(&dir.path().join("none")).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}"); - - 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 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..e29a8cff 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,7 +169,7 @@ 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, }, /// What a coding agent should be told about this repository at the start of a session: node @@ -1299,13 +1299,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,40 +1315,37 @@ 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 retired = vec![install_agent::retire_repo_install(&repo, target)]; + 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))); + }, + install_agent::Target::Codex => { + // An earlier release wrote the Codex hook into the main worktree, for the + // machine's hooks file to point at. + 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"), &mut r); + retired.push(r); + } + if let Some(home) = install_agent::codex_home() { retired.push(install_agent::retire_codex_hooks(&home)); } } } - // 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())); + for r in &retired { for note in &r.notes { println!("{name}: {note}"); } } + 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}"); } Ok(()) From c32d9c2932e09f718515b69ac018a5b93226d367 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 11:36:16 +0200 Subject: [PATCH 05/10] fix(agent): keep others' hooks and links when retiring an older install - Hook entries are retired per command, so a command sharing a matcher group with ours stays. - A symlinked settings file, rule, subagent or hook script is left and named instead of unlinked. - $CODEX_HOME/hooks.json is rewritten where its link points. - The MCP launcher takes ZEROGRAPH_BIN before the binary option, as the hook does, and treats the unsubstituted option as unset. - On Windows the claude and codex CLIs are found as npm .cmd shims too. - The docs no longer promise a refresh after every edit. --- README.md | 2 +- agent/claude-code/scripts/mcp.mjs | 10 +++- agent/claude-code/scripts/mcp.test.mjs | 16 ++++- agent/codex/scripts/mcp.mjs | 10 +++- docs/install.md | 3 +- src/install_agent.rs | 82 ++++++++++++++++++++++++-- 6 files changed, 108 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7102149b..97beac5a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ zerograph install-agent --claude # optional: the Claude Code plugin 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 keeps the graph fresh after edits. A status mod puts the index's state under +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. diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs index c0a61ac6..0bd3d3f3 100644 --- a/agent/claude-code/scripts/mcp.mjs +++ b/agent/claude-code/scripts/mcp.mjs @@ -17,15 +17,19 @@ export function argvOf(bin, checkout, comspec) { return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); } +const UNSET_OPTION = '${user_config.binary}'; + 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()); - // The option comes as the one argument under Claude Code; Codex has no options and passes - // ZEROGRAPH_BIN through instead. - const found = binaryOf(process.argv[2] || env.ZEROGRAPH_BIN || '', { path: env[pathKey(env)], checkout, win }); + // 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; Codex has + // no options and passes ZEROGRAPH_BIN through. + const option = process.argv[2] === UNSET_OPTION ? '' : process.argv[2]; + const found = binaryOf(env.ZEROGRAPH_BIN || option || '', { path: env[pathKey(env)], 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. diff --git a/agent/claude-code/scripts/mcp.test.mjs b/agent/claude-code/scripts/mcp.test.mjs index 340ee9cc..6ad26aca 100644 --- a/agent/claude-code/scripts/mcp.test.mjs +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -3,7 +3,7 @@ // 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'; @@ -147,3 +147,17 @@ test('no binary is a line in the server log and a failed start, never a word on assert.strictEqual(r.stdout, ''); 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); + } +}); diff --git a/agent/codex/scripts/mcp.mjs b/agent/codex/scripts/mcp.mjs index c0a61ac6..0bd3d3f3 100644 --- a/agent/codex/scripts/mcp.mjs +++ b/agent/codex/scripts/mcp.mjs @@ -17,15 +17,19 @@ export function argvOf(bin, checkout, comspec) { return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); } +const UNSET_OPTION = '${user_config.binary}'; + 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()); - // The option comes as the one argument under Claude Code; Codex has no options and passes - // ZEROGRAPH_BIN through instead. - const found = binaryOf(process.argv[2] || env.ZEROGRAPH_BIN || '', { path: env[pathKey(env)], checkout, win }); + // 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; Codex has + // no options and passes ZEROGRAPH_BIN through. + const option = process.argv[2] === UNSET_OPTION ? '' : process.argv[2]; + const found = binaryOf(env.ZEROGRAPH_BIN || option || '', { path: env[pathKey(env)], 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. diff --git a/docs/install.md b/docs/install.md index ebaa0596..06783c73 100644 --- a/docs/install.md +++ b/docs/install.md @@ -116,7 +116,8 @@ the server, hook, skill and stanza an older `install-agent --claude` wrote (see 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 a background refresh. A checkout hears from them only once it has a +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 diff --git a/src/install_agent.rs b/src/install_agent.rs index 91647fde..c461e04f 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -59,7 +59,27 @@ pub struct Report { pub written: usize, pub paths: Vec, pub notes: Vec = &'a dyn Fn(&str, &Path, &[String]) -> Option; pub fn run_cli(program: &str, cwd: &Path, args: &[String]) -> Option { - std::process::Command::new(program).args(args).current_dir(cwd).stdin(std::process::Stdio::null()).output().ok() + 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 @@ -172,6 +192,12 @@ fn write_if_changed(path: &Path, text: &str, report: &mut Report) -> Result<()> /// Removes `path`, counted and named, or says why it stayed. fn remove(path: &Path, why: &str, report: &mut Report) { + // 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; + } match std::fs::remove_file(path) { Ok(()) => { report.written += 1; @@ -228,11 +254,23 @@ pub fn without_hook_entries(existing: &str) -> Result> { 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(arr) = list.as_array_mut() else { continue }; - let before = arr.len(); - arr.retain(|e| !e.to_string().contains(LEGACY_HOOK)); - found |= arr.len() < before; + 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())); @@ -245,8 +283,8 @@ fn retire_hook_entries(path: &Path, report: &mut Report) { let Ok(existing) = std::fs::read_to_string(path) else { return }; match without_hook_entries(&existing) { Ok(None) => {} - 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", report), 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())), + 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", report), Ok(Some(rest)) => match std::fs::write(path, rest) { Ok(()) => { report.written += 1; @@ -345,7 +383,9 @@ pub fn codex_home() -> Option { /// paste in: with the plugin's hook beside them, the hook would answer every event twice. pub fn retire_codex_hooks(home: &Path) -> 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 report }; let Ok(existing) = std::fs::read_to_string(&path) else { return report }; match without_hook_entries(&existing) { @@ -1112,6 +1152,36 @@ mod tests { assert_eq!(retire_codex_hooks(&dir.path().join("none")).written, 0); } + #[test] + 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" }] }] } })); + } + + #[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).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); + assert!(is_symlink(&repo.join(".claude").join("settings.json")), "{r:?}"); + assert!(r.notes.iter().any(|n| n.contains("is a symlink")), "{r:?}"); + } + #[test] fn the_server_an_earlier_release_registered_for_a_live_checkout_goes_when_the_plugin_replaces_it() { let dir = root(); From fab26a54c49c3e3058ab3ec46ab8b8333ec227ef Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 11:51:01 +0200 Subject: [PATCH 06/10] fix(agent): keep a home-directory checkout's PATH and find links as the CLI does - When the checkout is the home directory, an ancestor of it or a filesystem root (a ~/.zerograph or a dotfiles ~/.git), the PATH cut is skipped in both the MCP launcher and the session hook, since it took nvm, cargo and ~/.local/bin with it. - PATH is matched case-insensitively only on Windows. - A quoted Windows PATH entry is judged without its quotes and kept as written. - The checkout is looked up from the real path of the session directory, as the CLI canonicalizes its working directory. --- agent/claude-code/scripts/mcp.mjs | 48 +++++++++++++++++++------- agent/claude-code/scripts/mcp.test.mjs | 37 +++++++++++++++++++- agent/hook.mjs | 18 +++++++--- agent/hook.test.mjs | 17 +++++++++ 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs index b4381861..4851ffbd 100644 --- a/agent/claude-code/scripts/mcp.mjs +++ b/agent/claude-code/scripts/mcp.mjs @@ -6,6 +6,7 @@ // 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 { fileURLToPath } from 'node:url'; @@ -30,9 +31,13 @@ export function real(p) { try { return realpathSync.native(p); } catch { return p; } } -/** The nearest directory at or above `start` holding `name`, or null. */ +/** + * 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 = start; ; d = dirname(d)) { + for (let d = real(start); ; d = dirname(d)) { if (existsSync(join(d, name))) return d; if (dirname(d) === d) return null; } @@ -53,10 +58,30 @@ export function checkoutOf(root) { * 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) { +export function machineDirs(path, checkout, win, home = homedir()) { + const cut = guarded(checkout, win, home); const resolved = real(checkout); - return (path ?? '').split(win ? ';' : ':') - .filter(dir => dir && isAbsolute(dir) && !inside(checkout, dir, win) && !inside(resolved, real(dir), win)); + 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'; } /** @@ -65,12 +90,12 @@ export function machineDirs(path, checkout, win) { * 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 }) { +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); + 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) { @@ -107,9 +132,9 @@ export function argvOf(bin, checkout, comspec) { } /** The environment the server runs in: PATH cut to the machine's entries, as the binary was. */ -export function childEnv(env, checkout, win) { - const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; - return { ...env, [key]: machineDirs(env[key], checkout, win).join(win ? ';' : ':') }; +export function childEnv(env, checkout, win, home) { + const key = pathKey(env, win); + return { ...env, [key]: machineDirs(env[key], checkout, win, home).join(win ? ';' : ':') }; } function main() { @@ -118,8 +143,7 @@ function main() { // a plugin's server in is not documented. const checkout = checkoutOf(process.env.CLAUDE_PROJECT_DIR || process.cwd()); const env = process.env; - const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; - const found = binaryOf(process.argv[2] ?? '', { path: env[pathKey], checkout, win }); + const found = binaryOf(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. diff --git a/agent/claude-code/scripts/mcp.test.mjs b/agent/claude-code/scripts/mcp.test.mjs index 340ee9cc..b3b94745 100644 --- a/agent/claude-code/scripts/mcp.test.mjs +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -9,7 +9,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; -import { binaryOf, argvOf, checkoutOf, childEnv } from './mcp.mjs'; +import { binaryOf, argvOf, checkoutOf, childEnv, machineDirs, pathKey } from './mcp.mjs'; const LAUNCHER = join(dirname(fileURLToPath(import.meta.url)), 'mcp.mjs'); const WIN = process.platform === 'win32'; @@ -147,3 +147,38 @@ test('no binary is a line in the server log and a failed start, never a word on assert.strictEqual(r.stdout, ''); assert.match(r.stderr, /relative\/zerograph\) is not an absolute path/); }); + +test('a checkout that is the home directory, or above it, cuts nothing from PATH', () => { + const w = world(); + const home = join(w.checkout, 'me'); + const nvm = join(home, '.nvm', 'bin'); + const path = [nvm, w.machine].join(sep); + 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.deepStrictEqual(machineDirs([join(w.checkout, 'bin'), w.machine].join(sep), w.checkout, WIN, w.machine), [w.machine], 'a checkout beside home is still cut'); +}); + +test('PATH is the only PATH on POSIX, and any case of it on Windows', () => { + const w = world(); + assert.strictEqual(pathKey({ Path: 'x', PATH: 'y' }, false), 'PATH'); + assert.strictEqual(pathKey({ Path: 'x' }, false), 'PATH'); + assert.strictEqual(pathKey({ Path: 'x' }, true), 'Path'); + const env = childEnv({ Path: join(w.checkout, 'bin'), PATH: w.machine }, w.checkout, false); + assert.strictEqual(env.PATH, w.machine); + assert.strictEqual(env.Path, join(w.checkout, 'bin'), 'another variable, left as it is'); +}); + +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); +}); + +test('a session opened through a link finds the checkout by its real path, as the CLI does', { skip: WIN && 'symlinks need a privilege' }, () => { + const w = world(); + const link = join(w.base, 'link'); + symlinkSync(join(w.checkout, 'packages', 'app'), link); + assert.strictEqual(checkoutOf(link), w.checkout); +}); diff --git a/agent/hook.mjs b/agent/hook.mjs index de3bbc4a..098dac7e 100644 --- a/agent/hook.mjs +++ b/agent/hook.mjs @@ -21,7 +21,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync, lstat 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 { homedir, 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 @@ -114,12 +114,21 @@ function onPath(root, platform = process.platform) { * on PATH nor a session opened through one lets the checkout's file through. */ function machineDirs(root) { - const checkout = root && (checkoutRoot(root) ?? 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.PATH || '').split(delimiter) + 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'; +} + /** `p` with every link followed, or as given when it does not resolve. */ function real(p) { try { return realpathSync.native(p); } catch { return p; } @@ -131,8 +140,7 @@ function real(p) { * entry an `.envrc` put inside the checkout would start the checkout's `node` one step on. */ function childEnv(root, extra = {}) { - const key = Object.keys(process.env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH'; - return { ...process.env, [key]: machineDirs(root).join(delimiter), ...extra }; + return { ...process.env, [pathKey()]: machineDirs(root).join(delimiter), ...extra }; } /** diff --git a/agent/hook.test.mjs b/agent/hook.test.mjs index 729f73f6..7e0dbf5e 100644 --- a/agent/hook.test.mjs +++ b/agent/hook.test.mjs @@ -702,3 +702,20 @@ test('malformed input is silence with exit zero', () => { assert.equal(r.status, 0); assert.equal(r.stdout, ''); }); + +test('a home directory that is a checkout keeps its own PATH entries', { skip: WIN && 'HOME is not the Windows profile' }, () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-home-'))); + mkdirSync(join(home, '.git')); + mkdirSync(join(home, 'notes')); + const bin = join(home, '.nvm', 'bin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, 'zerograph'), ''); + chmodSync(join(bin, 'zerograph'), 0o755); + const saved = { HOME: process.env.HOME, PATH: process.env.PATH }; + Object.assign(process.env, { HOME: home, PATH: [bin, '/usr/bin'].join(delimiter) }); + try { + assert.equal(onPath(join(home, 'notes')), join(bin, 'zerograph')); + } finally { + Object.assign(process.env, saved); + } +}); From 12a9f6b975eb5a710822e0cf4e99e90a96ceadb7 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 12:04:13 +0200 Subject: [PATCH 07/10] test(agent): compare launcher paths the way Windows spells them The checkout lookup follows links with realpathSync.native, which on Windows also expands 8.3 short names such as RUNNER~1, so the tests now build their expected paths the same way. The POSIX PATH-key case uses POSIX literals, since a drive letter's colon split a Windows host's real paths. --- agent/claude-code/scripts/mcp.test.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/agent/claude-code/scripts/mcp.test.mjs b/agent/claude-code/scripts/mcp.test.mjs index b3b94745..33516ec4 100644 --- a/agent/claude-code/scripts/mcp.test.mjs +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -16,7 +16,7 @@ const WIN = process.platform === 'win32'; /** A checkout that ships a zerograph where a local install puts one, and one on the machine. */ function world() { - const base = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); + const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); const checkout = join(base, 'checkout'); mkdirSync(join(checkout, '.git'), { recursive: true }); mkdirSync(join(checkout, 'packages', 'app'), { recursive: true }); @@ -95,7 +95,7 @@ test('the server is told the checkout around the project, not a package inside i }); test('outside git the repository is the nearest directory above holding a store, else the project', () => { - const base = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); + const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); const project = join(base, 'repo', 'packages', 'app'); mkdirSync(project, { recursive: true }); assert.strictEqual(checkoutOf(project), project); @@ -161,13 +161,13 @@ test('a checkout that is the home directory, or above it, cuts nothing from PATH }); test('PATH is the only PATH on POSIX, and any case of it on Windows', () => { - const w = world(); assert.strictEqual(pathKey({ Path: 'x', PATH: 'y' }, false), 'PATH'); assert.strictEqual(pathKey({ Path: 'x' }, false), 'PATH'); assert.strictEqual(pathKey({ Path: 'x' }, true), 'Path'); - const env = childEnv({ Path: join(w.checkout, 'bin'), PATH: w.machine }, w.checkout, false); - assert.strictEqual(env.PATH, w.machine); - assert.strictEqual(env.Path, join(w.checkout, 'bin'), 'another variable, left as it is'); + // 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'); + assert.strictEqual(env.PATH, '/usr/bin'); + assert.strictEqual(env.Path, '/src/app/bin', 'another variable, left as it is'); }); test('a quoted Windows PATH entry is judged without its quotes and kept as written', () => { From b4e304d7312a37b7ed0d18550b26dcb21115ac96 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 12:04:13 +0200 Subject: [PATCH 08/10] test(agent): compare launcher paths the way Windows spells them The checkout lookup follows links with realpathSync.native, which on Windows also expands 8.3 short names such as RUNNER~1, so the tests now build their expected paths the same way. The POSIX PATH-key case uses POSIX literals, since a drive letter's colon split a Windows host's real paths. --- agent/claude-code/scripts/mcp.test.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/agent/claude-code/scripts/mcp.test.mjs b/agent/claude-code/scripts/mcp.test.mjs index 70d7168b..0b0db212 100644 --- a/agent/claude-code/scripts/mcp.test.mjs +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -17,7 +17,7 @@ const WIN = process.platform === 'win32'; /** A checkout that ships a zerograph where a local install puts one, and one on the machine. */ function world() { - const base = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); + const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); const checkout = join(base, 'checkout'); mkdirSync(join(checkout, '.git'), { recursive: true }); mkdirSync(join(checkout, 'packages', 'app'), { recursive: true }); @@ -96,7 +96,7 @@ test('the server is told the checkout around the project, not a package inside i }); test('outside git the repository is the nearest directory above holding a store, else the project', () => { - const base = realpathSync(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); + const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'zerograph-mcp-test-'))); const project = join(base, 'repo', 'packages', 'app'); mkdirSync(project, { recursive: true }); assert.strictEqual(checkoutOf(project), project); @@ -176,13 +176,13 @@ test('a checkout that is the home directory, or above it, cuts nothing from PATH }); test('PATH is the only PATH on POSIX, and any case of it on Windows', () => { - const w = world(); assert.strictEqual(pathKey({ Path: 'x', PATH: 'y' }, false), 'PATH'); assert.strictEqual(pathKey({ Path: 'x' }, false), 'PATH'); assert.strictEqual(pathKey({ Path: 'x' }, true), 'Path'); - const env = childEnv({ Path: join(w.checkout, 'bin'), PATH: w.machine }, w.checkout, false); - assert.strictEqual(env.PATH, w.machine); - assert.strictEqual(env.Path, join(w.checkout, 'bin'), 'another variable, left as it is'); + // 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'); + assert.strictEqual(env.PATH, '/usr/bin'); + assert.strictEqual(env.Path, '/src/app/bin', 'another variable, left as it is'); }); test('a quoted Windows PATH entry is judged without its quotes and kept as written', () => { From 09551db696e4edc90156bd31d33b1be95f0986ef Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 12:44:39 +0200 Subject: [PATCH 09/10] fix: settle the review of the agent plugins - The risk line reads Codex's apply_patch: every file a patch adds, updates or moves to is scored, not only Claude Code's file_path. - install-agent takes a hook script out only after the entries that run it are gone, in .claude/settings.json and in $CODEX_HOME/hooks.json, and only while it is the text 0.6.0-0.6.4 wrote (kept in agent/legacy/zerograph-hook.mjs). - Files git tracks are left for the team and listed in one line; --retire-repo takes them out once every teammate has the plugin. - A re-run after a binary upgrade moves the plugin with it: Claude Code refreshes the marketplace and runs plugin update, and Codex removes and re-adds a marketplace pinned to an older tag. - The Codex MCP bootstrap finds the newest installed version instead of naming one, says so when none is installed, and passes the ZEROGRAPH_*, model cache, proxy, certificate and locale variables through. - Codex hooks carry a commandWindows form that reads PLUGIN_ROOT in node. - mcp.mjs drops its own copy of the unset-option placeholder. - scripts/sync-plugin-scripts.sh copies the launcher scripts into the Codex plugin. - Docs and release notes name the commands the code runs. --- agent/claude-code/scripts/hook.mjs | 25 +- agent/claude-code/scripts/hook.test.mjs | 17 + agent/claude-code/scripts/mcp.mjs | 9 +- agent/codex.md | 18 +- agent/codex/.mcp.json | 36 +- agent/codex/hooks/hooks.json | 6 +- agent/codex/plugin.test.mjs | 43 ++- agent/codex/scripts/hook.mjs | 25 +- agent/codex/scripts/mcp.mjs | 9 +- agent/legacy/zerograph-hook.mjs | 459 ++++++++++++++++++++++++ docs/install.md | 33 +- docs/releases/0.7.0.md | 11 +- scripts/sync-plugin-scripts.sh | 16 + src/install_agent.rs | 342 +++++++++++++++--- src/lib.rs | 42 ++- 15 files changed, 975 insertions(+), 116 deletions(-) create mode 100644 agent/legacy/zerograph-hook.mjs create mode 100755 scripts/sync-plugin-scripts.sh diff --git a/agent/claude-code/scripts/hook.mjs b/agent/claude-code/scripts/hook.mjs index 01d2dc2b..6c37a918 100644 --- a/agent/claude-code/scripts/hook.mjs +++ b/agent/claude-code/scripts/hook.mjs @@ -414,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('/'); @@ -568,4 +585,6 @@ if (invokedDirectly()) { main(); } -export { rule, RULE, 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/claude-code/scripts/hook.test.mjs b/agent/claude-code/scripts/hook.test.mjs index a0bf3a89..afa079a3 100644 --- a/agent/claude-code/scripts/hook.test.mjs +++ b/agent/claude-code/scripts/hook.test.mjs @@ -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 }, diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs index b116d05c..398b69dc 100644 --- a/agent/claude-code/scripts/mcp.mjs +++ b/agent/claude-code/scripts/mcp.mjs @@ -17,8 +17,6 @@ export function argvOf(bin, checkout, comspec) { return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); } -const UNSET_OPTION = '${user_config.binary}'; - export function main() { const win = process.platform === 'win32'; const env = process.env; @@ -26,10 +24,9 @@ export function main() { // 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; Codex has - // no options and passes ZEROGRAPH_BIN through. - const option = process.argv[2] === UNSET_OPTION ? '' : process.argv[2]; - const found = binaryOf(env.ZEROGRAPH_BIN || option || '', { path: env[pathKey(env, win)], checkout, win }); + // 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. diff --git a/agent/codex.md b/agent/codex.md index e81072ea..89ef14fb 100644 --- a/agent/codex.md +++ b/agent/codex.md @@ -87,15 +87,23 @@ Established on 0.154.0 with a scratch `CODEX_HOME` and a local marketplace, no m 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 what `env_vars` names. With no `cwd` it starts in the project. So - `.mcp.json` runs `node -e` with a bootstrap that imports `scripts/mcp.mjs` from the cache path - under `CODEX_HOME` (or `~/.codex`), and the version in that path is held equal to - `plugin.json`'s by `plugin.test.mjs`. + 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 `plugin.test.mjs` fails when they differ. +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` diff --git a/agent/codex/.mcp.json b/agent/codex/.mcp.json index 1513b898..71f0d635 100644 --- a/agent/codex/.mcp.json +++ b/agent/codex/.mcp.json @@ -4,12 +4,44 @@ "command": "node", "env_vars": [ "CODEX_HOME", - "ZEROGRAPH_BIN" + "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{homedir}from'node:os';import{join}from'node:path';import{pathToFileURL}from'node:url';const m=await import(pathToFileURL(join(process.env.CODEX_HOME||join(homedir(),'.codex'),'plugins','cache','zerograph','zerograph','0.7.0','scripts','mcp.mjs')).href);m.main();" + "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 index bfe81512..2e23c5b3 100644 --- a/agent/codex/hooks/hooks.json +++ b/agent/codex/hooks/hooks.json @@ -1,7 +1,7 @@ { "hooks": { - "SessionStart": [{ "matcher": "startup|resume|clear|compact", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "timeout": 10 }] }], - "SubagentStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "timeout": 5 }] }], - "PostToolUse": [{ "matcher": "apply_patch|Edit|Write", "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/scripts/hook.mjs\"", "timeout": 10 }] }] + "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 index 30d3510e..4c994259 100644 --- a/agent/codex/plugin.test.mjs +++ b/agent/codex/plugin.test.mjs @@ -19,16 +19,16 @@ 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; copy it from agent/claude-code/scripts`); + `agent/codex/scripts/${file} differs; run scripts/sync-plugin-scripts.sh`); } }); -test('one version: both plugins, the bootstrap\'s cache path and the crate', () => { +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); - // Codex installs a plugin under its version, and the bootstrap names that directory. - assert.ok(json(CODEX, '.mcp.json').mcpServers.zerograph.args.join(' ').includes(`'${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', () => { @@ -52,7 +52,12 @@ test('the hooks run the shipped hook, and the marketplace points at this directo 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"'); + 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'); @@ -61,11 +66,23 @@ test('the hooks run the shipped hook, and the marketplace points at this directo 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 version = json(CODEX, '.codex-plugin', 'plugin.json').version; const home = join(base, 'codex-home'); - cpSync(CODEX, join(home, 'plugins', 'cache', 'zerograph', 'zerograph', version), { recursive: true }); + 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')); @@ -81,4 +98,16 @@ test('the MCP bootstrap finds the installed plugin under CODEX_HOME and serves t 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 index 01d2dc2b..6c37a918 100644 --- a/agent/codex/scripts/hook.mjs +++ b/agent/codex/scripts/hook.mjs @@ -414,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('/'); @@ -568,4 +585,6 @@ if (invokedDirectly()) { main(); } -export { rule, RULE, 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/codex/scripts/mcp.mjs b/agent/codex/scripts/mcp.mjs index b116d05c..398b69dc 100644 --- a/agent/codex/scripts/mcp.mjs +++ b/agent/codex/scripts/mcp.mjs @@ -17,8 +17,6 @@ export function argvOf(bin, checkout, comspec) { return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); } -const UNSET_OPTION = '${user_config.binary}'; - export function main() { const win = process.platform === 'win32'; const env = process.env; @@ -26,10 +24,9 @@ export function main() { // 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; Codex has - // no options and passes ZEROGRAPH_BIN through. - const option = process.argv[2] === UNSET_OPTION ? '' : process.argv[2]; - const found = binaryOf(env.ZEROGRAPH_BIN || option || '', { path: env[pathKey(env, win)], checkout, win }); + // 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. 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 06783c73..017da42a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -63,8 +63,11 @@ plugin, then takes out what releases before 0.7.0 wrote, which the plugin now do `.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 skill or subagent somebody edited, a link, or a directory with files of its own is left and -named. Both take the `post-merge`/`post-checkout` block an earlier release wrote out of the git +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. @@ -109,7 +112,9 @@ by name from its own `PATH`, which the plugin cannot pin; a checkout whose direc `PATH`, or on Windows the directory the server starts in, could put its own `node` there. **With `install-agent --claude`.** It installs this plugin through the `claude` CLI (`claude plugin -marketplace add devmaxxx/zerograph`, then `claude plugin install zerograph@zerograph`) and removes +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__*`. @@ -144,8 +149,11 @@ 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 @@ -156,15 +164,24 @@ 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). The +`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 -only `CODEX_HOME` and `ZEROGRAPH_BIN` from the environment, so a project-local binary goes in -`ZEROGRAPH_BIN`. Codex has no hook before a shell command, so nothing answers a grep before it runs. +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/releases/0.7.0.md b/docs/releases/0.7.0.md index cf0978a2..5a1f1cfa 100644 --- a/docs/releases/0.7.0.md +++ b/docs/releases/0.7.0.md @@ -20,12 +20,17 @@ links to. - **`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` and `claude plugin install zerograph@zerograph`; `--codex` - runs `codex plugin marketplace add devmaxxx/zerograph` and `codex plugin add zerograph@zerograph`. + 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. Where the CLI + 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. 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 c461e04f..6e6942ce 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -19,10 +19,13 @@ const LEGACY_SKILL: &str = include_str!("../agent/legacy/skill.md"); 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 } @@ -36,23 +39,75 @@ impl Target { match self { Target::Claude => "claude", Target::Codex => "codex" } } - /// The commands that install the plugin, each safe to repeat. Claude Code's marketplace pins - /// the plugin to a release tag itself; 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. + /// 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", "install", PLUGIN])], + 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]), ], } } + + /// 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, +} + +/// 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, +} + +/// 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()) +} + +/// 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 +} /// How a CLI is run: `program`, in `cwd`, with `args`; `None` when it is not installed. A test /// passes its own. @@ -89,16 +144,27 @@ 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 by_hand = steps.iter().map(|s| format!(" {cli} {}", s.iter().map(|a| shell_word(a)).collect::>().join(" "))).collect::>().join("\n"); + 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 { - match run(cli, root, step) { + 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() => { - let said = [&out.stderr[..], &out.stdout[..]].iter().map(|b| String::from_utf8_lossy(b).trim().to_string()).find(|s| !s.is_empty()).unwrap_or_default(); - report.notes.push(format!("`{cli} {}` failed ({said}), so the plugin was not installed; the commands are:\n{by_hand}", step.join(" "))); + 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(_) => {} @@ -191,13 +257,15 @@ fn write_if_changed(path: &Path, text: &str, report: &mut Report) -> Result<()> } /// Removes `path`, counted and named, or says why it stayed. -fn remove(path: &Path, why: &str, report: &mut Report) { +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; @@ -233,7 +301,7 @@ fn generated_from(template: &str, text: &str) -> bool { /// 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, report: &mut Report) { +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| { @@ -243,7 +311,7 @@ fn retire_skill(dir: &Path, ours: &dyn Fn(&str) -> bool, report: &mut Report) { 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; } - remove(&file, "the plugin carries the skill now", report); + remove(&file, "the plugin carries the skill now", retire, report); let _ = std::fs::remove_dir(dir); } @@ -278,35 +346,51 @@ pub fn without_hook_entries(existing: &str) -> Result> { 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. -fn retire_hook_entries(path: &Path, report: &mut Report) { - let Ok(existing) = std::fs::read_to_string(path) else { return }; +/// 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) => {} - 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())), - 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", report), + 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!("{} could not be rewritten ({e}); take out the entries that run {LEGACY_HOOK}", path.display())), }, - Err(e) => report.notes.push(format!("{} is left as it is ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())), + 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, report: &mut Report) { +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; } + 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", report); + 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) { @@ -326,13 +410,38 @@ fn opencode_wants_stanza(root: &Path) -> bool { .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, + }; + 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. -pub fn retire_hook_script(hooks: &Path, report: &mut Report) { - remove(&hooks.join(LEGACY_HOOK), "the plugin runs the session hook now", report); +/// 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", report), + 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(_) => {} } @@ -343,29 +452,32 @@ pub fn retire_hook_script(hooks: &Path, report: &mut Report) { /// 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) -> Report { +pub fn retire_repo_install(root: &Path, target: Target, retire: Retire) -> Report { let mut report = Report::default(); let base = root.join(target.dir()); - retire_hook_script(&base.join("hooks"), &mut report); - if target == Target::Claude { retire_hook_entries(&base.join("settings.json"), &mut report); } + let entries_gone = match target { + Target::Claude => retire_hook_entries(&base.join("settings.json"), retire, &mut report), + Target::Codex => retire.machine_hooks_gone, + }; + 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, &mut report); } - if skills.join("repo-query").exists() { retire_skill(&skills.join("repo-query"), &|t| generated_from(LEGACY_REPO_QUERY, t), &mut report); } + 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", &mut report), + 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, &mut report) }, + 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"), &mut report), + Target::Codex => retire_stanza(&root.join("AGENTS.md"), retire, &mut report), } remove_if_empty(&base); report @@ -380,27 +492,38 @@ pub fn codex_home() -> Option { } /// 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. -pub fn retire_codex_hooks(home: &Path) -> Report { +/// 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 report }; - let Ok(existing) = std::fs::read_to_string(&path) else { return report }; - match without_hook_entries(&existing) { - Ok(None) => {} + 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!("{} could not be rewritten ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())), }, - Err(e) => report.notes.push(format!("{} is left as it is ({e:#}); take out the entries that run {LEGACY_HOOK}", path.display())), - } - report + 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 @@ -1017,7 +1140,8 @@ mod tests { #[test] 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 install zerograph@zerograph".to_string()]), + (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()); @@ -1033,7 +1157,7 @@ mod tests { 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 install zerograph@zerograph"), "{:?}", 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); } #[test] @@ -1049,6 +1173,46 @@ mod tests { assert!(r.notes[0].contains("network down") && r.notes[0].contains("codex plugin add zerograph@zerograph"), "{:?}", r.notes); } + #[test] + 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 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| { @@ -1057,7 +1221,7 @@ mod tests { std::fs::write(p, text).unwrap(); }; for dir in [".claude", ".codex"] { - w(&format!("{dir}/hooks/zerograph-hook.mjs"), "#!/usr/bin/env node\n"); + 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")); } @@ -1076,7 +1240,7 @@ mod tests { fn a_re_run_over_an_older_install_takes_out_what_it_wrote_and_keeps_the_rest() { let dir = root(); older_install(dir.path(), "pnpm exec zerograph"); - let r = retire_repo_install(dir.path(), Target::Claude); + 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:?}"); @@ -1086,11 +1250,11 @@ mod tests { 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); + 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); + let again = retire_repo_install(dir.path(), Target::Claude, OLD); assert_eq!((again.written, again.notes.len()), (0, 0), "{again:?}"); } @@ -1102,19 +1266,81 @@ mod tests { 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); + 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_hook_script_stays_while_an_entry_still_runs_it() { + let dir = root(); + 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_entries_left_in_place_keep_the_script_they_run() { + let dir = root(); + 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 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:?}"); + } + + #[test] + 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| p.ends_with(rel)), "{rel}: {r:?}"); + } + 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 opencode_keeps_the_agents_md_stanza_it_still_reads() { let dir = root(); older_install(dir.path(), "zerograph"); install_editor(dir.path(), Editor::Opencode, "zerograph").unwrap(); - retire_repo_install(dir.path(), Target::Codex); + retire_repo_install(dir.path(), Target::Codex, OLD); assert!(std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap().contains(BEGIN)); } @@ -1125,7 +1351,7 @@ mod tests { 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); + 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)); } @@ -1145,11 +1371,11 @@ mod tests { 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()); + 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()).written, 0); - assert_eq!(retire_codex_hooks(&dir.path().join("none")).written, 0); + assert_eq!(retire_codex_hooks(dir.path()).1.written, 0); + assert_eq!(retire_codex_hooks(&dir.path().join("none")).1.written, 0); } #[test] @@ -1168,7 +1394,7 @@ mod tests { 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).written, 1); + 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 })); @@ -1177,7 +1403,7 @@ mod tests { 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); + 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:?}"); } diff --git a/src/lib.rs b/src/lib.rs index e29a8cff..eed358ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -171,6 +171,9 @@ enum Cmd { #[arg(long)] opencode: bool, /// 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(); @@ -1320,23 +1323,38 @@ fn run(cli: Cli) -> anyhow::Result<()> { 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 retired = vec![install_agent::retire_repo_install(&repo, target)]; + 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); + } + } + 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))); }, - install_agent::Target::Codex => { - // An earlier release wrote the Codex hook into the main worktree, for the - // machine's hooks file to point at. - 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"), &mut r); - retired.push(r); - } - if let Some(home) = install_agent::codex_home() { retired.push(install_agent::retire_codex_hooks(&home)); } - } + // 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 \ From f24abeb925ae617c5ad54735577322e2d4d0657c Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 12:53:20 +0200 Subject: [PATCH 10/10] test: compare committed paths as paths, not strings On Windows the report holds backslash paths, so a string suffix with forward slashes never matched. --- src/install_agent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/install_agent.rs b/src/install_agent.rs index 6e6942ce..228deefd 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -1322,7 +1322,7 @@ mod tests { 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| p.ends_with(rel)), "{rel}: {r:?}"); + assert!(r.committed.iter().any(|p| Path::new(p).ends_with(rel)), "{rel}: {r:?}"); } 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");