nearly-cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ Watch the first thirty seconds. The cover says what the diff cannot: an action t
22
22
 
23
23
  ## What it needs
24
24
 
25
- Node 18 or newer, Claude Code signed in, and git. No dependencies and no API key: agents run on your existing Claude subscription.
25
+ Node 18 or newer, git, and one of the seven coding agents below signed in. No dependencies and no API key of its own: agents run on whatever subscription you already have.
26
26
 
27
27
  ### Platforms
28
28
 
@@ -46,6 +46,73 @@ but a whole session with a real agent, and a real push through the hook, have
46
46
  only been done on macOS. If you are the first to try either on Windows or Linux,
47
47
  an issue with what broke would be genuinely useful.
48
48
 
49
+ ### Agents
50
+
51
+ The editor is not the question. Claude Code in VS Code, in a JetBrains IDE, in a
52
+ plain terminal or over SSH all read the same `.claude/settings.local.json`, so
53
+ all four are already covered. Neither is the model — the gate sits between the
54
+ agent and your machine, below whichever model is answering.
55
+
56
+ The harness making the tool calls is the question, because that is what exposes
57
+ the hook. Seven are supported:
58
+
59
+ | | Config it writes | Holds for a human | Record |
60
+ |---|---|---|---|
61
+ | Claude Code | `.claude/settings.local.json` | yes | full |
62
+ | Cursor | `.cursor/hooks.json` | yes | full |
63
+ | Antigravity | `.agents/hooks.json` | yes | full |
64
+ | GitHub Copilot CLI | `.github/hooks/nearly.json` | yes | full |
65
+ | Gemini CLI | `.gemini/settings.json` | yes | full |
66
+ | Codex CLI | `.codex/hooks.json` | yes | no prompts, one turn |
67
+ | Windsurf | `.windsurf/hooks.json` | until Cascade gives up | shell and file tools only |
68
+
69
+ `nearly` turns on whichever of these the repo shows signs of, and Claude Code
70
+ either way. `nearly --agent=cursor` forces one, `--agent=all` forces all of them,
71
+ and `nearly agents` prints what is actually wired here.
72
+
73
+ **One of these rows is not like the others.** Claude Code has been run end to end
74
+ against a live agent. The other six are built from each vendor's published hook
75
+ documentation and tested against payloads copied from it — every adapter has to
76
+ refuse `rm -rf` and have that refusal land in words its harness acts on, or the
77
+ suite fails. That is a good bet. It is not the same as having watched it work,
78
+ and `nearly agents` says so in as many words:
79
+
80
+ ```
81
+ Run against a live agent: Claude Code
82
+ Built to the vendor's published hook spec and tested against payloads
83
+ copied from it, but never yet run against the real thing:
84
+ Cursor, Antigravity, GitHub Copilot CLI, Codex CLI, Gemini CLI, Windsurf
85
+ ```
86
+
87
+ If you use one of those six, the most useful thing you can do is try it and open
88
+ an issue saying what broke.
89
+
90
+ #### What the adapters actually do
91
+
92
+ The server speaks one dialect. Everything downstream of a hook — the consent
93
+ gradient, the recording, the record page, the PR comment — reads Claude Code's
94
+ shape and nothing else. An adapter is a translation at the edge, about thirty
95
+ lines: their payload in, ours out; our answer in, theirs out.
96
+
97
+ Two decisions make that small enough to trust. Nearly never asks the harness to
98
+ ask — every one of them can prompt, and we want none of it, because their dialog
99
+ is not the record. We hold the hook open and answer once a human has. And tools
100
+ are matched by shape as well as by name: `run_command`, `shell`,
101
+ `run_terminal_cmd` and `bash` are all Bash, and anything carrying a command
102
+ string is treated as Bash even if nobody here has heard of it — because if it
103
+ isn't, the never-rules don't apply to it and `rm -rf` walks through a gate that
104
+ reports itself as working. Anything still unrecognised falls to `ask`.
105
+
106
+ The harness's own name for the tool travels with the call, so the record says
107
+ `run_command` where Antigravity said `run_command`, while the rule you set
108
+ applies to every one of them.
109
+
110
+ #### Not supported
111
+
112
+ Zed's built-in agent, Aider, Kilo Code, Warp and the hosted builders (Replit,
113
+ Lovable, Bolt, v0) expose no blocking pre-tool hook. There is nothing to attach
114
+ to, and no adapter can change that.
115
+
49
116
  ## Use it on your own repo
