nearly-cli 0.1.8 → 0.1.11
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 +5 -1
- package/bin/nearly.mjs +5 -0
- package/package.json +1 -1
- package/scripts/doctor.mjs +177 -0
- package/scripts/post-recap.mjs +7 -2
- package/scripts/push-record.mjs +17 -4
package/README.md
CHANGED
|
@@ -22,7 +22,11 @@ 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, git, and one of the seven coding agents below signed in.
|
|
25
|
+
Node 18 or newer, git, and one of the seven coding agents below signed in.
|
|
26
|
+
Posting the record to a pull request also needs the [GitHub CLI](https://cli.github.com)
|
|
27
|
+
signed in (`gh auth login`); everything else works without it. If nothing is
|
|
28
|
+
showing up, `nearly doctor` walks the whole chain — gate, recording, record,
|
|
29
|
+
pull request, link — and names what is in the way. No dependencies and no API key of its own: agents run on whatever subscription you already have.
|
|
26
30
|
|
|
27
31
|
### Platforms
|
|
28
32
|
|
package/bin/nearly.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// nearly record build the record for the current branch
|
|
9
9
|
// nearly post put that record on the pull request
|
|
10
10
|
// nearly agents which agents this repo is gated for
|
|
11
|
+
// nearly doctor why nothing is showing up
|
|
11
12
|
// nearly voices list the narration voices you have
|
|
12
13
|
// nearly server run the server in the foreground (it self-starts otherwise)
|
|
13
14
|
// nearly hook <ev> internal: what the Claude Code hooks call
|
|
@@ -78,6 +79,9 @@ switch (cmd) {
|
|
|
78
79
|
case 'agents':
|
|
79
80
|
return run(s('agents.mjs'), rest);
|
|
80
81
|
|
|
82
|
+
case 'doctor': case 'why':
|
|
83
|
+
return run(s('doctor.mjs'), rest);
|
|
84
|
+
|
|
81
85
|
case 'voices':
|
|
82
86
|
return run(s('build-recap.mjs'), ['--voices']);
|
|
83
87
|
|
|
@@ -106,6 +110,7 @@ switch (cmd) {
|
|
|
106
110
|
nearly record build the record for the current branch
|
|
107
111
|
nearly post put that record on the pull request
|
|
108
112
|
nearly agents which agents this repo is gated for
|
|
113
|
+
nearly doctor why nothing is showing up
|
|
109
114
|
nearly voices list the narration voices you have
|
|
110
115
|
nearly server run the server in the foreground
|
|
111
116
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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": {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Why is nothing showing up?
|
|
2
|
+
//
|
|
3
|
+
// nearly doctor
|
|
4
|
+
//
|
|
5
|
+
// Everything this project does is a chain: gate the session, record it, build
|
|
6
|
+
// the record, find the pull request, post the link. A break anywhere means
|
|
7
|
+
// nothing appears, and every step is quiet by design — hooks must never
|
|
8
|
+
// interrupt an agent, and a push must never fail over a recap. Quiet is right
|
|
9
|
+
// and it is also how somebody ends up staring at an empty pull request with no
|
|
10
|
+
// idea which link came apart.
|
|
11
|
+
//
|
|
12
|
+
// So this walks the chain in order and stops being polite about it.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { join, resolve, basename, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
18
|
+
import { paths, dataRoot } from '../server/paths.mjs';
|
|
19
|
+
import { ADAPTERS } from '../server/adapters.mjs';
|
|
20
|
+
|
|
21
|
+
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
22
|
+
const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
|
|
23
|
+
const PORT = Number(process.env.NEARLY_PORT || 47653);
|
|
24
|
+
|
|
25
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
26
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
27
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
28
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
29
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
30
|
+
|
|
31
|
+
const blockers = [];
|
|
32
|
+
function say(ok, label, detail, fix) {
|
|
33
|
+
const mark = ok === true ? green('✓') : ok === false ? red('✗') : yellow('!');
|
|
34
|
+
console.log(` ${mark} ${label}${detail ? dim(` ${detail}`) : ''}`);
|
|
35
|
+
if (ok === false && fix) blockers.push(fix);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const git = (args) => {
|
|
39
|
+
try { return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
|
|
40
|
+
catch { return null; }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log(` ${bold('Nearly')} ${dim(repo)}`);
|
|
45
|
+
console.log('');
|
|
46
|
+
|
|
47
|
+
// 1 — a repo at all
|
|
48
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
49
|
+
if (!existsSync(join(repo, '.git'))) {
|
|
50
|
+
say(false, 'a git repository', 'this is not one', `cd into the repo you work in, then run nearly`);
|
|
51
|
+
} else {
|
|
52
|
+
say(true, 'a git repository', `on ${branch}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2 — which agents are gated here. An agent Nearly cannot see records nothing,
|
|
56
|
+
// and that is the single most common reason for an empty pull request.
|
|
57
|
+
const gated = ADAPTERS.filter((a) => {
|
|
58
|
+
const f = join(repo, a.config);
|
|
59
|
+
try { return existsSync(f) && /nearly/i.test(readFileSync(f, 'utf8')); } catch { return false; }
|
|
60
|
+
});
|
|
61
|
+
if (gated.length) say(true, 'agents gated here', gated.map((a) => a.name).join(', '));
|
|
62
|
+
else say(false, 'agents gated here', 'none', 'run `nearly` in this repo to turn it on');
|
|
63
|
+
|
|
64
|
+
// 3 — the server, and whether it is this build
|
|
65
|
+
let health = null;
|
|
66
|
+
try {
|
|
67
|
+
const r = await fetch(`http://127.0.0.1:${PORT}/health`, { signal: AbortSignal.timeout(900) });
|
|
68
|
+
if (r.ok) health = await r.json();
|
|
69
|
+
} catch { /* not running is normal: hooks start it */ }
|
|
70
|
+
if (!health) {
|
|
71
|
+
say(null, 'server', 'not running — a hook starts it when one fires');
|
|
72
|
+
} else {
|
|
73
|
+
let mine = root;
|
|
74
|
+
try { mine = realpathSync(root); } catch { /* compare literally */ }
|
|
75
|
+
const same = health.root === mine;
|
|
76
|
+
let ours = null;
|
|
77
|
+
try { ours = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; } catch { /* unknown */ }
|
|
78
|
+
if (same) {
|
|
79
|
+
say(true, 'server', `v${health.version}`);
|
|
80
|
+
} else if (health.version && health.version === ours) {
|
|
81
|
+
// Running this through npx gives a throwaway directory every time, so the
|
|
82
|
+
// paths differ even when the build is identical. Saying "a different
|
|
83
|
+
// install" there is true and useless; the version is what anyone cares
|
|
84
|
+
// about, and a matching one is holding nothing back.
|
|
85
|
+
say(null, 'server', `v${health.version} from another copy of the same version — nothing stale about it`);
|
|
86
|
+
} else {
|
|
87
|
+
say(false, 'server', `an older build is answering${health.version ? ` (v${health.version})` : ''}: ${health.root || 'it does not say where it lives'}`,
|
|
88
|
+
'run `nearly` here — it closes the older server holding the port');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4 — recordings for this branch, matched the way the record builder matches
|
|
93
|
+
// them, so this cannot disagree with it.
|
|
94
|
+
const recDir = paths.recordings();
|
|
95
|
+
let runs = 0, otherBranches = new Set();
|
|
96
|
+
const real = (p) => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
|
|
97
|
+
try {
|
|
98
|
+
for (const f of readdirSync(recDir).filter((f) => f.endsWith('.jsonl'))) {
|
|
99
|
+
let created = null;
|
|
100
|
+
for (const line of readFileSync(join(recDir, f), 'utf8').split('\n')) {
|
|
101
|
+
if (!line.trim()) continue;
|
|
102
|
+
try {
|
|
103
|
+
const e = JSON.parse(line);
|
|
104
|
+
if (e.type === 'session' && e.subtype === 'created') { created = e; break; }
|
|
105
|
+
} catch { /* a torn line */ }
|
|
106
|
+
}
|
|
107
|
+
if (!created) continue;
|
|
108
|
+
if (created.worktree && real(created.worktree) !== real(repo)) continue;
|
|
109
|
+
if (created.branch === branch) runs += 1;
|
|
110
|
+
else if (created.branch) otherBranches.add(created.branch);
|
|
111
|
+
}
|
|
112
|
+
} catch { /* no recordings directory yet */ }
|
|
113
|
+
|
|
114
|
+
if (runs) {
|
|
115
|
+
say(true, `sessions recorded on ${branch}`, `${runs}`);
|
|
116
|
+
} else {
|
|
117
|
+
say(false, `sessions recorded on ${branch}`, 'none',
|
|
118
|
+
otherBranches.size
|
|
119
|
+
? `this branch has no recorded sessions. Others do: ${[...otherBranches].join(', ')}. A record only exists for work an agent Nearly gates actually did.`
|
|
120
|
+
: `nothing here has been recorded yet. Nearly only sees the agents it gates — run \`nearly agents\` to see which those are.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 5 — the hook that offers the record at push time
|
|
124
|
+
const pushHook = join(repo, '.git', 'hooks', 'pre-push');
|
|
125
|
+
const hasPush = existsSync(pushHook) && /x-session-record-hook|nearly/i.test((() => {
|
|
126
|
+
try { return readFileSync(pushHook, 'utf8'); } catch { return ''; }
|
|
127
|
+
})());
|
|
128
|
+
say(hasPush, 'pre-push hook', hasPush ? 'installed' : 'missing', 'run `nearly` here to install it');
|
|
129
|
+
|
|
130
|
+
// 6 — gh, which is how a pull request is found and commented on. Its absence
|
|
131
|
+
// used to be reported as "no pull request yet", which sent people off to raise
|
|
132
|
+
// one they already had.
|
|
133
|
+
const ghOk = spawnSync('gh', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
134
|
+
if (!ghOk) {
|
|
135
|
+
say(false, 'GitHub CLI (gh)', 'not on PATH',
|
|
136
|
+
'install it from cli.github.com and run `gh auth login` — without it the record cannot be posted to a pull request');
|
|
137
|
+
} else {
|
|
138
|
+
const auth = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8' }).status === 0;
|
|
139
|
+
say(auth, 'GitHub CLI (gh)', auth ? 'installed and signed in' : 'installed but not signed in', 'run `gh auth login`');
|
|
140
|
+
if (auth) {
|
|
141
|
+
const pr = spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' });
|
|
142
|
+
if (pr.status === 0) {
|
|
143
|
+
let url = '';
|
|
144
|
+
try { url = JSON.parse(pr.stdout).url; } catch { /* keep it blank */ }
|
|
145
|
+
say(true, 'open pull request', url);
|
|
146
|
+
} else {
|
|
147
|
+
say(null, 'open pull request', `none for ${branch} — raise one, then push again`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 7 — where a posted record would point
|
|
153
|
+
const urlBase = (() => {
|
|
154
|
+
if (process.env.NEARLY_URL_BASE) return process.env.NEARLY_URL_BASE;
|
|
155
|
+
for (const f of [paths.config(), join(root, '.nearly.json')]) {
|
|
156
|
+
try { if (existsSync(f)) { const u = JSON.parse(readFileSync(f, 'utf8')).urlBase; if (u) return u; } }
|
|
157
|
+
catch { /* try the next */ }
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
})();
|
|
161
|
+
say(urlBase ? true : null, 'somewhere to publish records',
|
|
162
|
+
urlBase || `kept in ${dataRoot.replace(process.env.HOME || '~', '~')}, so a comment would have no link to give`);
|
|
163
|
+
|
|
164
|
+
// 8 — whether a record for this branch exists right now
|
|
165
|
+
const slug = `${String(basename(repo)).replace(/[^a-z0-9._-]+/gi, '-').toLowerCase()}--${String(branch).replace(/[^a-z0-9._-]+/gi, '-').toLowerCase()}`;
|
|
166
|
+
const built = join(paths.records(), `${slug}.json`);
|
|
167
|
+
say(existsSync(built) ? true : null, 'record built for this branch',
|
|
168
|
+
existsSync(built) ? built : 'not yet — it is built at push time, or by `nearly record`');
|
|
169
|
+
|
|
170
|
+
console.log('');
|
|
171
|
+
if (!blockers.length) {
|
|
172
|
+
console.log(` ${green('Nothing is in the way.')} ${dim('Work, push, and the record is offered.')}`);
|
|
173
|
+
} else {
|
|
174
|
+
console.log(` ${bold(blockers.length === 1 ? 'One thing is in the way:' : `${blockers.length} things are in the way:`)}`);
|
|
175
|
+
for (const b of blockers) console.log(` · ${b}`);
|
|
176
|
+
}
|
|
177
|
+
console.log('');
|
package/scripts/post-recap.mjs
CHANGED
|
@@ -49,7 +49,12 @@ lines.push(sb.runs > 1
|
|
|
49
49
|
lines.push('');
|
|
50
50
|
lines.push(urlBase
|
|
51
51
|
? `**[Watch the record (${mmss(sb.totalS)})](${urlBase}/${slug}.html)** · ${sb.runs > 1 ? `${sb.runs} agent sessions` : `agent \`${sb.name}\``} · ${sb.model} · ${sb.date}`
|
|
52
|
-
|
|
52
|
+
// No host configured. Everything a reviewer needs to act on is in this
|
|
53
|
+
// comment already — what was refused, and what the diff therefore cannot show
|
|
54
|
+
// them. Only the player is missing, so say where it is honestly rather than
|
|
55
|
+
// naming a path from the developer's own checkout that means nothing to
|
|
56
|
+
// anybody who installed this.
|
|
57
|
+
: `${sb.runs > 1 ? `${sb.runs} agent sessions` : `Agent \`${sb.name}\``} · ${sb.model} · ${sb.date} · ${mmss(sb.totalS)} recording, kept on the author's machine`);
|
|
53
58
|
lines.push('');
|
|
54
59
|
if (outcome?.notDone?.length) {
|
|
55
60
|
lines.push(`> **${outcome.notDone.length} thing${outcome.notDone.length > 1 ? 's' : ''} the agent wanted to do did not happen.** The diff cannot show you this.`);
|
|
@@ -67,7 +72,7 @@ sb.scenes.forEach((s, i) => { lines.push(`${i + 1}. **${s.kind}** — ${s.narrat
|
|
|
67
72
|
lines.push('');
|
|
68
73
|
lines.push('</details>');
|
|
69
74
|
lines.push('');
|
|
70
|
-
lines.push(`<sub>Every number above was computed from the session recording. ${sb.polished ? 'Sentences were rewritten by a model; facts were not.' : 'No model wrote any of it.'}</sub>`);
|
|
75
|
+
lines.push(`<sub>Every number above was computed from the session recording. ${sb.polished ? 'Sentences were rewritten by a model; facts were not.' : 'No model wrote any of it.'}${urlBase ? '' : ' The narrated version is not published anywhere; `nearly publish` puts it on GitHub Pages.'}</sub>`);
|
|
71
76
|
// A hidden marker so we can find our own comment again on the next push and
|
|
72
77
|
// edit it, instead of stacking a new one on every push until nobody reads any.
|
|
73
78
|
// Deliberately carries no product name. This string is how a comment is
|
package/scripts/push-record.mjs
CHANGED
|
@@ -94,11 +94,24 @@ try {
|
|
|
94
94
|
applyUpdate(await checkForUpdate());
|
|
95
95
|
} catch { /* never worth failing a push over */ }
|
|
96
96
|
|
|
97
|
-
//
|
|
97
|
+
// Finding the pull request needs gh. Not having it and not having a pull
|
|
98
|
+
// request are different problems with the same silence, and they were reported
|
|
99
|
+
// as the same sentence — which sent people off to raise a pull request they
|
|
100
|
+
// were already looking at.
|
|
98
101
|
const hasGh = spawnSync('gh', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
console.log(dim('
|
|
102
|
+
if (!hasGh) {
|
|
103
|
+
console.log(dim(' The record is built, but posting it to a pull request needs the GitHub CLI,'));
|
|
104
|
+
console.log(dim(' and `gh` is not on PATH. Install it from cli.github.com, then `gh auth login`.'));
|
|
105
|
+
console.log(dim(' `nearly doctor` checks the whole chain.'));
|
|
106
|
+
console.log('');
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
const pr = spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' });
|
|
110
|
+
if (pr.status !== 0) {
|
|
111
|
+
const why = /not logged|authentication|gh auth/i.test(pr.stderr || '')
|
|
112
|
+
? 'gh is installed but not signed in. Run `gh auth login`, then push again.'
|
|
113
|
+
: 'No open pull request for this branch yet. Raise one, then push again to attach the record.';
|
|
114
|
+
console.log(dim(` ${why}`));
|
|
102
115
|
console.log('');
|
|
103
116
|
process.exit(0);
|
|
104
117
|
}
|