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 +85 -6
- package/bin/nearly.mjs +11 -1
- package/package.json +2 -2
- package/scripts/agents.mjs +61 -0
- package/scripts/attach.mjs +61 -38
- package/scripts/detect.mjs +75 -0
- package/scripts/hook.mjs +38 -4
- package/server/adapters.mjs +595 -0
- package/server/index.mjs +65 -16
- package/server/paths.mjs +10 -0
- package/ui/index.html +26 -6
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,
|
|
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
|
-
·
|
|
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
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
+
"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('');
|
package/scripts/attach.mjs
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
//
|
|
7
7
|
// One command, once per repo. It installs everything and works out the rest:
|
|
8
8
|
//
|
|
9
|
-
// ·
|
|
10
|
-
// whether you start
|
|
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
|
-
//
|
|
132
|
+
// Agent hooks
|
|
129
133
|
// ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|