50
117
 
51
118
  One command, in the repo you want recorded.
@@ -63,7 +130,8 @@ npx nearly-cli
63
130
  ```
64
131
  ✓ Nearly is on for my-app
65
132
 
66
- · every Claude Code session here is gated and recorded
133
+ · Claude Code sessions here are gated and recorded (run end to end against a live agent)
134
+ · Cursor sessions here are gated and recorded (built to their published hook spec, not yet run against a live agent)
67
135
  · upgrades reach this repo automatically
68
136
  · the record is offered when you push
69
137
 
@@ -142,7 +210,13 @@ Every tool call passes through an HTTP `PreToolUse` hook to this server, which s
142
210
 
143
211
  "Allow always" and "Never" turn a decision into a rule for the rest of the run, keyed by tool and first word of the command, or file extension for edits. In lab mode every turn is committed in the agent's worktree by the `Stop` hook, so **Undo turn** is a `git reset --hard HEAD~1`.
144
212
 
145
- Agents in lab mode are real Claude Code sessions (`claude -p`) on your Claude subscription, each in its own git worktree under `workspace/.worktrees/`. No API key, no paid infrastructure, anywhere in this project.
213
+ Agents in lab mode are real Claude Code sessions (`claude -p`) on your Claude
214
+ subscription, each on its own branch in its own git worktree. You pick which
215
+ repo to branch from — the dashboard offers the ones you have turned Nearly on
216
+ for — and the branch starts from that repo's HEAD, so it begins where you
217
+ actually are rather than on some assumed `main`. Worktrees live under
218
+ `~/.nearly/workspace/.worktrees/`, outside the installed package, so upgrading
219
+ never deletes one. No API key, no paid infrastructure, anywhere in this project.
146
220
 
147
221
  ## What the record actually contains
148
222
 
@@ -248,12 +322,16 @@ Claude Code treats a hook that times out, errors, or returns anything other than
248
322
  npm test
249
323
  ```
250
324
 
251
- 41 tests, no dependencies, about 30 seconds. They run on a fresh clone with no
325
+ 83 tests, no dependencies, about 50 seconds. They run on a fresh clone with no
252
326
  agent, no network and no Claude subscription, because the fixtures are the two
253
327
  recorded sessions committed in `recordings/demo`.
254
328
 
255
329
  What they hold the project to:
256
330
 
331
+ - **Every adapter.** That `rm -rf` is refused in all seven harnesses' dialects,
332
+ that the refusal comes back in words each one acts on, that a session appears
333
+ from whichever field that harness calls its session id, and that a payload none
334
+ of them would ever send leaves the agent working rather than hanging.
257
335
  - **The consent gradient.** That destructive commands are denied without asking,
258
336
  that a never pattern still fires when the command is buried in a chain, that an
259
337
  unclassified tool is held rather than allowed, and that "always" for `git status`
@@ -278,17 +356,18 @@ The claim this project makes is testable: a reviewer who sees the session record
278
356
  ## Roadmap
279
357
 
280
358
  - **Read Prempti's audit trail as an input.** Their recording is structured, local, Apache-licensed and covers more than ours. The recap builder reads its own JSONL today; a second reader would let anyone already running Prempti get a session record without changing their gate.
281
- - **A Cursor adapter.** The server speaks JSON in and JSON out, so a tool that runs a script instead of calling a URL needs about twenty lines of translation. Cursor's `beforeShellExecution` is the first target.
359
+ - **Run the six unverified adapters against their real agents.** They are built to spec and tested against the vendors' own documented payloads, but documentation is not a build. Each one that gets run for real either becomes a verified row or becomes a bug report.
282
360
  - **Port the recap player to React.** It is one self-contained page today.
283
361
 
284
362
  ## Files
285
363
 
364
+ - `server/adapters.mjs`, the seven harnesses and the translation at each edge
286
365
  - `server/index.mjs`, spawn sessions, hooks, policy, recorder, undo
287
366
  - `ui/index.html`, sessions, triage of pending approvals, rules, log
288
367
  - `scripts/attach.mjs`, install or remove the hooks in a repo of your own; `scripts/post-recap.mjs`, comment the recap on its PR
289
368
  - `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
290
369
  - `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
291
370
  - `scripts/install-push-hook.mjs` + `scripts/push-record.mjs`, hand the branch record over at `git push`
