nearly-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +274 -0
- package/STUDY.md +101 -0
- package/bin/nearly.mjs +108 -0
- package/package.json +39 -0
- package/scripts/attach.mjs +195 -0
- package/scripts/build-recap.mjs +655 -0
- package/scripts/hook.mjs +79 -0
- package/scripts/install-push-hook.mjs +87 -0
- package/scripts/post-recap.mjs +124 -0
- package/scripts/publish-pages.mjs +141 -0
- package/scripts/push-record.mjs +126 -0
- package/scripts/update-check.mjs +116 -0
- package/server/index.mjs +538 -0
- package/server/policy.mjs +61 -0
- package/ui/index.html +522 -0
- package/ui/recap.template.html +449 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Turn on the Nearly for a repo you already work in.
|
|
2
|
+
//
|
|
3
|
+
// node scripts/attach.mjs # this repo
|
|
4
|
+
// node scripts/attach.mjs ~/code/my-app # that one
|
|
5
|
+
// node scripts/attach.mjs --off # turn it off again
|
|
6
|
+
//
|
|
7
|
+
// One command, once per repo. It installs everything and works out the rest:
|
|
8
|
+
//
|
|
9
|
+
// · Claude Code hooks, so every session in this repo is gated and recorded
|
|
10
|
+
// whether you start it in a terminal, in VS Code, or in JetBrains
|
|
11
|
+
// · a git pre-push hook, so the record is offered when the work leaves your
|
|
12
|
+
// machine
|
|
13
|
+
// · where the records are published, read from the Nearly's own remote
|
|
14
|
+
//
|
|
15
|
+
// There is no server to remember. The hooks start it the first time they need
|
|
16
|
+
// it, and if it cannot start, Claude Code falls back to its own prompts and
|
|
17
|
+
// nothing breaks.
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join, resolve, dirname, basename } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
23
|
+
|
|
24
|
+
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
25
|
+
const HOOK = join(root, 'scripts', 'hook.mjs');
|
|
26
|
+
const PORT = 47653;
|
|
27
|
+
|
|
28
|
+
// What the hooks should invoke, in order of preference. This choice decides
|
|
29
|
+
// whether upgrading the tool ever reaches the repos it was turned on for.
|
|
30
|
+
//
|
|
31
|
+
// 1. The command on PATH, if it is this package. Resolved fresh every time a
|
|
32
|
+
// hook fires, so `npm i -g nearly-cli@latest` updates every repo at once
|
|
33
|
+
// and nothing has to be turned on again.
|
|
34
|
+
// 2. A pinned npx call, when running from a cache that gets cleared. Pinned on
|
|
35
|
+
// purpose: @latest would check the registry before every single tool call.
|
|
36
|
+
// 3. The path on disk, when running from a clone. Stable and fastest.
|
|
37
|
+
function pkgVersion() {
|
|
38
|
+
try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; }
|
|
39
|
+
catch { return 'latest'; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only trust a `nearly` on PATH if it really is this tool.
|
|
43
|
+
function onPath() {
|
|
44
|
+
try {
|
|
45
|
+
const p = execFileSync(process.platform === 'win32' ? 'where' : 'which', ['nearly'],
|
|
46
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n')[0];
|
|
47
|
+
if (!p) return null;
|
|
48
|
+
const out = execFileSync(p, ['--which'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
49
|
+
return out ? p : null;
|
|
50
|
+
} catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fromPackage = /[\\/]node_modules[\\/]/.test(root) || /[\\/]_npx[\\/]/.test(root);
|
|
54
|
+
const installed = onPath();
|
|
55
|
+
const hookCmd = (ev) => installed
|
|
56
|
+
? `nearly hook ${ev}`
|
|
57
|
+
: fromPackage
|
|
58
|
+
? `npx -y nearly-cli@${pkgVersion()} hook ${ev}`
|
|
59
|
+
: `node ${JSON.stringify(HOOK)} ${ev}`;
|
|
60
|
+
const updateNote = installed
|
|
61
|
+
? 'upgrades reach this repo automatically'
|
|
62
|
+
: fromPackage
|
|
63
|
+
? `pinned to v${pkgVersion()} — run nearly again here after upgrading`
|
|
64
|
+
: 'running from a checkout — git pull updates it';
|
|
65
|
+
|
|
66
|
+
const argv = process.argv.slice(2);
|
|
67
|
+
const off = argv.includes('--off') || argv.includes('--detach');
|
|
68
|
+
const repo = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
|
|
69
|
+
const nameIdx = argv.indexOf('--name');
|
|
70
|
+
const name = (nameIdx !== -1 ? argv[nameIdx + 1] : basename(repo))
|
|
71
|
+
.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 24) || 'repo';
|
|
72
|
+
|
|
73
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
74
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
75
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
76
|
+
|
|
77
|
+
if (!existsSync(join(repo, '.git'))) {
|
|
78
|
+
console.error(`${repo} is not a git repository.`);
|
|
79
|
+
console.error('Run this inside the repo you want recorded, or pass its path.');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Claude Code hooks
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
const dir = join(repo, '.claude');
|
|
87
|
+
const file = join(dir, 'settings.local.json');
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
let settings = {};
|
|
91
|
+
if (existsSync(file)) {
|
|
92
|
+
try { settings = JSON.parse(readFileSync(file, 'utf8')); }
|
|
93
|
+
catch (e) { console.error(`Could not read ${file}: ${e.message}`); process.exit(1); }
|
|
94
|
+
}
|
|
95
|
+
settings.hooks = settings.hooks || {};
|
|
96
|
+
|
|
97
|
+
// Recognise our own entries by the script they run, so this is safe to re-run
|
|
98
|
+
// and leaves anyone else's hooks alone.
|
|
99
|
+
const ours = (m) => (m?.hooks || []).some((h) =>
|
|
100
|
+
/nearly/.test(String(h.command || '')) || String(h.command || '').includes(HOOK) ||
|
|
101
|
+
String(h.url || '').includes(`:${PORT}/hooks/`));
|
|
102
|
+
for (const ev of Object.keys(settings.hooks)) {
|
|
103
|
+
settings.hooks[ev] = (settings.hooks[ev] || []).filter((m) => !ours(m));
|
|
104
|
+
if (!settings.hooks[ev].length) delete settings.hooks[ev];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!off) {
|
|
108
|
+
const entry = (ev, timeout) => ({
|
|
109
|
+
hooks: [{ type: 'command', command: `${hookCmd(ev)} ${name}`, timeout }],
|
|
110
|
+
});
|
|
111
|
+
const add = (event, ev, timeout) => { settings.hooks[event] = [...(settings.hooks[event] || []), entry(ev, timeout)]; };
|
|
112
|
+
add('SessionStart', 'session-start', 20);
|
|
113
|
+
add('UserPromptSubmit', 'prompt', 20);
|
|
114
|
+
add('PreToolUse', 'pre-tool', 600); // long enough to hold while a human decides
|
|
115
|
+
add('PostToolUse', 'post-tool', 20);
|
|
116
|
+
add('Stop', 'stop', 30);
|
|
117
|
+
add('SessionEnd', 'session-end', 120);
|
|
118
|
+
}
|
|
119
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
120
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// git pre-push hook
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
const push = spawnSync(process.execPath,
|
|
126
|
+
[join(root, 'scripts', 'install-push-hook.mjs'), repo, ...(off ? ['--remove'] : [])],
|
|
127
|
+
{ encoding: 'utf8' });
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Where the records are published. They are served by the Nearly's own
|
|
131
|
+
// GitHub Pages, so read it from the Nearly's remote rather than asking.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
function pagesUrl() {
|
|
134
|
+
try {
|
|
135
|
+
const remote = execFileSync('git', ['remote', 'get-url', 'origin'],
|
|
136
|
+
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
137
|
+
const m = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/i);
|
|
138
|
+
if (!m) return null;
|
|
139
|
+
// A plain clone of the upstream repo points at somebody else's Pages, where
|
|
140
|
+
// your records will never exist. Publishing needs a fork you control, so
|
|
141
|
+
// say nothing rather than hand out links that 404.
|
|
142
|
+
let upstream = null;
|
|
143
|
+
try { upstream = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).repository?.url || null; } catch { /* no manifest */ }
|
|
144
|
+
const u = upstream && upstream.match(/github\.com[:/]([^/]+)\/([^/.]+)/i);
|
|
145
|
+
if (u && u[1].toLowerCase() === m[1].toLowerCase() && u[2].toLowerCase() === m[2].toLowerCase()) return null;
|
|
146
|
+
return `https://${m[1].toLowerCase()}.github.io/${m[2]}/records`;
|
|
147
|
+
} catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
// An address already configured wins: it was either set deliberately or worked
|
|
150
|
+
// out here before, and it survives the project being renamed.
|
|
151
|
+
function configured() {
|
|
152
|
+
try {
|
|
153
|
+
const f = join(root, '.nearly.json');
|
|
154
|
+
if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).urlBase || null;
|
|
155
|
+
} catch { /* fall through */ }
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const derived = pagesUrl();
|
|
159
|
+
const base = process.env.NEARLY_URL_BASE || configured() || derived;
|
|
160
|
+
if (base && !off) {
|
|
161
|
+
try {
|
|
162
|
+
const cfg = join(root, '.nearly.json');
|
|
163
|
+
const prev = existsSync(cfg) ? JSON.parse(readFileSync(cfg, 'utf8')) : {};
|
|
164
|
+
writeFileSync(cfg, JSON.stringify({ ...prev, urlBase: base }, null, 2) + '\n');
|
|
165
|
+
} catch { /* the env var still works */ }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
console.log('');
|
|
170
|
+
if (off) {
|
|
171
|
+
console.log(`${bold('Nearly off')} for ${dim(repo)}`);
|
|
172
|
+
console.log(' Claude Code hooks removed');
|
|
173
|
+
console.log(push.status === 0 ? ' pre-push hook removed' : dim(' pre-push hook was not ours, left alone'));
|
|
174
|
+
console.log('');
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log(`${ok('✓')} ${bold('Nearly is on')} for ${bold(name)} ${dim(repo)}`);
|
|
179
|
+
console.log('');
|
|
180
|
+
console.log(` ${ok('·')} every Claude Code session here is gated and recorded`);
|
|
181
|
+
console.log(` ${ok('·')} ${dim(updateNote)}`);
|
|
182
|
+
console.log(` ${ok('·')} ${push.status === 0 ? 'the record is offered when you push' : dim('pre-push hook skipped: ' + (push.stderr || '').trim().split('\n')[0])}`);
|
|
183
|
+
if (base) {
|
|
184
|
+
console.log(` ${ok('·')} records publish to ${base}`);
|
|
185
|
+
} else {
|
|
186
|
+
// Only reachable from a clone of the upstream repo, where publishing needs a
|
|
187
|
+
// fork the person actually controls.
|
|
188
|
+
console.log(` ${ok('·')} ${dim('records stay on this machine')}`);
|
|
189
|
+
console.log(` ${dim('to publish them, fork this repo, turn on GitHub Pages, then:')}`);
|
|
190
|
+
console.log(` ${dim('NEARLY_URL_BASE=https://<you>.github.io/<fork>/records nearly')}`);
|
|
191
|
+
}
|
|
192
|
+
console.log('');
|
|
193
|
+
console.log(` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`);
|
|
194
|
+
console.log(dim(' Nothing to leave running. Turn it off again with --off.'));
|
|
195
|
+
console.log('');
|