nearly-cli 0.1.5 → 0.1.7
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 +90 -6
- package/bin/nearly.mjs +17 -3
- package/package.json +2 -2
- package/scripts/agents.mjs +61 -0
- package/scripts/attach.mjs +80 -43
- package/scripts/detect.mjs +75 -0
- package/scripts/hook.mjs +40 -5
- package/scripts/push-record.mjs +8 -4
- package/server/adapters.mjs +602 -0
- package/server/index.mjs +73 -18
- package/server/paths.mjs +25 -0
- package/ui/index.html +54 -11
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,18 @@ 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
|
-
|
|
213
|
+
Lab mode is off by default. `nearly open` shows your own sessions and what
|
|
214
|
+
needs you — the gate, which is what you installed this for. `nearly lab` adds
|
|
215
|
+
the panel for starting agents from the dashboard, which is a different job and
|
|
216
|
+
no longer the first thing a new user is asked about.
|
|
217
|
+
|
|
218
|
+
Agents in lab mode are real Claude Code sessions (`claude -p`) on your Claude
|
|
219
|
+
subscription, each on its own branch in its own git worktree. You pick which
|
|
220
|
+
repo to branch from — the dashboard offers the ones you have turned Nearly on
|
|
221
|
+
for — and the branch starts from that repo's HEAD, so it begins where you
|
|
222
|
+
actually are rather than on some assumed `main`. Worktrees live under
|
|
223
|
+
`~/.nearly/workspace/.worktrees/`, outside the installed package, so upgrading
|
|
224
|
+
never deletes one. No API key, no paid infrastructure, anywhere in this project.
|
|
146
225
|
|
|
147
226
|
## What the record actually contains
|
|
148
227
|
|
|
@@ -248,12 +327,16 @@ Claude Code treats a hook that times out, errors, or returns anything other than
|
|
|
248
327
|
npm test
|
|
249
328
|
```
|
|
250
329
|
|
|
251
|
-
|
|
330
|
+
83 tests, no dependencies, about 50 seconds. They run on a fresh clone with no
|
|
252
331
|
agent, no network and no Claude subscription, because the fixtures are the two
|
|
253
332
|
recorded sessions committed in `recordings/demo`.
|
|
254
333
|
|
|
255
334
|
What they hold the project to:
|
|
256
335
|
|
|
336
|
+
- **Every adapter.** That `rm -rf` is refused in all seven harnesses' dialects,
|
|
337
|
+
that the refusal comes back in words each one acts on, that a session appears
|
|
338
|
+
from whichever field that harness calls its session id, and that a payload none
|
|
339
|
+
of them would ever send leaves the agent working rather than hanging.
|
|
257
340
|
- **The consent gradient.** That destructive commands are denied without asking,
|
|
258
341
|
that a never pattern still fires when the command is buried in a chain, that an
|
|
259
342
|
unclassified tool is held rather than allowed, and that "always" for `git status`
|
|
@@ -278,17 +361,18 @@ The claim this project makes is testable: a reviewer who sees the session record
|
|
|
278
361
|
## Roadmap
|
|
279
362
|
|
|
280
363
|
- **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
|
-
- **
|
|
364
|
+
- **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
365
|
- **Port the recap player to React.** It is one self-contained page today.
|
|
283
366
|
|
|
284
367
|
## Files
|
|
285
368
|
|
|
369
|
+
- `server/adapters.mjs`, the seven harnesses and the translation at each edge
|
|
286
370
|
- `server/index.mjs`, spawn sessions, hooks, policy, recorder, undo
|
|
287
371
|
- `ui/index.html`, sessions, triage of pending approvals, rules, log
|
|
288
372
|
- `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
373
|
- `scripts/build-recap.mjs` + `ui/recap.template.html`, narrated recap page per session
|
|
290
374
|
- `scripts/publish-pages.mjs`, build the `docs/` folder GitHub Pages serves
|
|
291
375
|
- `scripts/install-push-hook.mjs` + `scripts/push-record.mjs`, hand the branch record over at `git push`
|
|
292
|
-
-
|
|
376
|
+
- `~/.nearly/`, where recordings, records, settings and agent worktrees are kept — outside the package, so an upgrade cannot destroy them
|
|
293
377
|
- `recordings/<session>.jsonl`, every event and decision; `recordings/demo/` is committed so the records can be rebuilt from source
|
|
294
378
|
- `STUDY.md`, the protocol for testing whether any of this helps a reviewer
|
package/bin/nearly.mjs
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// nearly turn it on for the repo you are in
|
|
5
5
|
// nearly off turn it off again
|
|
6
6
|
// nearly open open the dashboard
|
|
7
|
+
// nearly lab open it with the panel for starting agents
|
|
7
8
|
// nearly record build the record for the current branch
|
|
8
9
|
// nearly post put that record on the pull request
|
|
10
|
+
// nearly agents which agents this repo is gated for
|
|
9
11
|
// nearly voices list the narration voices you have
|
|
10
12
|
// nearly server run the server in the foreground (it self-starts otherwise)
|
|
11
13
|
// nearly hook <ev> internal: what the Claude Code hooks call
|
|
@@ -34,7 +36,12 @@ const run = async (file, args = []) => {
|
|
|
34
36
|
process.exit(r.status ?? 0);
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
// A leading flag is not a command. `nearly --agent=cursor` and `nearly --off`
|
|
40
|
+
// are how the docs say to do those things, and both used to land on "Unknown
|
|
41
|
+
// command" because the first argument was read as a subcommand name.
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
const leadingFlag = argv[0]?.startsWith('-') && !['--help', '-h', '--which'].includes(argv[0]);
|
|
44
|
+
const [cmd = 'attach', ...rest] = leadingFlag ? ['attach', ...argv] : argv;
|
|
38
45
|
|
|
39
46
|
async function main() {
|
|
40
47
|
switch (cmd) {
|
|
@@ -68,6 +75,9 @@ switch (cmd) {
|
|
|
68
75
|
case 'publish':
|
|
69
76
|
return run(s('publish-pages.mjs'), rest);
|
|
70
77
|
|
|
78
|
+
case 'agents':
|
|
79
|
+
return run(s('agents.mjs'), rest);
|
|
80
|
+
|
|
71
81
|
case 'voices':
|
|
72
82
|
return run(s('build-recap.mjs'), ['--voices']);
|
|
73
83
|
|
|
@@ -77,8 +87,10 @@ switch (cmd) {
|
|
|
77
87
|
return run(join(root, 'server', 'index.mjs'), rest);
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
case 'open': {
|
|
81
|
-
|
|
90
|
+
case 'lab': case 'open': {
|
|
91
|
+
// `open` is the gate: your sessions and what needs you. `lab` adds the
|
|
92
|
+
// panel for starting agents from here, which is a different job.
|
|
93
|
+
const url = 'http://127.0.0.1:47653' + (cmd === 'lab' ? '/?lab=1' : '');
|
|
82
94
|
spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open',
|
|
83
95
|
[url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
84
96
|
console.log(url);
|
|
@@ -90,8 +102,10 @@ switch (cmd) {
|
|
|
90
102
|
nearly turn it on for the repo you are in
|
|
91
103
|
nearly off turn it off again
|
|
92
104
|
nearly open open the dashboard
|
|
105
|
+
nearly lab open it with the panel for starting agents
|
|
93
106
|
nearly record build the record for the current branch
|
|
94
107
|
nearly post put that record on the pull request
|
|
108
|
+
nearly agents which agents this repo is gated for
|
|
95
109
|
nearly voices list the narration voices you have
|
|
96
110
|
nearly server run the server in the foreground
|
|
97
111
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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,53 @@ 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); }
|
|
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);
|
|
138
141
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
142
|
+
|
|
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
|
+
}
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
// On a machine that has never run Nearly, ~/.nearly does not exist yet and
|
|
164
|
+
// this write fails with ENOENT. It used to fail into a bare catch, so the
|
|
165
|
+
// list was silently never created and the dashboard could never offer a repo
|
|
166
|
+
// — invisible on every machine except a genuinely fresh one.
|
|
167
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
168
|
+
let list = [];
|
|
169
|
+
try { list = JSON.parse(readFileSync(f, 'utf8')); } catch { /* first one */ }
|
|
170
|
+
// Prune as we go: a repo that has been moved or deleted is noise in a list
|
|
171
|
+
// whose only job is to offer you somewhere to start.
|
|
172
|
+
list = list.filter((r) => r !== repo && existsSync(join(r, '.git')));
|
|
173
|
+
if (!off) list.unshift(repo);
|
|
174
|
+
writeFileSync(f, JSON.stringify(list.slice(0, 50), null, 2) + '\n');
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// Not fatal — the gate does not depend on it — but not silent either.
|
|
177
|
+
notes.push(`could not remember this repo for the dashboard: ${e.message}`);
|
|
162
178
|
}
|
|
163
|
-
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
164
|
-
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
165
179
|
|
|
166
180
|
// ---------------------------------------------------------------------------
|
|
167
181
|
// git pre-push hook
|
|
@@ -193,27 +207,35 @@ function pagesUrl() {
|
|
|
193
207
|
// An address already configured wins: it was either set deliberately or worked
|
|
194
208
|
// out here before, and it survives the project being renamed.
|
|
195
209
|
function configured() {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
210
|
+
// The legacy path is read, never written: an upgrade destroys it, so the
|
|
211
|
+
// first run after this change is the last chance to carry it forward.
|
|
212
|
+
for (const f of [paths.config(), join(root, '.nearly.json')]) {
|
|
213
|
+
try {
|
|
214
|
+
if (existsSync(f)) {
|
|
215
|
+
const u = JSON.parse(readFileSync(f, 'utf8')).urlBase;
|
|
216
|
+
if (u) return u;
|
|
217
|
+
}
|
|
218
|
+
} catch { /* try the next one */ }
|
|
219
|
+
}
|
|
200
220
|
return null;
|
|
201
221
|
}
|
|
202
222
|
const derived = pagesUrl();
|
|
203
223
|
const base = process.env.NEARLY_URL_BASE || configured() || derived;
|
|
204
224
|
if (base && !off) {
|
|
205
225
|
try {
|
|
206
|
-
const cfg =
|
|
226
|
+
const cfg = paths.config();
|
|
207
227
|
const prev = existsSync(cfg) ? JSON.parse(readFileSync(cfg, 'utf8')) : {};
|
|
208
228
|
writeFileSync(cfg, JSON.stringify({ ...prev, urlBase: base }, null, 2) + '\n');
|
|
209
|
-
} catch {
|
|
229
|
+
} catch (e) { notes.push(`could not save where records publish: ${e.message}`); }
|
|
210
230
|
}
|
|
211
231
|
|
|
212
232
|
// ---------------------------------------------------------------------------
|
|
213
233
|
console.log('');
|
|
214
234
|
if (off) {
|
|
215
235
|
console.log(`${bold('Nearly off')} for ${dim(repo)}`);
|
|
216
|
-
console.log(
|
|
236
|
+
console.log(wired.length
|
|
237
|
+
? ` hooks removed: ${wired.map((a) => a.name).join(', ')}`
|
|
238
|
+
: ' no agent hooks of ours were installed');
|
|
217
239
|
console.log(push.status === 0 ? ' pre-push hook removed' : dim(' pre-push hook was not ours, left alone'));
|
|
218
240
|
console.log('');
|
|
219
241
|
process.exit(0);
|
|
@@ -221,7 +243,10 @@ if (off) {
|
|
|
221
243
|
|
|
222
244
|
console.log(`${ok('✓')} ${bold('Nearly is on')} for ${bold(name)} ${dim(repo)}`);
|
|
223
245
|
console.log('');
|
|
224
|
-
|
|
246
|
+
for (const a of wired) {
|
|
247
|
+
const how = a.verified ? dim(`(${a.verified})`) : dim('(built to their published hook spec, not yet run against a live agent)');
|
|
248
|
+
console.log(` ${ok('·')} ${a.name} sessions here are gated and recorded ${how}`);
|
|
249
|
+
}
|
|
225
250
|
console.log(` ${ok('·')} ${dim(updateNote())}`);
|
|
226
251
|
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
252
|
if (base) {
|
|
@@ -233,6 +258,18 @@ if (base) {
|
|
|
233
258
|
console.log(` ${dim('to link them from a pull request, host that folder anywhere and:')}`);
|
|
234
259
|
console.log(` ${dim('NEARLY_URL_BASE=https://your-host/records nearly')}`);
|
|
235
260
|
}
|
|
261
|
+
for (const n of notes) console.log(` ${dim('·')} ${dim(n)}`);
|
|
262
|
+
|
|
263
|
+
// An agent you have on this machine but have not used here is worth a word, and
|
|
264
|
+
// nothing more: having it installed is no reason to write files into this repo.
|
|
265
|
+
const elsewhere = agentsOnMachine().filter((i) => !wired.some((w) => w.id === i.id));
|
|
266
|
+
if (elsewhere.length) {
|
|
267
|
+
console.log('');
|
|
268
|
+
console.log(dim(` Also installed here: ${elsewhere.map((e) => e.name).join(', ')}.`));
|
|
269
|
+
console.log(dim(` Nothing in this repo suggests you use them for it, so they were left alone:`));
|
|
270
|
+
console.log(dim(` nearly --agent=${elsewhere[0].id} turns one on.`));
|
|
271
|
+
}
|
|
272
|
+
|
|
236
273
|
console.log('');
|
|
237
274
|
console.log(` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`);
|
|
238
275
|
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,37 @@ 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
|
-
const
|
|
90
|
+
const hold = adapter?.holdMs ? `&hold=${adapter.holdMs}` : '';
|
|
91
|
+
const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}`, {
|
|
70
92
|
method: 'POST',
|
|
71
93
|
headers: { 'content-type': 'application/json' },
|
|
72
|
-
body:
|
|
94
|
+
body: payload,
|
|
73
95
|
signal: AbortSignal.timeout(budget),
|
|
74
96
|
});
|
|
75
97
|
const text = await res.text();
|
|
98
|
+
|
|
99
|
+
// ...and on the way out. Rendering is what makes a deny actually land: half of
|
|
100
|
+
// these harnesses would read Claude Code's answer as no answer at all, and an
|
|
101
|
+
// unread deny is a gate that reports success while allowing everything.
|
|
102
|
+
if (adapter && adapter.render) {
|
|
103
|
+
let answer = {};
|
|
104
|
+
try { answer = JSON.parse(text || '{}'); } catch { /* treat as no answer */ }
|
|
105
|
+
const out = adapter.render(event, answer);
|
|
106
|
+
if (out.stderr) process.stderr.write(out.stderr);
|
|
107
|
+
if (out.stdout) process.stdout.write(out.stdout);
|
|
108
|
+
process.exit(out.exit || 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
if (text && text !== '{}') process.stdout.write(text);
|
|
77
112
|
} catch { /* fail open */ }
|
|
78
113
|
|
package/scripts/push-record.mjs
CHANGED
|
@@ -15,10 +15,14 @@ import { paths } from '../server/paths.mjs';
|
|
|
15
15
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
16
16
|
const repo = resolve(process.argv[2] || '.');
|
|
17
17
|
function configured() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
for (const f of [paths.config(), join(root, '.nearly.json')]) {
|
|
19
|
+
try {
|
|
20
|
+
if (existsSync(f)) {
|
|
21
|
+
const u = JSON.parse(readFileSync(f, 'utf8')).urlBase;
|
|
22
|
+
if (u) return u;
|
|
23
|
+
}
|
|
24
|
+
} catch { /* try the next one */ }
|
|
25
|
+
}
|
|
22
26
|
return '';
|
|
23
27
|
}
|
|
24
28
|
const URL_BASE = (process.env.NEARLY_URL_BASE || configured() || '').replace(/\/$/, '');
|