nearly-cli 0.1.3 → 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,9 +22,96 @@ 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
+
27
+ ### Platforms
28
+
29
+ The suite runs on every push against macOS, Linux and Windows, on Node 18 and 22.
30
+
31
+ | | macOS | Linux | Windows |
32
+ |---|---|---|---|
33
+ | Gate, recording, records | tested in CI | tested in CI | tested in CI |
34
+ | Turning it on and off | tested in CI | tested in CI | tested in CI |
35
+ | Updating itself | tested in CI | tested in CI | tested in CI |
36
+ | Driving a real agent end to end | **run by hand** | not yet | not yet |
37
+ | Handing the record over at `git push` | **run by hand** | not yet | not yet |
38
+ | Spoken narration | yes | no | no |
39
+
40
+ Narration uses the built-in `say`, so it is macOS only. Everywhere else the
41
+ record is built identically and reads from its captions, or you can record the
42
+ lines yourself with `--voice-dir`.
43
+
44
+ The bottom two rows are the honest gap: CI proves the pieces work on all three,
45
+ but a whole session with a real agent, and a real push through the hook, have
46
+ only been done on macOS. If you are the first to try either on Windows or Linux,
47
+ an issue with what broke would be genuinely useful.
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:
26
79
 
27
- Gating, recording and the record itself work on macOS, Linux and Windows. **Spoken narration is macOS only**, because it uses the built-in `say`. Elsewhere the record is built the same way and reads from its captions, or you can supply your own recordings with `--voice-dir`.
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.
28
115
 
29
116
  ## Use it on your own repo
30
117
 
@@ -34,10 +121,17 @@ One command, in the repo you want recorded.
34
121
  npx nearly-cli
35
122
  ```
36
123
 
124
+ > **`npx` not recognised?** It comes with Node.js, so that error means Node is
125
+ > not installed. Get it from [nodejs.org](https://nodejs.org) or, on Windows,
126
+ > `winget install OpenJS.NodeJS.LTS`. Then **open a new terminal** so it picks up
127
+ > the change, and check with `node --version`. You also need Claude Code signed
128
+ > in: Nearly gates Claude Code sessions and does nothing without one.
129
+
37
130
  ```
38
131
  ✓ Nearly is on for my-app
39
132
 
40
- · 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)
41
135
  · upgrades reach this repo automatically
42
136
  · the record is offered when you push
43
137
 
@@ -116,7 +210,13 @@ Every tool call passes through an HTTP `PreToolUse` hook to this server, which s
116
210
 
117
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`.
118
212
 
119
- 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.
120
220
 
121
221
  ## What the record actually contains
122
222
 
@@ -222,12 +322,16 @@ Claude Code treats a hook that times out, errors, or returns anything other than
222
322
  npm test
223
323
  ```
224
324
 
225
- 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
226
326
  agent, no network and no Claude subscription, because the fixtures are the two
227
327
  recorded sessions committed in `recordings/demo`.
228
328
 
229
329
  What they hold the project to:
230
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.
231
335
  - **The consent gradient.** That destructive commands are denied without asking,
232
336
  that a never pattern still fires when the command is buried in a chain, that an
233
337
  unclassified tool is held rather than allowed, and that "always" for `git status`
@@ -252,17 +356,18 @@ The claim this project makes is testable: a reviewer who sees the session record
252
356
  ## Roadmap
253
357
 
254
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.
255
- - **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.
256
360
  - **Port the recap player to React.** It is one self-contained page today.
257
361
 
258
362
  ## Files
259
363
 
364
+ - `server/adapters.mjs`, the seven harnesses and the translation at each edge
260
365
  - `server/index.mjs`, spawn sessions, hooks, policy, recorder, undo
261
366
  - `ui/index.html`, sessions, triage of pending approvals, rules, log
262
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
263
368
  - `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
264
369
  - `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
265
370
  - `scripts/install-push-hook.mjs` + `scripts/push-record.mjs`, hand the branch record over at `git push`
266
- - `workspace/`, the repo agents work on (seeded with the Tempo demo)
371
+ - `~/.nearly/`, where recordings, records and agent worktrees are kept
267
372
  - `recordings/<session>.jsonl`, every event and decision; `recordings/demo/` is committed so the records can be rebuilt from source