292
- - `workspace/`, the repo agents work on (seeded with the Tempo demo)
371
+ - `~/.nearly/`, where recordings, records and agent worktrees are kept
293
372
  - `recordings/<session>.jsonl`, every event and decision; `recordings/demo/` is committed so the records can be rebuilt from source
294
373
  - `STUDY.md`, the protocol for testing whether any of this helps a reviewer
package/bin/nearly.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // nearly open open the dashboard
7
7
  // nearly record build the record for the current branch
8
8
  // nearly post put that record on the pull request
9
+ // nearly agents which agents this repo is gated for
9
10
  // nearly voices list the narration voices you have
10
11
  // nearly server run the server in the foreground (it self-starts otherwise)
11
12
  // nearly hook <ev> internal: what the Claude Code hooks call
@@ -34,7 +35,12 @@ const run = async (file, args = []) => {
34
35
  process.exit(r.status ?? 0);
35
36
  };
36
37
 
37
- const [cmd = 'attach', ...rest] = process.argv.slice(2);
38
+ // A leading flag is not a command. `nearly --agent=cursor` and `nearly --off`
39
+ // are how the docs say to do those things, and both used to land on "Unknown
40
+ // command" because the first argument was read as a subcommand name.
41
+ const argv = process.argv.slice(2);
42
+ const leadingFlag = argv[0]?.startsWith('-') && !['--help', '-h', '--which'].includes(argv[0]);
43
+ const [cmd = 'attach', ...rest] = leadingFlag ? ['attach', ...argv] : argv;
38
44
 
