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 +112 -7
- package/bin/nearly.mjs +11 -1
- package/package.json +2 -2
- package/scripts/agents.mjs +61 -0
- package/scripts/attach.mjs +69 -41
- package/scripts/detect.mjs +75 -0
- package/scripts/hook.mjs +38 -4
- package/scripts/update-check.mjs +4 -1
- 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,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,
|
|
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
|
-
|
|
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
|
-
·
|
|
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
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
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
|
-
|
|
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/"
|
|
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');
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
//
|
|
132
|
+
// Agent hooks
|
|
124
133
|
// ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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(
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
package/scripts/update-check.mjs
CHANGED
|
@@ -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(
|
|
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) {
|