268
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.3",
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/"
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');
@@ -78,7 +82,7 @@ const viaNpx = /[\\/]_npx[\\/]/.test(root);
78
82
  function installGlobally() {
79
83
  if (!viaNpx || onPath() || argv.includes('--no-install') || process.env.NEARLY_NO_INSTALL === '1') return false;
80
84
  process.stdout.write(dim(` Installing nearly so upgrades reach you… `));
81
- const r = spawnSync('npm', ['install', '-g', `nearly-cli@${pkgVersion()}`, '--silent', '--no-fund', '--no-audit'],
85
+ const r = spawnSync(NPM, ['install', '-g', `nearly-cli@${pkgVersion()}`, '--silent', '--no-fund', '--no-audit'],
82
86
  { encoding: 'utf8', timeout: 180_000 });
83
87
  if (r.status === 0 && onPath()) { console.log('done'); return true; }
84
88
  console.log(dim('skipped'));
@@ -92,7 +96,9 @@ const hookCmd = (ev) => installed
92
96
  : fromPackage
93
97
  ? `npx -y nearly-cli@${pkgVersion()} hook ${ev}`
94
98
  : `node ${JSON.stringify(HOOK)} ${ev}`;
95
- const updateNote = installed
99
+ // A function, not a value: the install below changes the answer, and computing
100
+ // it early told a fresh install it was pinned when it was not.
101
+ const updateNote = () => installed
96
102
  ? 'upgrades reach this repo automatically'
97
103
  : fromPackage
98
104
  ? `pinned to v${pkgVersion()} — run nearly again here after upgrading`
@@ -105,6 +111,9 @@ const nameIdx = argv.indexOf('--name');
105
111
  const name = (nameIdx !== -1 ? argv[nameIdx + 1] : basename(repo))
106
112
  .replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo';
107
113
 
114
+ // npm is a .cmd shim on Windows and Node will not run one through spawn unless
115
+ // it is named exactly. Without this, installing and upgrading both fail there.
116
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
108
117
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
109
118
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
110
119
  const ok = (s) => `\x1b[32m${s}\x1b[0m`;
@@ -120,43 +129,45 @@ if (!existsSync(join(repo, '.git'))) {
120
129
  if (!off && installGlobally()) installed = onPath();
121
130
 
122
131
  // ---------------------------------------------------------------------------
123
- // Claude Code hooks
132
+ // Agent hooks
124
133
  // ---------------------------------------------------------------------------
125
- const dir = join(repo, '.claude');
126
- const file = join(dir, 'settings.local.json');
127
- mkdirSync(dir, { recursive: true });
128
-
129
- let settings = {};
130
- if (existsSync(file)) {
131
- try { settings = JSON.parse(readFileSync(file, 'utf8')); }
132
- catch (e) { console.error(`Could not read ${file}: ${e.message}`); process.exit(1); }
133
- }
134
- settings.hooks = settings.hooks || {};
135
-
136
- // Recognise our own entries by the script they run, so this is safe to re-run
137
- // and leaves anyone else's hooks alone.
138
- const ours = (m) => (m?.hooks || []).some((h) =>
139
- /nearly/.test(String(h.command || '')) || String(h.command || '').includes(HOOK) ||
140
- String(h.url || '').includes(`:${PORT}/hooks/`));
141
- for (const ev of Object.keys(settings.hooks)) {
142
- settings.hooks[ev] = (settings.hooks[ev] || []).filter((m) => !ours(m));
143
- 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);
144
141
  }
145
142
 
146
- if (!off) {
147
- const entry = (ev, timeout) => ({
148
- hooks: [{ type: 'command', command: `${hookCmd(ev)} ${name}`, timeout }],
149
- });
150
- const add = (event, ev, timeout) => { settings.hooks[event] = [...(settings.hooks[event] || []), entry(ev, timeout)]; };
151
- add('SessionStart', 'session-start', 20);
152
- add('UserPromptSubmit', 'prompt', 20);
153
- add('PreToolUse', 'pre-tool', 600); // long enough to hold while a human decides
154
- add('PostToolUse', 'post-tool', 20);
155
- add('Stop', 'stop', 30);
156
- 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
+ }
157
156
  }
158
- if (!Object.keys(settings.hooks).length) delete settings.hooks;
159
- 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 */ }
160
171
 
161
172
  // ---------------------------------------------------------------------------
162
173
  // git pre-push hook
@@ -208,7 +219,9 @@ if (base && !off) {
208
219
  console.log('');
209
220
  if (off) {
210
221
  console.log(`${bold('Nearly off')} for ${dim(repo)}`);
211
- 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');
212
225
  console.log(push.status === 0 ? ' pre-push hook removed' : dim(' pre-push hook was not ours, left alone'));
213
226
  console.log('');
214
227
  process.exit(0);
@@ -216,8 +229,11 @@ if (off) {
216
229
 
217
230
  console.log(`${ok('✓')} ${bold('Nearly is on')} for ${bold(name)} ${dim(repo)}`);
218
231
  console.log('');
219
- console.log(` ${ok('·')} every Claude Code session here is gated and recorded`);
220
- console.log(` ${ok('·')} ${dim(updateNote)}`);
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
+ }
236
+ console.log(` ${ok('·')} ${dim(updateNote())}`);
221
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])}`);
222
238
  if (base) {
223
239
  console.log(` ${ok('·')} records publish to ${base}`);
@@ -228,6 +244,18 @@ if (base) {
228
244
  console.log(` ${dim('to link them from a pull request, host that folder anywhere and:')}`);
229
245
  console.log(` ${dim('NEARLY_URL_BASE=https://your-host/records nearly')}`);
230
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
+
231
259
  console.log('');
232
260
  console.log(` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`);
233
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
 
@@ -24,6 +24,9 @@ import { spawnSync, execFileSync } from 'node:child_process';
24
24
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
25
25
  const DAY = 24 * 60 * 60 * 1000;
26
26
 
27
+ // npm is a .cmd shim on Windows and Node will not run one through spawn unless
28
+ // it is named exactly. Without this, installing and upgrading both fail there.
29
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
27
30
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
28
31
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
29
32
 
@@ -100,7 +103,7 @@ export function applyUpdate(u) {
100
103
  }
101
104
 
102
105
  process.stdout.write(dim(` Updating Nearly ${u.from} → ${u.to}… `));
103
- const r = spawnSync('npm', ['install', '-g', `${u.name}@${u.to}`, '--silent', '--no-fund', '--no-audit'],
106
+ const r = spawnSync(NPM, ['install', '-g', `${u.name}@${u.to}`, '--silent', '--no-fund', '--no-audit'],
104
107
  { encoding: 'utf8', timeout: 120_000 });
105
108
 
106
109
  if (r.status === 0) {