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 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. No dependencies and no API key of its own: agents run on whatever subscription you already have.
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.8",
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('');
@@ -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
- : `Record: \`ui/records/${slug}.html\` in the Nearly checkout (${mmss(sb.totalS)}, not hosted yet) · ${sb.runs > 1 ? `${sb.runs} agent sessions` : `agent \`${sb.name}\``} · ${sb.model} · ${sb.date}`);
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
@@ -94,11 +94,24 @@ try {
94
94
  applyUpdate(await checkForUpdate());
95
95
  } catch { /* never worth failing a push over */ }
96
96
 
97
- // gh is only useful if there is a pull request to comment on
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
- const pr = hasGh ? spawnSync('gh', ['pr', 'view', '--json', 'number,url'], { cwd: repo, encoding: 'utf8' }) : null;
100
- if (!pr || pr.status !== 0) {
101
- console.log(dim(' No open pull request for this branch yet. Raise one, then push again to attach the record.'));
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
  }