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.
@@ -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('');