39
45
  async function main() {
40
46
  switch (cmd) {
@@ -68,6 +74,9 @@ switch (cmd) {
68
74
  case 'publish':
69
75
  return run(s('publish-pages.mjs'), rest);
70
76
 
77
+ case 'agents':
78
+ return run(s('agents.mjs'), rest);
79
+
71
80
  case 'voices':
72
81
  return run(s('build-recap.mjs'), ['--voices']);
73
82
 
@@ -92,6 +101,7 @@ switch (cmd) {
92
101
  nearly open open the dashboard
93
102
  nearly record build the record for the current branch
94
103
  nearly post put that record on the pull request
104
+ nearly agents which agents this repo is gated for
95
105
  nearly voices list the narration voices you have
96
106
  nearly server run the server in the foreground
97
107
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nearly-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "A pull request tells you what changed. Nearly tells you what nearly happened: the commands a human refused, the pushes policy blocked, the turns rolled back.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,6 @@
34
34
  },
35
35
  "homepage": "https://anujpatel06.github.io/nearly/",
36
36
  "scripts": {
37
- "test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs"
37
+ "test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs"
38
38
  }
39
39
  }
@@ -0,0 +1,61 @@
1
+ // What Nearly can gate, and how much of that is a claim rather than a
2
+ // demonstration.
3
+ //
4
+ // nearly agents what this repo is gated for, and what else is possible
5
+ //
6
+ // Printed because the difference matters. An adapter written from a vendor's
7
+ // hook documentation is a reasonable bet; it is not the same thing as having
8
+ // watched an agent be stopped. Saying so is cheap, and the alternative — a tool
9
+ // that reports coverage it has not earned — is the exact failure this project
10
+ // was built to catch.
11
+
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+ import { ADAPTERS } from '../server/adapters.mjs';
15
+ import { detect, installed } from './detect.mjs';
16
+
17
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
18
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
19
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
20
+
21
+ const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
22
+ const here = new Set(detect(repo).map((d) => d.id));
23
+ const machine = new Set(installed().map((d) => d.id));
24
+
25
+ // The config file existing proves nothing: Cursor writes .cursor/hooks.json for
26
+ // its own reasons. What proves the gate is wired is our command being inside it.
27
+ // Anything weaker would let this command report coverage it has not got.
28
+ const gated = (a) => {
29
+ const f = join(repo, a.config);
30
+ if (!existsSync(f)) return false;
31
+ try { return /nearly/i.test(readFileSync(f, 'utf8')); } catch { return false; }
32
+ };
33
+
34
+ const width = Math.max(...ADAPTERS.map((a) => a.name.length));
35
+ const pad = (s) => s + ' '.repeat(width - s.length);
36
+
37
+ console.log('');
38
+ console.log(` ${bold('Agents')} ${dim(repo)}`);
39
+ console.log('');
40
+ for (const a of ADAPTERS) {
41
+ const on = gated(a);
42
+ const mark = on ? ok('●') : dim('○');
43
+ const state = on ? 'gated here' : here.has(a.id) ? 'used here, not gated' : machine.has(a.id) ? 'installed, unused here' : 'not in use here';
44
+ console.log(` ${mark} ${bold(pad(a.name))} ${pad2(state)} ${dim(a.config)}`);
45
+ }
46
+ function pad2(s) { return s + ' '.repeat(Math.max(0, 22 - s.length)); }
47
+
48
+ console.log('');
49
+ const proven = ADAPTERS.filter((a) => a.verified);
50
+ const claimed = ADAPTERS.filter((a) => !a.verified);
51
+ console.log(` ${ok('Run against a live agent:')} ${proven.map((a) => a.name).join(', ')}`);
52
+ console.log(` ${dim('Built to the vendor\'s published hook spec and tested against payloads')}`);
53
+ console.log(` ${dim('copied from it, but never yet run against the real thing:')}`);
54
+ console.log(` ${dim(' ' + claimed.map((a) => a.name).join(', '))}`);
55
+ console.log('');
56
+ console.log(dim(' If you use one of those, the useful thing you can do is try it and say'));
57
+ console.log(dim(' what broke: github.com/anujpatel06/nearly/issues'));
58
+ console.log('');
59
+ console.log(dim(' nearly --agent=cursor turn one on for this repo'));
60
+ console.log(dim(' nearly --agent=all turn on every one of them'));
61
+ console.log('');
@@ -6,8 +6,10 @@
6
6
  //
7
7
  // One command, once per repo. It installs everything and works out the rest:
8
8
  //
9
- // · Claude Code hooks, so every session in this repo is gated and recorded
10
- // whether you start it in a terminal, in VS Code, or in JetBrains
9
+ // · hooks for every coding agent this repo is driven by, so sessions are
10
+ // gated and recorded whether you start them in a terminal, in VS Code or in
11
+ // JetBrains. Claude Code always; Cursor, Antigravity, Copilot, Codex, Gemini
12
+ // and Windsurf when the repo shows signs of them, or on --agent=
11
13
  // · a git pre-push hook, so the record is offered when the work leaves your
12
14
  // machine
13
15
  // · where the records are published, read from the Nearly's own remote
@@ -20,7 +22,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from
20
22
  import { join, resolve, dirname, basename } from 'node:path';
21
23
  import { fileURLToPath } from 'node:url';
22
24
  import { execFileSync, spawnSync } from 'node:child_process';
23
- import { dataRoot } from '../server/paths.mjs';
25
+ import { dataRoot, paths } from '../server/paths.mjs';
26
+ import { choose, installed as agentsOnMachine } from './detect.mjs';
27
+ import { ADAPTERS } from '../server/adapters.mjs';
24
28
 
25
29
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
26
30
  const HOOK = join(root, 'scripts', 'hook.mjs');
@@ -125,43 +129,45 @@ if (!existsSync(join(repo, '.git'))) {
125
129
  if (!off && installGlobally()) installed = onPath();
126
130
 
127
131
  // ---------------------------------------------------------------------------
128
- // Claude Code hooks
132
+ // Agent hooks
129
133
  // ---------------------------------------------------------------------------
130
- const dir = join(repo, '.claude');
131
- const file = join(dir, 'settings.local.json');
132
- mkdirSync(dir, { recursive: true });
133
-
134
- let settings = {};
135
- if (existsSync(file)) {
136
- try { settings = JSON.parse(readFileSync(file, 'utf8')); }
137
- catch (e) { console.error(`Could not read ${file}: ${e.message}`); process.exit(1); }
138
- }
139
- settings.hooks = settings.hooks || {};
140
-
141
- // Recognise our own entries by the script they run, so this is safe to re-run
142
- // and leaves anyone else's hooks alone.
143
- const ours = (m) => (m?.hooks || []).some((h) =>
144
- /nearly/.test(String(h.command || '')) || String(h.command || '').includes(HOOK) ||
145
- String(h.url || '').includes(`:${PORT}/hooks/`));
146
- for (const ev of Object.keys(settings.hooks)) {
147
- settings.hooks[ev] = (settings.hooks[ev] || []).filter((m) => !ours(m));
148
- if (!settings.hooks[ev].length) delete settings.hooks[ev];
134
+ // Turning off removes every adapter, not just the ones this repo still shows
135
+ // signs of, so nothing is left behind pointing at a command that will not run.
136
+ const { chosen, unknown } = off ? { chosen: ADAPTERS, unknown: [] } : choose(repo, argv);
137
+ if (unknown.length) {
138
+ console.error(`Unknown agent: ${unknown.join(', ')}`);
139
+ console.error(`Known: ${ADAPTERS.map((a) => a.id).join(', ')}`);
140
+ process.exit(1);
149
141
  }
150
142
 
151
- if (!off) {
152
- const entry = (ev, timeout) => ({
153
- hooks: [{ type: 'command', command: `${hookCmd(ev)} ${name}`, timeout }],
154
- });
155
- const add = (event, ev, timeout) => { settings.hooks[event] = [...(settings.hooks[event] || []), entry(ev, timeout)]; };
156
- add('SessionStart', 'session-start', 20);
157
- add('UserPromptSubmit', 'prompt', 20);
158
- add('PreToolUse', 'pre-tool', 600); // long enough to hold while a human decides
159
- add('PostToolUse', 'post-tool', 20);
160
- add('Stop', 'stop', 30);
161
- add('SessionEnd', 'session-end', 120);
143
+ const wired = [];
144
+ const notes = [];
145
+ for (const a of chosen) {
146
+ const cmdFor = (ev) => `${hookCmd(ev)} ${name}` + (a.id === 'claude-code' ? '' : ` --adapter=${a.id}`);
147
+ try {
148
+ const r = off ? a.uninstall({ repo }) : a.install({ repo, cmdFor, name });
149
+ if (r?.error) { notes.push(`${a.name}: ${r.error}`); continue; }
150
+ if (off ? r?.removed : r?.file) wired.push({ ...a, file: r.file });
151
+ if (r?.note) notes.push(`${a.name}: ${r.note}`);
152
+ } catch (e) {
153
+ // One harness's config being unwritable must not cost you the others.
154
+ notes.push(`${a.name}: ${e.message}`);
155
+ }
162
156
  }
163
- if (!Object.keys(settings.hooks).length) delete settings.hooks;
164
- writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
157
+
158
+ // Remember this repo, so the dashboard can offer it before anything has run in
159
+ // it. A list of paths and nothing else: it is a convenience, and it is rebuilt
160
+ // by simply turning Nearly on again.
161
+ try {
162
+ const f = paths.repos();
163
+ let list = [];
164
+ try { list = JSON.parse(readFileSync(f, 'utf8')); } catch { /* first one */ }
165
+ // Prune as we go: a repo that has been moved or deleted is noise in a list
166
+ // whose only job is to offer you somewhere to start.
167
+ list = list.filter((r) => r !== repo && existsSync(join(r, '.git')));
168
+ if (!off) list.unshift(repo);
169
+ writeFileSync(f, JSON.stringify(list.slice(0, 50), null, 2) + '\n');
170
+ } catch { /* the dashboard still works without it */ }
165
171
 
166
172
  // ---------------------------------------------------------------------------
167
173
  // git pre-push hook
@@ -213,7 +219,9 @@ if (base && !off) {
213
219
  console.log('');
214
220
  if (off) {
215
221
  console.log(`${bold('Nearly off')} for ${dim(repo)}`);
216
- console.log(' Claude Code hooks removed');
222
+ console.log(wired.length
223
+ ? ` hooks removed: ${wired.map((a) => a.name).join(', ')}`
224
+ : ' no agent hooks of ours were installed');
217
225
  console.log(push.status === 0 ? ' pre-push hook removed' : dim(' pre-push hook was not ours, left alone'));
218
226
  console.log('');
219
227
  process.exit(0);
@@ -221,7 +229,10 @@ if (off) {
221
229
 
222
230
  console.log(`${ok('✓')} ${bold('Nearly is on')} for ${bold(name)} ${dim(repo)}`);
223
231
  console.log('');
224
- console.log(` ${ok('·')} every Claude Code session here is gated and recorded`);
232
+ for (const a of wired) {
233
+ const how = a.verified ? dim(`(${a.verified})`) : dim('(built to their published hook spec, not yet run against a live agent)');
234
+ console.log(` ${ok('·')} ${a.name} sessions here are gated and recorded ${how}`);
235
+ }
225
236
  console.log(` ${ok('·')} ${dim(updateNote())}`);
226
237
  console.log(` ${ok('·')} ${push.status === 0 ? 'the record is offered when you push' : dim('pre-push hook skipped: ' + (push.stderr || '').trim().split('\n')[0])}`);
227
238
  if (base) {
@@ -233,6 +244,18 @@ if (base) {
233
244
  console.log(` ${dim('to link them from a pull request, host that folder anywhere and:')}`);
234
245
  console.log(` ${dim('NEARLY_URL_BASE=https://your-host/records nearly')}`);
235
246
  }
247
+ for (const n of notes) console.log(` ${dim('·')} ${dim(n)}`);
248
+
249
+ // An agent you have on this machine but have not used here is worth a word, and
250
+ // nothing more: having it installed is no reason to write files into this repo.
251
+ const elsewhere = agentsOnMachine().filter((i) => !wired.some((w) => w.id === i.id));
252
+ if (elsewhere.length) {
253
+ console.log('');
254
+ console.log(dim(` Also installed here: ${elsewhere.map((e) => e.name).join(', ')}.`));
255
+ console.log(dim(` Nothing in this repo suggests you use them for it, so they were left alone:`));
256
+ console.log(dim(` nearly --agent=${elsewhere[0].id} turns one on.`));
257
+ }
258
+
236
259
  console.log('');
237
260
  console.log(` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`);
238
261
  console.log(dim(' Nothing to leave running. Turn it off again with --off.'));
@@ -0,0 +1,75 @@
1
+ // Which coding agents is this repo actually driven by?
2
+ //
3
+ // The IDE is not the question. Claude Code inside VS Code or a JetBrains IDE is
4
+ // still Claude Code, reading the same settings file, so those need nothing. What
5
+ // matters is the harness making the tool calls, because that is what exposes the
6
+ // hook Nearly attaches to.
7
+ //
8
+ // This exists so nobody has to know that. You run one command; it finds what you
9
+ // use here and turns the gate on for each of them.
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { ADAPTERS, byId } from '../server/adapters.mjs';
15
+
16
+ // Signals beyond each adapter's own config file: the files a harness leaves in a
17
+ // repo whether or not anybody has configured hooks in it.
18
+ const MARKS = {
19
+ 'claude-code': ['.claude', 'CLAUDE.md', '.claude/settings.json'],
20
+ cursor: ['.cursor', '.cursorrules', '.cursor/rules'],
21
+ antigravity: ['.agents', '.antigravity'],
22
+ copilot: ['.github/copilot-instructions.md', '.github/hooks'],
23
+ codex: ['.codex', 'AGENTS.md'],
24
+ gemini: ['.gemini'],
25
+ windsurf: ['.windsurf', '.windsurfrules'],
26
+ };
27
+
28
+ const CLIS = {
29
+ 'claude-code': ['claude'], cursor: ['cursor-agent'], antigravity: ['agy'],
30
+ copilot: ['copilot'], codex: ['codex'], gemini: ['gemini'], windsurf: ['windsurf'],
31
+ };
32
+
33
+ function onPath(cmd) {
34
+ try {
35
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd],
36
+ { stdio: ['ignore', 'ignore', 'ignore'] });
37
+ return true;
38
+ } catch { return false; }
39
+ }
40
+
41
+ // A config file in the repo is evidence about this repo. A CLI on PATH is only
42
+ // evidence about the machine, so it is reported separately and never acted on:
43
+ // having Gemini installed is not a reason to write files into someone's project.
44
+ export function detect(repo) {
45
+ const found = [];
46
+ for (const a of ADAPTERS) {
47
+ const marks = [a.config, ...(MARKS[a.id] || [])];
48
+ const inRepo = marks.some((m) => existsSync(join(repo, m)));
49
+ if (inRepo) found.push({ id: a.id, name: a.name, why: 'configured in this repo' });
50
+ }
51
+ return found;
52
+ }
53
+
54
+ export function installed() {
55
+ return ADAPTERS
56
+ .filter((a) => (CLIS[a.id] || []).some(onPath))
57
+ .map((a) => ({ id: a.id, name: a.name, why: 'installed on this machine' }));
58
+ }
59
+
60
+ // What attach should turn on: everything this repo shows signs of, and Claude
61
+ // Code either way, since it is the one that has been run end to end.
62
+ //
63
+ // --agent=cursor,gemini exactly these
64
+ // --agent=all every adapter there is
65
+ export function choose(repo, argv = []) {
66
+ const flag = argv.find((a) => a.startsWith('--agent='));
67
+ if (flag) {
68
+ const want = flag.slice('--agent='.length).split(',').map((s) => s.trim()).filter(Boolean);
69
+ if (want.includes('all')) return { chosen: ADAPTERS, unknown: [] };
70
+ const chosen = want.map(byId).filter(Boolean);
71
+ return { chosen, unknown: want.filter((w) => !byId(w)) };
72
+ }
73
+ const ids = new Set(['claude-code', ...detect(repo).map((d) => d.id)]);
74
+ return { chosen: ADAPTERS.filter((a) => ids.has(a.id)), unknown: [] };
75
+ }
package/scripts/hook.mjs CHANGED
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // One Claude Code hook event, forwarded to the Nearly.
2
+ // One agent hook event, forwarded to the Nearly.
3
3
  //
4
- // node scripts/hook.mjs <event> <repo-name>
4
+ // node scripts/hook.mjs <event> <repo-name> [--adapter=<id>]
5
+ //
6
+ // <event> is always one of Nearly's own names (pre-tool, stop, ...) because
7
+ // attach picks it when it writes the hook. --adapter names whose dialect is
8
+ // arriving on stdin; without one, Claude Code's is assumed and the payload is
9
+ // forwarded untouched, which keeps the oldest path the simplest one.
5
10
  //
6
11
  // Claude Code writes the event as JSON on stdin and reads our answer from
7
12
  // stdout. We sit in between so the server does not have to be running before
@@ -20,15 +25,22 @@
20
25
  import { spawn } from 'node:child_process';
21
26
  import { join, dirname } from 'node:path';
22
27
  import { fileURLToPath } from 'node:url';
28
+ import { byId } from '../server/adapters.mjs';
23
29
 
24
30
  const HOST = '127.0.0.1';
25
31
  const PORT = Number(process.env.NEARLY_PORT || 47653);
26
32
  const BASE = `http://${HOST}:${PORT}`;
27
33
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
28
34
 
29
- const [, , event, name = 'repo'] = process.argv;
35
+ const args = process.argv.slice(2);
36
+ const flag = args.find((a) => a.startsWith('--adapter='));
37
+ const positional = args.filter((a) => !a.startsWith('--'));
38
+ const [event, name = 'repo'] = positional;
30
39
  if (!event) process.exit(0);
31
40
 
41
+ // An unknown id is a typo in a config file, not a reason to wedge the agent.
42
+ const adapter = flag ? byId(flag.slice('--adapter='.length)) : null;
43
+
32
44
  const body = await new Promise((r) => {
33
45
  let s = '';
34
46
  process.stdin.setEncoding('utf8');
@@ -65,14 +77,36 @@ if (!(await up()) && !(await start())) process.exit(0); // fail open, silently
65
77
  // stall the agent.
66
78
  const budget = event === 'pre-tool' ? 600_000 : 15_000;
67
79
 
80
+ // Translate on the way in. A payload we cannot parse is forwarded as it came,
81
+ // so a harness that changes its shape degrades to Claude Code's rather than to
82
+ // nothing.
83
+ let payload = body || '{}';
84
+ if (adapter && adapter.normalize) {
85
+ try { payload = JSON.stringify(adapter.normalize(event, JSON.parse(body || '{}'))); }
86
+ catch { /* keep the original */ }
87
+ }
88
+
68
89
  try {
69
90
  const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}`, {
70
91
  method: 'POST',
71
92
  headers: { 'content-type': 'application/json' },
72
- body: body || '{}',
93
+ body: payload,
73
94
  signal: AbortSignal.timeout(budget),
74
95
  });
75
96
  const text = await res.text();
97
+
98
+ // ...and on the way out. Rendering is what makes a deny actually land: half of
99
+ // these harnesses would read Claude Code's answer as no answer at all, and an
100
+ // unread deny is a gate that reports success while allowing everything.
101
+ if (adapter && adapter.render) {
102
+ let answer = {};
103
+ try { answer = JSON.parse(text || '{}'); } catch { /* treat as no answer */ }
104
+ const out = adapter.render(event, answer);
105
+ if (out.stderr) process.stderr.write(out.stderr);
106
+ if (out.stdout) process.stdout.write(out.stdout);
107
+ process.exit(out.exit || 0);
108
+ }
109
+
76
110
  if (text && text !== '{}') process.stdout.write(text);
77
111
  } catch { /* fail open */ }
78
112