waku-memory 0.1.0 → 0.2.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/dist/bootstrap.js +757 -0
- package/dist/capture.js +358 -63
- package/dist/cli.js +101 -11
- package/dist/dialogue.js +139 -0
- package/dist/hook.js +272 -121
- package/dist/project.js +43 -0
- package/package.json +1 -1
package/dist/capture.js
CHANGED
|
@@ -13,63 +13,88 @@
|
|
|
13
13
|
// "y" before anything else happens, including asking for a key.
|
|
14
14
|
//
|
|
15
15
|
// Zero dependencies, per the shim's package.json: only node:fs, node:path
|
|
16
|
-
// and node:util (all built in) below.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
// and node:util (all built in) below, one constant from hook.ts -- the
|
|
17
|
+
// process budget the SessionEnd entry installs, which hook.ts's own network
|
|
18
|
+
// budget has to fit under -- and, since task 13, the bootstrap module (spec
|
|
19
|
+
// 011 §8): the third question's scan/render/parse (task 12) and send (task
|
|
20
|
+
// 13) all live in bootstrap.ts, so enable() below only orchestrates them.
|
|
21
|
+
// hook.ts imports nothing from here in return; bootstrap.ts imports from
|
|
22
|
+
// hook.ts (the watermark rule) but nothing from this file either.
|
|
23
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
25
|
import { isDeepStrictEqual } from 'node:util';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
// same event. Deliberately just the tail of the real command (not the full
|
|
23
|
-
// "npx -y waku-memory hook" string) so a hand-edited variant --
|
|
24
|
-
// different flags, a pinned version -- is still recognized as ours.
|
|
25
|
-
export const WAKU_HOOK_MARKER = 'waku-memory hook';
|
|
26
|
-
// No separate script file to install, version, or leave stale (spec 005):
|
|
27
|
-
// every event runs the shim package itself via npx. `-y` skips npm's
|
|
28
|
-
// install-confirmation prompt, which would otherwise hang a hook that has
|
|
29
|
-
// no terminal to answer it.
|
|
30
|
-
const WAKU_HOOK_COMMAND = `npx -y ${WAKU_HOOK_MARKER}`;
|
|
31
|
-
// Must match hook.ts's own (unexported) CONFIG_FILE_NAME -- the two files
|
|
32
|
-
// agree on this only by convention, not by import, because hook.ts is
|
|
33
|
-
// deliberately dependency-free of this file (it is the piece that runs
|
|
34
|
-
// unattended on every turn; this is the piece a human runs once).
|
|
35
|
-
const CONFIG_FILE_NAME = 'config.json';
|
|
26
|
+
import { LIVE_WINDOW_MS, parseBootstrapAnswer, renderBootstrapList, runBootstrap, scanClaudeCode, } from "./bootstrap.js";
|
|
27
|
+
import { CONFIG_FILE_NAME, SESSION_END_HOOK_TIMEOUT_S } from "./hook.js";
|
|
36
28
|
// `Stop` is where captured content is actually sent -- "async": true so it
|
|
37
29
|
// runs in the background without blocking the turn (600s default budget,
|
|
38
|
-
// research doc §2). `SessionStart`
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
30
|
+
// research doc §2). `SessionStart` is synchronous instead, with a 5s
|
|
31
|
+
// timeout and a matcher naming the five events worth a brief: its stdout is
|
|
32
|
+
// printed straight into the session as context (spec 011 §6, task 9), which
|
|
33
|
+
// is exactly why it can no longer be fire-and-forget the way 0.1.x had it.
|
|
34
|
+
// `SessionEnd` is the flush: all SessionEnd hooks share a 1.5s-by-default
|
|
35
|
+
// exit budget and cannot block Claude Code's own exit, so it carries a
|
|
36
|
+
// `timeout` instead of `async` -- raised to SESSION_END_HOOK_TIMEOUT_S
|
|
37
|
+
// seconds, the process budget hook.ts's own network budget is derived from
|
|
38
|
+
// (its SESSION_END_TIMEOUT_MS comment has the measured detail). `async`
|
|
39
|
+
// only affects whether a hook keeps running after Claude Code moves on; for
|
|
40
|
+
// an event that already cannot block exit, that distinction does not
|
|
41
|
+
// apply, so it is left off rather than set to a value the harness ignores
|
|
42
|
+
// anyway (pinned by the given test's `sessionEnd.async === undefined`).
|
|
43
|
+
const SESSION_START_MATCHER = 'startup|resume|clear|compact|fork';
|
|
44
|
+
function hookEvents(run) {
|
|
45
|
+
const base = run.args
|
|
46
|
+
? { type: 'command', command: run.command, args: run.args }
|
|
47
|
+
: { type: 'command', command: run.command };
|
|
48
|
+
return {
|
|
49
|
+
Stop: { hooks: [{ ...base, async: true }] },
|
|
50
|
+
SessionStart: { matcher: SESSION_START_MATCHER, hooks: [{ ...base, timeout: 5 }] },
|
|
51
|
+
SessionEnd: { hooks: [{ ...base, timeout: SESSION_END_HOOK_TIMEOUT_S }] },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Ours in either form: a shell command that names the package and ends
|
|
55
|
+
// with the subcommand (0.1.x), or an exec-form entry whose last argument is
|
|
56
|
+
// `hook` and whose script lives under a `.waku-memory/hook/` directory.
|
|
57
|
+
// Deliberately not a single marker constant checked against one field
|
|
58
|
+
// anymore -- the exec form has no one field that both names the package and
|
|
59
|
+
// the subcommand the way the 0.1.x shell string did, so recognising it
|
|
60
|
+
// needs its own arm.
|
|
61
|
+
export function isOwnEntry(entry) {
|
|
62
|
+
// settings.json is only validated as a top-level plain object
|
|
63
|
+
// (readClaudeSettings's own comment) -- anything inside `hooks` is
|
|
64
|
+
// whatever some other tool wrote there, so `command` and `args` are
|
|
65
|
+
// `unknown` in practice despite what HookCommandEntry claims at compile
|
|
66
|
+
// time. An entry this cannot prove is shaped the way ours would be is
|
|
67
|
+
// never ours: false, not a thrown TypeError (final review M1 -- a real
|
|
68
|
+
// exec-form entry with a non-string argument used to throw out of here,
|
|
69
|
+
// reaching mergeHookSettings, reaching enable() *after* config.json is
|
|
70
|
+
// already written).
|
|
71
|
+
if (typeof entry.command !== 'string')
|
|
72
|
+
return false;
|
|
73
|
+
if (entry.args !== undefined) {
|
|
74
|
+
if (!Array.isArray(entry.args) || entry.args.some((a) => typeof a !== 'string'))
|
|
75
|
+
return false;
|
|
76
|
+
const args = entry.args;
|
|
77
|
+
return args[args.length - 1] === 'hook' && args.some((a) => a.replace(/\\/g, '/').includes('/.waku-memory/hook/'));
|
|
78
|
+
}
|
|
79
|
+
return entry.command.includes('waku-memory') && entry.command.trimEnd().endsWith(' hook');
|
|
80
|
+
}
|
|
52
81
|
// A group is "ours" only if every hook inside it is ours -- mergeHookSettings
|
|
53
82
|
// only ever writes single-hook groups, so in practice this means exactly
|
|
54
|
-
// one hook
|
|
55
|
-
//
|
|
56
|
-
//
|
|
83
|
+
// one hook isOwnEntry recognises, but the "every" check (rather than
|
|
84
|
+
// "some") is what makes removeHookSettings's contract exact: it must never
|
|
85
|
+
// delete a group that also carries someone else's hook.
|
|
57
86
|
function isOwnGroup(group) {
|
|
58
|
-
return group.hooks.length > 0 && group.hooks.every(
|
|
87
|
+
return group.hooks.length > 0 && group.hooks.every(isOwnEntry);
|
|
59
88
|
}
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
export function mergeHookSettings(existing) {
|
|
89
|
+
// Replaces any group of ours on each event -- an older version's entry, in
|
|
90
|
+
// either form -- with one built from `run`, and keeps every foreign group
|
|
91
|
+
// untouched. Idempotent: merging the same invocation twice yields the same
|
|
92
|
+
// settings (pinned below), which is what makes running `enable` again, on
|
|
93
|
+
// the same version, safe.
|
|
94
|
+
export function mergeHookSettings(existing, run) {
|
|
67
95
|
const hooks = { ...(existing.hooks ?? {}) };
|
|
68
|
-
for (const [event,
|
|
69
|
-
|
|
70
|
-
if (!groups.some(isOwnGroup)) {
|
|
71
|
-
hooks[event] = [...groups, { hooks: [entry] }];
|
|
72
|
-
}
|
|
96
|
+
for (const [event, group] of Object.entries(hookEvents(run))) {
|
|
97
|
+
hooks[event] = [...(hooks[event] ?? []).filter((g) => !isOwnGroup(g)), group];
|
|
73
98
|
}
|
|
74
99
|
return { ...existing, hooks };
|
|
75
100
|
}
|
|
@@ -94,14 +119,86 @@ export function removeHookSettings(existing) {
|
|
|
94
119
|
}
|
|
95
120
|
return { ...existing, hooks };
|
|
96
121
|
}
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
122
|
+
// Copies the shim's own compiled dist/*.js, next to a one-line ESM
|
|
123
|
+
// package.json, into a version-numbered directory under configDir --
|
|
124
|
+
// installed rather than run via npx (2.5s per hook, measured, on top of
|
|
125
|
+
// SessionStart now being synchronous and printing: spec 011 §6) and
|
|
126
|
+
// versioned rather than a single fixed path so an in-flight hook from the
|
|
127
|
+
// previous version is never overwritten mid-run by the next `enable`
|
|
128
|
+
// (pruneHookCopies below cleans up once settings.json has moved on).
|
|
129
|
+
// A no-op when sourceDir already *is* the copy -- enable() re-run from
|
|
130
|
+
// inside an already-installed copy has nothing left to do.
|
|
131
|
+
export function installHookCopy(sourceDir, configDir, version) {
|
|
132
|
+
const target = join(configDir, 'hook', version);
|
|
133
|
+
const indexPath = join(target, 'index.js').replace(/\\/g, '/'); // Claude Code spawns this directly -- see hookInvocation
|
|
134
|
+
if (resolve(sourceDir) === resolve(target))
|
|
135
|
+
return indexPath;
|
|
136
|
+
mkdirSync(target, { recursive: true });
|
|
137
|
+
for (const name of readdirSync(sourceDir)) {
|
|
138
|
+
if (name.endsWith('.js'))
|
|
139
|
+
copyFileSync(join(sourceDir, name), join(target, name));
|
|
140
|
+
}
|
|
141
|
+
writeFileSync(join(target, 'package.json'), '{"type":"module"}\n', 'utf8'); // dist/*.js is ESM
|
|
142
|
+
return indexPath;
|
|
143
|
+
}
|
|
144
|
+
// After settings.json points only at `keep`, every other copy under
|
|
145
|
+
// configDir/hook is stale disk. A directory Windows refuses to delete
|
|
146
|
+
// (EBUSY: an old hook process still running) is reported and left for the
|
|
147
|
+
// next enable to retry -- the entries already point elsewhere, so a copy
|
|
148
|
+
// left behind here is wasted space, never a wrong hook running.
|
|
149
|
+
export function pruneHookCopies(configDir, keep) {
|
|
150
|
+
const root = join(configDir, 'hook');
|
|
151
|
+
let names;
|
|
152
|
+
try {
|
|
153
|
+
names = readdirSync(root);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
if (name === keep)
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
rmSync(join(root, name), { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
console.error(`Could not remove the old hook copy ${join(root, name)} -- ${err.message}. ` +
|
|
166
|
+
`It will be removed by the next "capture enable".`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Exec form (spec 011 §6): Claude Code spawns command/args directly, no
|
|
171
|
+
// shell. The command is the Node that ran `enable`, by absolute path, not
|
|
172
|
+
// the bare word "node" -- Claude Code ships no Node of its own and a
|
|
173
|
+
// launcher's PATH is not guaranteed to have one on it either.
|
|
174
|
+
export function hookInvocation(execPath, indexPath) {
|
|
175
|
+
return { command: execPath.replace(/\\/g, '/'), args: [indexPath, 'hook'] };
|
|
176
|
+
}
|
|
177
|
+
// The first two sentences are spec 005's "Disclosure" section, shown at the
|
|
178
|
+
// moment that discharges status.md item 9's obligation for capture: "before
|
|
179
|
+
// anyone outside the three of us is invited, and no later than the day a
|
|
180
|
+
// worker is deployed, they need to be told the environment can lose their
|
|
181
|
+
// data and that importing [or capturing] sends its contents to Anthropic."
|
|
182
|
+
// Both facts, two sentences, nothing softened.
|
|
183
|
+
//
|
|
184
|
+
// The next three sentences are spec 011 §9 (A15, task 14), added because
|
|
185
|
+
// "captured content is sent" on its own reads as "everything on this
|
|
186
|
+
// machine is sent" -- not true, and the gap matters enough to name: typed
|
|
187
|
+
// text, replies and tool names go; the files an agent reads and the
|
|
188
|
+
// commands it runs stay local *unless the agent's own reply quotes them
|
|
189
|
+
// back* (the one path by which their content can still leave, so the
|
|
190
|
+
// carve-out has to say so rather than imply a cleaner boundary than the
|
|
191
|
+
// hook actually draws); reasoning is never sent, full stop. The last
|
|
192
|
+
// sentence previews enable()'s own third question (task 13, spec 011 §8):
|
|
193
|
+
// "the next question" is literal, not rhetorical -- renderBootstrapList
|
|
194
|
+
// (bootstrap.ts) is the list it points at, printed immediately after this
|
|
195
|
+
// disclosure and the "y" it gates, before that question is ever asked.
|
|
103
196
|
export const DISCLOSURE = 'Captured content is sent to our servers and to Anthropic for extraction. ' +
|
|
104
|
-
'This is an alpha whose data can be lost.'
|
|
197
|
+
'This is an alpha whose data can be lost. ' +
|
|
198
|
+
'What you type, what the agent replies, and the names of the tools it uses are sent; ' +
|
|
199
|
+
'the files it reads and the commands it runs are not, except where the agent quotes them in its reply, ' +
|
|
200
|
+
'and its reasoning is never sent. ' +
|
|
201
|
+
'Enabling can also import the memory and history Claude Code already keeps here; the next question lists exactly what.';
|
|
105
202
|
// Exported so cli.ts's usage text can point at the same URL without a
|
|
106
203
|
// second copy of it drifting out of sync.
|
|
107
204
|
//
|
|
@@ -140,6 +237,102 @@ function readClaudeSettings(settingsPath) {
|
|
|
140
237
|
return undefined;
|
|
141
238
|
}
|
|
142
239
|
}
|
|
240
|
+
// Only printable ASCII can be part of a bearer key. Non-printable
|
|
241
|
+
// characters at either *end* came from the terminal, not the person: on
|
|
242
|
+
// 2026-09-04 a Windows console delivered Ctrl+V's own control code (0x16)
|
|
243
|
+
// ahead of the key, `.trim()` left it in place, and every hook call then
|
|
244
|
+
// failed with an opaque "fetch failed" (undici refusing the Authorization
|
|
245
|
+
// header). Whitespace and non-ASCII (an IME's full-width space, say) are
|
|
246
|
+
// stripped from the ends for the same reason.
|
|
247
|
+
//
|
|
248
|
+
// Anything non-printable left *inside* is a different thing: the paste
|
|
249
|
+
// held more than the key ("Bearer mem_sk_..."), and stripping it would
|
|
250
|
+
// glue two tokens into one wrong key that looks right -- the 0.1.1 review
|
|
251
|
+
// reproduced exactly that, with the next hook's 401 landing in a
|
|
252
|
+
// background process nobody watches. So the interior count is returned,
|
|
253
|
+
// not repaired, and askForKey refuses on it.
|
|
254
|
+
export function sanitizeKey(raw) {
|
|
255
|
+
const printable = (c) => {
|
|
256
|
+
const code = c.charCodeAt(0);
|
|
257
|
+
return code >= 0x21 && code <= 0x7e;
|
|
258
|
+
};
|
|
259
|
+
const chars = [...raw];
|
|
260
|
+
let start = 0;
|
|
261
|
+
let end = chars.length;
|
|
262
|
+
while (start < end && !printable(chars[start]))
|
|
263
|
+
start++;
|
|
264
|
+
while (end > start && !printable(chars[end - 1]))
|
|
265
|
+
end--;
|
|
266
|
+
const kept = chars.slice(start, end);
|
|
267
|
+
return {
|
|
268
|
+
key: kept.join(''),
|
|
269
|
+
removed: chars.length - kept.length,
|
|
270
|
+
interior: kept.filter((c) => !printable(c)).length,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const KEY_ATTEMPTS = 3;
|
|
274
|
+
// What every key minted at KEYS_PAGE_URL starts with (api_key_repository.py:
|
|
275
|
+
// PREFIX + token_urlsafe(32)). A key without it is warned about, not
|
|
276
|
+
// refused: the server's opinion is the only one that counts, and a refusal
|
|
277
|
+
// here would make a future prefix change strand every user on this shim.
|
|
278
|
+
const KEY_PREFIX = 'mem_sk_';
|
|
279
|
+
// Asks up to KEY_ATTEMPTS times. null means the person never pasted anything
|
|
280
|
+
// usable, and enable() then ends with the same "nothing was written"
|
|
281
|
+
// guarantee that declining the disclosure carries. No branch below ever
|
|
282
|
+
// prints the key or any part of it -- counts and a prefix only.
|
|
283
|
+
async function askForKey(deps) {
|
|
284
|
+
for (let attempt = 1; attempt <= KEY_ATTEMPTS; attempt++) {
|
|
285
|
+
const raw = await deps.prompt(`Paste an API key from ${KEYS_PAGE_URL} (it is shown once, at mint time): `);
|
|
286
|
+
const { key, removed, interior } = sanitizeKey(raw);
|
|
287
|
+
if (interior > 0) {
|
|
288
|
+
console.log(`The pasted text has ${interior} character(s) inside it that cannot be part of a key -- ` +
|
|
289
|
+
`more than the key was pasted. Paste the key itself, nothing else.`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (key === '') {
|
|
293
|
+
if (attempt < KEY_ATTEMPTS) {
|
|
294
|
+
console.log('That did not contain a usable key -- paste the key itself, nothing else.');
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (removed > 0) {
|
|
299
|
+
console.log(`Removed ${removed} non-printable character(s) from the ends of the pasted key -- ` +
|
|
300
|
+
`a terminal can add Ctrl+V's own code ahead of what it pastes.`);
|
|
301
|
+
}
|
|
302
|
+
if (!key.startsWith(KEY_PREFIX)) {
|
|
303
|
+
console.log(`Note: the key does not start with ${KEY_PREFIX}, which every key minted at ${KEYS_PAGE_URL} does. ` +
|
|
304
|
+
`Saving it as pasted; if the hook reports 401, run this command again with a fresh key.`);
|
|
305
|
+
}
|
|
306
|
+
return key;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
// A parsed BootstrapAnswer still carries `invalid`; runBootstrap's own
|
|
311
|
+
// BootstrapSelection does not (its three real outcomes are `all`, `skip` and
|
|
312
|
+
// `exclude`) -- askBootstrapSelection's own re-ask loop below is where a
|
|
313
|
+
// still-invalid second answer becomes `skip`, so by the time this is called
|
|
314
|
+
// that case has already been decided; this is a plain, total mapping, never
|
|
315
|
+
// itself a decision point.
|
|
316
|
+
function toSelection(answer) {
|
|
317
|
+
if (answer.kind === 'exclude')
|
|
318
|
+
return { kind: 'exclude', numbers: answer.numbers };
|
|
319
|
+
if (answer.kind === 'all')
|
|
320
|
+
return { kind: 'all' };
|
|
321
|
+
return { kind: 'skip' }; // 'skip', or a still-invalid second answer
|
|
322
|
+
}
|
|
323
|
+
// The third question, spec 011 §8: asks '> ' once, and on an invalid answer
|
|
324
|
+
// prints the one-line hint and asks once more -- a second invalid answer is
|
|
325
|
+
// treated as skip rather than asked a third time, the same "stop pestering
|
|
326
|
+
// the person" reasoning A12's own parseBootstrapAnswer comment gives for
|
|
327
|
+
// only ever re-asking once.
|
|
328
|
+
async function askBootstrapSelection(projectCount, prompt) {
|
|
329
|
+
let answer = parseBootstrapAnswer(await prompt('> '), projectCount);
|
|
330
|
+
if (answer.kind === 'invalid') {
|
|
331
|
+
console.log('Enter, numbers, or n.');
|
|
332
|
+
answer = parseBootstrapAnswer(await prompt('> '), projectCount);
|
|
333
|
+
}
|
|
334
|
+
return toSelection(answer);
|
|
335
|
+
}
|
|
143
336
|
// The one gesture that turns capture on. Order is deliberate and each step
|
|
144
337
|
// depends on the one before it having succeeded:
|
|
145
338
|
// 1. refuse if Claude Code itself is not on this machine -- the directory
|
|
@@ -153,10 +346,37 @@ function readClaudeSettings(settingsPath) {
|
|
|
153
346
|
// including asking for the key, so declining costs the user nothing
|
|
154
347
|
// and asks for nothing;
|
|
155
348
|
// 4. only then ask for the key;
|
|
156
|
-
// 5.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
349
|
+
// 5. install a local copy of the shim under configDir/hook/<version> and
|
|
350
|
+
// run it for real -- verifyHook -- before anything is wired into
|
|
351
|
+
// settings.json. A copy that does not even start (a corrupted copy, a
|
|
352
|
+
// Node too old for it, ...) must never become the thing Claude Code
|
|
353
|
+
// calls on every turn, so a failed verification removes the copy this
|
|
354
|
+
// call made under configDir/hook/<version> (and configDir/hook itself,
|
|
355
|
+
// if that copy was the only thing under it) and refuses right here,
|
|
356
|
+
// before config.json or settings.json is ever touched. A refusal
|
|
357
|
+
// removes only a copy this call made, though: installHookCopy is a
|
|
358
|
+
// no-op when hookSourceDir already *is* configDir/hook/<version> --
|
|
359
|
+
// enable running from inside the copy already installed there, which
|
|
360
|
+
// is what hookSourceDir (derived from the running cli.js's own
|
|
361
|
+
// import.meta.url) gives once a copy is in place -- and then this call
|
|
362
|
+
// installed nothing, so a failed verification leaves that directory
|
|
363
|
+
// alone: it may be the very copy settings.json still points at from an
|
|
364
|
+
// earlier, successful enable, and it is not this call's to delete.
|
|
365
|
+
// installHookCopy's mkdirSync(..., { recursive: true }) creates every
|
|
366
|
+
// ancestor directory on the way down, though, so the one thing a
|
|
367
|
+
// first-ever run can still leave behind after refusing here is an
|
|
368
|
+
// empty configDir -- not removed, since it is the config directory
|
|
369
|
+
// itself, not anything this step wrote into it;
|
|
370
|
+
// 6. write config.json, then merge and write settings.json with the
|
|
371
|
+
// now-verified invocation. Everything before this point (refusing,
|
|
372
|
+
// declining, a failed verification) writes nothing new to disk -- step
|
|
373
|
+
// 5's own cleanup is what makes that true when a failed verification
|
|
374
|
+
// follows a real copy, down to the empty-configDir exception described
|
|
375
|
+
// there; when installHookCopy was instead a no-op, this call had
|
|
376
|
+
// nothing of its own to remove, and the pre-existing copy it leaves in
|
|
377
|
+
// place was never new. This step
|
|
378
|
+
// itself is not atomic across the two files, though: config.json is
|
|
379
|
+
// committed to disk first, so a
|
|
160
380
|
// failure on the settings.json write right after -- disk full, a
|
|
161
381
|
// permission error -- does leave a real credential saved with no
|
|
162
382
|
// hook actually installed, i.e. capture silently not active despite
|
|
@@ -166,7 +386,22 @@ function readClaudeSettings(settingsPath) {
|
|
|
166
386
|
// sets a nonzero exit code, so it is not silent; it fails toward
|
|
167
387
|
// "capture not enabled" rather than a consent bypass; and re-running
|
|
168
388
|
// `enable` repairs it, since writing config.json again is a plain
|
|
169
|
-
// overwrite (capture.test.mjs's "running twice" test)
|
|
389
|
+
// overwrite (capture.test.mjs's "running twice" test);
|
|
390
|
+
// 7. prune every other hook/<version> copy now that settings.json points
|
|
391
|
+
// only at this one (pruneHookCopies) -- best-effort, never fatal, and
|
|
392
|
+
// only after the settings write so a prune failure can never leave
|
|
393
|
+
// settings.json pointing at a copy this step just deleted.
|
|
394
|
+
// 8. when deps.bootstrap.enabled (task 13, spec 011 §8): scan the machine
|
|
395
|
+
// for Claude Code's own memory files and transcripts (scanClaudeCode)
|
|
396
|
+
// and, only if that scan found something, print the one numbered list
|
|
397
|
+
// and ask the third and last question. This runs after the hooks are
|
|
398
|
+
// wired in and verified, deliberately: bootstrap is additional value
|
|
399
|
+
// once capture itself is real, not a precondition for it, and its own
|
|
400
|
+
// failure (a bad answer, a network error inside runBootstrap) must
|
|
401
|
+
// never undo a successful enable -- so nothing below this point can
|
|
402
|
+
// turn 'enabled' into anything else. An empty scan (nothing found, no
|
|
403
|
+
// user CLAUDE.md) says so and asks nothing at all: the person who has
|
|
404
|
+
// nothing to import should not be asked whether to skip importing it.
|
|
170
405
|
export async function enable(url, deps) {
|
|
171
406
|
if (!existsSync(deps.claudeDir)) {
|
|
172
407
|
console.error(`Claude Code not found at ${deps.claudeDir} -- install Claude Code first, then run ` +
|
|
@@ -186,7 +421,35 @@ export async function enable(url, deps) {
|
|
|
186
421
|
console.log('Capture not enabled -- nothing was written.');
|
|
187
422
|
return 'declined';
|
|
188
423
|
}
|
|
189
|
-
const key =
|
|
424
|
+
const key = await askForKey(deps);
|
|
425
|
+
if (key === null) {
|
|
426
|
+
console.log('Capture not enabled -- no usable key was entered; nothing was written.');
|
|
427
|
+
return 'declined';
|
|
428
|
+
}
|
|
429
|
+
const indexPath = installHookCopy(deps.hookSourceDir, deps.configDir, deps.version);
|
|
430
|
+
const run = hookInvocation(deps.execPath, indexPath);
|
|
431
|
+
if (!deps.verifyHook(run)) {
|
|
432
|
+
// Undo the copy this call just installed -- but only if it made one.
|
|
433
|
+
// installHookCopy no-ops when hookSourceDir already *is* the target
|
|
434
|
+
// directory (enable re-run from inside the installed copy); deleting
|
|
435
|
+
// the target in that case would remove a pre-existing directory this
|
|
436
|
+
// call never wrote, possibly the very copy settings.json still points
|
|
437
|
+
// at from an earlier, successful enable.
|
|
438
|
+
if (resolve(deps.hookSourceDir) !== resolve(dirname(indexPath))) {
|
|
439
|
+
rmSync(dirname(indexPath), { recursive: true, force: true });
|
|
440
|
+
const hookRoot = join(deps.configDir, 'hook');
|
|
441
|
+
try {
|
|
442
|
+
if (readdirSync(hookRoot).length === 0)
|
|
443
|
+
rmSync(hookRoot, { recursive: true, force: true });
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// hookRoot is already gone -- nothing left to prune
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
console.error(`The hook entry did not run (${run.command} ${(run.args ?? []).join(' ')}) -- ` +
|
|
450
|
+
`nothing was written.`);
|
|
451
|
+
return 'refused';
|
|
452
|
+
}
|
|
190
453
|
mkdirSync(deps.configDir, { recursive: true }); // first run: ~/.waku-memory may not exist yet
|
|
191
454
|
const configPath = join(deps.configDir, CONFIG_FILE_NAME);
|
|
192
455
|
// {mode: 0o600} is a request, not a guarantee: Windows has no POSIX
|
|
@@ -199,11 +462,43 @@ export async function enable(url, deps) {
|
|
|
199
462
|
// atomic temp-file-plus-rename technique earns its complexity there, not
|
|
200
463
|
// here.
|
|
201
464
|
writeFileSync(configPath, JSON.stringify({ url, key }, null, 2) + '\n', { mode: 0o600 });
|
|
202
|
-
const merged = mergeHookSettings(existingSettings);
|
|
465
|
+
const merged = mergeHookSettings(existingSettings, run);
|
|
203
466
|
deps.writeSettingsJson(deps.settingsPath, merged);
|
|
467
|
+
pruneHookCopies(deps.configDir, deps.version);
|
|
204
468
|
console.log('');
|
|
205
469
|
console.log(`Capture enabled -- wrote ${configPath} and merged hooks into ${deps.settingsPath}.`);
|
|
206
470
|
console.log('Start a new Claude Code session to pick up the hooks.');
|
|
471
|
+
if (deps.bootstrap.enabled) {
|
|
472
|
+
const { windowDays } = deps.bootstrap;
|
|
473
|
+
const stateDir = join(deps.configDir, 'state');
|
|
474
|
+
const scan = scanClaudeCode(deps.claudeDir, stateDir, {
|
|
475
|
+
nowMs: Date.now(),
|
|
476
|
+
sinceMs: windowDays === null ? 0 : Date.now() - windowDays * 86_400_000,
|
|
477
|
+
liveWindowMs: LIVE_WINDOW_MS,
|
|
478
|
+
all: windowDays === null,
|
|
479
|
+
});
|
|
480
|
+
console.log('');
|
|
481
|
+
// renderBootstrapList already returns the one "nothing found" sentence
|
|
482
|
+
// for an empty scan (A12) -- reusing it here, rather than a second copy
|
|
483
|
+
// of that literal string, is what keeps the two from drifting apart.
|
|
484
|
+
// The question itself is skipped on exactly the same condition: a
|
|
485
|
+
// person with nothing to import should not be asked whether to skip it.
|
|
486
|
+
console.log(renderBootstrapList(scan, windowDays));
|
|
487
|
+
if (scan.projects.length > 0 || scan.userClaudeMd !== null) {
|
|
488
|
+
const selection = await askBootstrapSelection(scan.projects.length, deps.prompt);
|
|
489
|
+
const result = await runBootstrap(scan, selection, {
|
|
490
|
+
url,
|
|
491
|
+
key,
|
|
492
|
+
fetchImpl: deps.fetchImpl,
|
|
493
|
+
stateDir,
|
|
494
|
+
all: windowDays === null,
|
|
495
|
+
});
|
|
496
|
+
let line = `${result.memoryFiles} memory files queued, ${result.sessions} sessions queued in ${result.pieces} pieces`;
|
|
497
|
+
if (result.failed > 0)
|
|
498
|
+
line += `, ${result.failed} failed`;
|
|
499
|
+
console.log(line);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
207
502
|
return 'enabled';
|
|
208
503
|
}
|
|
209
504
|
// Removes exactly what enable() added and nothing else. Deliberately leaves
|