waku-memory 0.1.0 → 0.3.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/capture.js CHANGED
@@ -13,63 +13,138 @@
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
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
- import { join } from 'node:path';
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
- // The marker every hook command this tool writes contains, and the only
21
- // thing removeHookSettings uses to tell "ours" from a foreign hook on the
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, scanAll, } from "./bootstrap.js";
27
+ import { CONFIG_FILE_NAME, SESSION_END_HOOK_TIMEOUT_S } from "./hook.js";
28
+ // Task 9's Codex writers (spec 012 §4.2, §4.3, §4.6), consumed here rather
29
+ // than re-implemented: codex-config.ts already decided *what* the TOML
30
+ // table, the hooks.json entries and the marketplace entry should look like,
31
+ // as pure functions over strings and objects -- enable()/disable()/status()
32
+ // below only decide *when* to call them and what to do with a real
33
+ // filesystem. codex-config.ts's own import from this file (HookCommandEntry,
34
+ // HookGroup) is `import type`, erased at compile time, so this is not a
35
+ // runtime import cycle: loading capture.ts still only ever requires
36
+ // node:fs/node:path/node:util, harnesses.ts (via codex-config.ts) and
37
+ // bootstrap.ts, the same dependency shape the file header above describes.
38
+ import { codexPluginPresent, isOwnCodexEntry, mergeCodexHooks, mergeCodexToml, mergeMarketplaceEntry, removeCodexHooks, removeCodexToml, } from "./codex-config.js";
39
+ // CONFIG_FILE_NAME and SESSION_END_HOOK_TIMEOUT_S are imported from hook.ts
40
+ // above rather than re-declared: two copies of a name the hook reads and
41
+ // this command writes would drift the first time one moved. The dependency
42
+ // runs one way only -- hook.ts (the piece that runs unattended on every
43
+ // turn) imports nothing from this file (the piece a human runs once).
44
+ // Fix round 2 (re-review finding #1, controller ruling R9, spec 012 §4.2):
45
+ // the single source of truth for "given one URL a person typed, what is the
46
+ // ingest base and what is the MCP endpoint". Round 1 got this only half
47
+ // right -- it derived ingestUrl from mcpUrl by stripping a trailing "/mcp",
48
+ // but cli.ts's usage text documents "--url <ingest-base>" (an *ingest*-shaped
49
+ // value), and dispatch() never computed a "/mcp"-suffixed mcpUrl from that
50
+ // shape at all: an ingest-shaped --url with no "/mcp" to strip left
51
+ // mcpUrl === ingestUrl, reproducing the exact defect (the ingest base
52
+ // reaching the Codex TOML) for anyone who follows the tool's own documented
53
+ // flag contract instead of guessing the MCP-shaped alternative.
54
+ //
55
+ // This function inverts the direction: always derive the *ingest* base first
56
+ // (strip trailing slashes, then one trailing "/mcp" if present), then always
57
+ // build mcpUrl by appending "/mcp" to that -- so every shape a person could
58
+ // reasonably type ("https://x.test", "https://x.test/", "https://x.test/mcp")
59
+ // converges on the same correct pair, rather than only working when the
60
+ // input happens to already be MCP-shaped. Exported so both dispatch()
61
+ // (cli.ts, computing the pair from --url) and disable() below (recovering
62
+ // the pair enable() used, from what it stored in config.json) share the one
63
+ // rule -- capture.ts is the natural home since cli.ts already imports from
64
+ // it and the reverse would be a cycle.
65
+ export function urlPair(given) {
66
+ let ingestUrl = given.replace(/\/+$/, '');
67
+ if (ingestUrl.endsWith('/mcp'))
68
+ ingestUrl = ingestUrl.slice(0, -'/mcp'.length).replace(/\/+$/, '');
69
+ return { ingestUrl, mcpUrl: `${ingestUrl}/mcp` };
70
+ }
36
71
  // `Stop` is where captured content is actually sent -- "async": true so it
37
72
  // runs in the background without blocking the turn (600s default budget,
38
- // research doc §2). `SessionStart` posts the liveness heartbeat on the same
39
- // fire-and-forget basis. `SessionEnd` is the flush: all SessionEnd hooks
40
- // share a 1.5s-by-default exit budget (raisable only to 60s) and cannot
41
- // block Claude Code's own exit -- hook.ts's SESSION_END_TIMEOUT_MS comment
42
- // has the measured detail -- so it carries a `timeout` instead of `async`.
43
- // `async` only affects whether a hook keeps running after Claude Code moves
44
- // on; for an event that already cannot block exit, that distinction does
45
- // not apply, so it is left off rather than set to a value the harness
46
- // ignores anyway (pinned by the given test's `sessionEnd.async === undefined`).
47
- const HOOK_EVENTS = {
48
- Stop: { type: 'command', command: WAKU_HOOK_COMMAND, async: true },
49
- SessionStart: { type: 'command', command: WAKU_HOOK_COMMAND, async: true },
50
- SessionEnd: { type: 'command', command: WAKU_HOOK_COMMAND, timeout: 10 },
51
- };
73
+ // research doc §2). `SessionStart` is synchronous instead, with a 5s
74
+ // timeout and a matcher naming the five events worth a brief: its stdout is
75
+ // printed straight into the session as context (spec 011 §6, task 9), which
76
+ // is exactly why it can no longer be fire-and-forget the way 0.1.x had it.
77
+ // `SessionEnd` is the flush: all SessionEnd hooks share a 1.5s-by-default
78
+ // exit budget and cannot block Claude Code's own exit, so it carries a
79
+ // `timeout` instead of `async` -- raised to SESSION_END_HOOK_TIMEOUT_S
80
+ // seconds, the process budget hook.ts's own network budget is derived from
81
+ // (its SESSION_END_TIMEOUT_MS comment has the measured detail). `async`
82
+ // only affects whether a hook keeps running after Claude Code moves on; for
83
+ // an event that already cannot block exit, that distinction does not
84
+ // apply, so it is left off rather than set to a value the harness ignores
85
+ // anyway (pinned by the given test's `sessionEnd.async === undefined`).
86
+ const SESSION_START_MATCHER = 'startup|resume|clear|compact|fork';
87
+ function hookEvents(run) {
88
+ const base = run.args
89
+ ? { type: 'command', command: run.command, args: run.args }
90
+ : { type: 'command', command: run.command };
91
+ return {
92
+ Stop: { hooks: [{ ...base, async: true }] },
93
+ SessionStart: { matcher: SESSION_START_MATCHER, hooks: [{ ...base, timeout: 5 }] },
94
+ SessionEnd: { hooks: [{ ...base, timeout: SESSION_END_HOOK_TIMEOUT_S }] },
95
+ };
96
+ }
97
+ // Ours in either form: a shell command that names the package and ends
98
+ // with the subcommand (0.1.x), or an exec-form entry whose last argument is
99
+ // `hook` and whose script lives under a `.waku-memory/hook/` directory.
100
+ // Deliberately not a single marker constant checked against one field
101
+ // anymore -- the exec form has no one field that both names the package and
102
+ // the subcommand the way the 0.1.x shell string did, so recognising it
103
+ // needs its own arm.
104
+ export function isOwnEntry(entry) {
105
+ // settings.json is only validated as a top-level plain object
106
+ // (readClaudeSettings's own comment) -- anything inside `hooks` is
107
+ // whatever some other tool wrote there, so `command` and `args` are
108
+ // `unknown` in practice despite what HookCommandEntry claims at compile
109
+ // time. An entry this cannot prove is shaped the way ours would be is
110
+ // never ours: false, not a thrown TypeError (final review M1 -- a real
111
+ // exec-form entry with a non-string argument used to throw out of here,
112
+ // reaching mergeHookSettings, reaching enable() *after* config.json is
113
+ // already written).
114
+ if (typeof entry.command !== 'string')
115
+ return false;
116
+ if (entry.args !== undefined) {
117
+ if (!Array.isArray(entry.args) || entry.args.some((a) => typeof a !== 'string'))
118
+ return false;
119
+ const args = entry.args;
120
+ return args[args.length - 1] === 'hook' && args.some((a) => a.replace(/\\/g, '/').includes('/.waku-memory/hook/'));
121
+ }
122
+ return entry.command.includes('waku-memory') && entry.command.trimEnd().endsWith(' hook');
123
+ }
52
124
  // A group is "ours" only if every hook inside it is ours -- mergeHookSettings
53
125
  // only ever writes single-hook groups, so in practice this means exactly
54
- // one hook whose command carries the marker, but the "every" check (rather
55
- // than "some") is what makes removeHookSettings's contract exact: it must
56
- // never delete a group that also carries someone else's hook.
126
+ // one hook isOwnEntry recognises, but the "every" check (rather than
127
+ // "some") is what makes removeHookSettings's contract exact: it must never
128
+ // delete a group that also carries someone else's hook.
57
129
  function isOwnGroup(group) {
58
- return group.hooks.length > 0 && group.hooks.every((h) => h.command.includes(WAKU_HOOK_MARKER));
59
- }
60
- // Appends one group per event in HOOK_EVENTS, skipping any event that
61
- // already carries one of ours -- running `enable` twice must not duplicate
62
- // entries (pinned by the brief's idempotence test and this file's "running
63
- // twice" orchestration test). A foreign group already on the same event is
64
- // never inspected beyond isOwnGroup's check and is never reordered or
65
- // rewritten -- only ours is ever added.
66
- export function mergeHookSettings(existing) {
130
+ return group.hooks.length > 0 && group.hooks.every(isOwnEntry);
131
+ }
132
+ // Ours if any group under any event is entirely ours -- status() (below)
133
+ // needs exactly this yes/no, not the merge/remove machinery.
134
+ function hasOwnHooks(settings) {
135
+ if (!settings.hooks)
136
+ return false;
137
+ return Object.values(settings.hooks).some((groups) => groups.some(isOwnGroup));
138
+ }
139
+ // Replaces any group of ours on each event -- an older version's entry, in
140
+ // either form -- with one built from `run`, and keeps every foreign group
141
+ // untouched. Idempotent: merging the same invocation twice yields the same
142
+ // settings (pinned below), which is what makes running `enable` again, on
143
+ // the same version, safe.
144
+ export function mergeHookSettings(existing, run) {
67
145
  const hooks = { ...(existing.hooks ?? {}) };
68
- for (const [event, entry] of Object.entries(HOOK_EVENTS)) {
69
- const groups = hooks[event] ?? [];
70
- if (!groups.some(isOwnGroup)) {
71
- hooks[event] = [...groups, { hooks: [entry] }];
72
- }
146
+ for (const [event, group] of Object.entries(hookEvents(run))) {
147
+ hooks[event] = [...(hooks[event] ?? []).filter((g) => !isOwnGroup(g)), group];
73
148
  }
74
149
  return { ...existing, hooks };
75
150
  }
@@ -94,14 +169,96 @@ export function removeHookSettings(existing) {
94
169
  }
95
170
  return { ...existing, hooks };
96
171
  }
97
- // The exact two sentences spec 005's "Disclosure" section requires, shown
98
- // at the moment that discharges status.md item 9's obligation for capture:
99
- // "before anyone outside the three of us is invited, and no later than the
100
- // day a worker is deployed, they need to be told the environment can lose
101
- // their data and that importing [or capturing] sends its contents to
102
- // Anthropic." Both facts, two sentences, nothing softened.
172
+ // Copies the shim's own compiled dist/*.js, next to a one-line ESM
173
+ // package.json, into a version-numbered directory under configDir --
174
+ // installed rather than run via npx (2.5s per hook, measured, on top of
175
+ // SessionStart now being synchronous and printing: spec 011 §6) and
176
+ // versioned rather than a single fixed path so an in-flight hook from the
177
+ // previous version is never overwritten mid-run by the next `enable`
178
+ // (pruneHookCopies below cleans up once settings.json has moved on).
179
+ // A no-op when sourceDir already *is* the copy -- enable() re-run from
180
+ // inside an already-installed copy has nothing left to do.
181
+ export function installHookCopy(sourceDir, configDir, version) {
182
+ const target = join(configDir, 'hook', version);
183
+ const indexPath = join(target, 'index.js').replace(/\\/g, '/'); // Claude Code spawns this directly -- see hookInvocation
184
+ if (resolve(sourceDir) === resolve(target))
185
+ return indexPath;
186
+ mkdirSync(target, { recursive: true });
187
+ for (const name of readdirSync(sourceDir)) {
188
+ if (name.endsWith('.js'))
189
+ copyFileSync(join(sourceDir, name), join(target, name));
190
+ }
191
+ writeFileSync(join(target, 'package.json'), '{"type":"module"}\n', 'utf8'); // dist/*.js is ESM
192
+ return indexPath;
193
+ }
194
+ // After settings.json points only at `keep`, every other copy under
195
+ // configDir/hook is stale disk. A directory Windows refuses to delete
196
+ // (EBUSY: an old hook process still running) is reported and left for the
197
+ // next enable to retry -- the entries already point elsewhere, so a copy
198
+ // left behind here is wasted space, never a wrong hook running.
199
+ export function pruneHookCopies(configDir, keep) {
200
+ const root = join(configDir, 'hook');
201
+ let names;
202
+ try {
203
+ names = readdirSync(root);
204
+ }
205
+ catch {
206
+ return;
207
+ }
208
+ for (const name of names) {
209
+ if (name === keep)
210
+ continue;
211
+ try {
212
+ rmSync(join(root, name), { recursive: true, force: true });
213
+ }
214
+ catch (err) {
215
+ console.error(`Could not remove the old hook copy ${join(root, name)} -- ${err.message}. ` +
216
+ `It will be removed by the next "capture enable".`);
217
+ }
218
+ }
219
+ }
220
+ // Exec form (spec 011 §6): Claude Code spawns command/args directly, no
221
+ // shell. The command is the Node that ran `enable`, by absolute path, not
222
+ // the bare word "node" -- Claude Code ships no Node of its own and a
223
+ // launcher's PATH is not guaranteed to have one on it either.
224
+ export function hookInvocation(execPath, indexPath) {
225
+ return { command: execPath.replace(/\\/g, '/'), args: [indexPath, 'hook'] };
226
+ }
227
+ // The first two sentences are spec 005's "Disclosure" section, shown at the
228
+ // moment that discharges status.md item 9's obligation for capture: "before
229
+ // anyone outside the three of us is invited, and no later than the day a
230
+ // worker is deployed, they need to be told the environment can lose their
231
+ // data and that importing [or capturing] sends its contents to Anthropic."
232
+ // Both facts, two sentences, nothing softened.
233
+ //
234
+ // The next three sentences are spec 011 §9 (A15, task 14), added because
235
+ // "captured content is sent" on its own reads as "everything on this
236
+ // machine is sent" -- not true, and the gap matters enough to name: typed
237
+ // text, replies and tool names go; the files an agent reads and the
238
+ // commands it runs stay local *unless the agent's own reply quotes them
239
+ // back* (the one path by which their content can still leave, so the
240
+ // carve-out has to say so rather than imply a cleaner boundary than the
241
+ // hook actually draws); reasoning is never sent, full stop. The next
242
+ // sentence previews enable()'s own third question (task 13, spec 011 §8):
243
+ // "the next question" is literal, not rhetorical -- renderBootstrapList
244
+ // (bootstrap.ts) is the list it points at, printed immediately after this
245
+ // disclosure and the "y" it gates, before that question is ever asked.
246
+ //
247
+ // The sixth sentence, appended by task 14 (spec 012 §9): everything above
248
+ // was written back when this shim only ran under Claude Code, and reads as
249
+ // a Claude-Code-only account of what "captured content" even is on a
250
+ // machine where `capture enable` now also wires up Codex (task 10, spec 012
251
+ // §4.6). Named rather than implied, because a person enabling capture on a
252
+ // Codex-only machine has no other way to learn that the same three hooks
253
+ // exist there too, or that installing the Waku plugin instead is the other
254
+ // route to the same events (codexPluginPresent, this file's own enable()).
103
255
  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.';
256
+ 'This is an alpha whose data can be lost. ' +
257
+ 'What you type, what the agent replies, and the names of the tools it uses are sent; ' +
258
+ 'the files it reads and the commands it runs are not, except where the agent quotes them in its reply, ' +
259
+ 'and its reasoning is never sent. ' +
260
+ 'Enabling can also import the memory and history Claude Code already keeps here; the next question lists exactly what. ' +
261
+ 'On Codex the same three hooks read ~/.codex/sessions, or the Waku plugin sends each turn as you go.';
105
262
  // Exported so cli.ts's usage text can point at the same URL without a
106
263
  // second copy of it drifting out of sync.
107
264
  //
@@ -113,6 +270,15 @@ export const DISCLOSURE = 'Captured content is sent to our servers and to Anthro
113
270
  // free to move now. tech.md calls the Vercel host an earlier one, kept and
114
271
  // not retired; this is the product's own address.
115
272
  export const KEYS_PAGE_URL = 'https://www.waku.one/account/keys';
273
+ // Shared by disable() and, since task 13, enable()'s own verify-fail
274
+ // refusal (controller ruling R14): both places are telling the user the
275
+ // exact same true thing -- a real credential is on disk even though this
276
+ // command did not finish -- so this is the one string, not two copies of it
277
+ // drifting apart the way the Codex refusal messages already warn against
278
+ // (see codexTomlRefusalMessage above).
279
+ function stillSavedMessage(configPath) {
280
+ return `Your API key is still saved at ${configPath}. Revoke it at ${KEYS_PAGE_URL} if you want to fully remove access.`;
281
+ }
116
282
  // Reads settings.json the same way applyToHarness (cli.ts) reads a harness
117
283
  // config: missing or blank is a blank slate (undefined here would wrongly
118
284
  // refuse the single most common first run -- no settings.json yet at all);
@@ -140,44 +306,486 @@ function readClaudeSettings(settingsPath) {
140
306
  return undefined;
141
307
  }
142
308
  }
309
+ // Reads ~/.codex/hooks.json the same way readClaudeSettings above reads
310
+ // settings.json: missing or blank is a blank slate (a fresh machine, or one
311
+ // where Codex itself has no hooks.json yet); anything non-blank that fails
312
+ // to parse, or does not parse to a plain object, is real corruption and
313
+ // returns undefined so enable() refuses rather than risks merging into --
314
+ // and overwriting -- a file it could not understand.
315
+ function readCodexHooksFile(path) {
316
+ let raw;
317
+ try {
318
+ raw = readFileSync(path, 'utf8');
319
+ }
320
+ catch {
321
+ return {};
322
+ }
323
+ if (raw.trim() === '')
324
+ return {};
325
+ try {
326
+ const parsed = JSON.parse(raw);
327
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
328
+ return undefined;
329
+ return parsed;
330
+ }
331
+ catch {
332
+ return undefined;
333
+ }
334
+ }
335
+ // Reads ~/.agents/plugins/marketplace.json. Missing or blank is `undefined`
336
+ // -- not `{}` -- because mergeMarketplaceEntry(undefined) is what builds the
337
+ // fresh personal marketplace (name, interface, our one plugin); passing `{}`
338
+ // instead would be merged into as if it were an existing, but nameless,
339
+ // file. Real corruption is its own 'invalid' outcome, distinct from
340
+ // undefined, so enable() can refuse on it without also refusing the far
341
+ // more common "nothing here yet" case.
342
+ function readMarketplaceFile(path) {
343
+ let raw;
344
+ try {
345
+ raw = readFileSync(path, 'utf8');
346
+ }
347
+ catch {
348
+ return undefined;
349
+ }
350
+ if (raw.trim() === '')
351
+ return undefined;
352
+ try {
353
+ const parsed = JSON.parse(raw);
354
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
355
+ return 'invalid';
356
+ return parsed;
357
+ }
358
+ catch {
359
+ return 'invalid';
360
+ }
361
+ }
362
+ // Ours if any group under any event is entirely ours -- mirrors isOwnGroup
363
+ // above, but over CodexHooksFile/isOwnCodexEntry: status() (below) needs to
364
+ // answer "did we write this file" without codex-config.ts having to export
365
+ // its own private isOwnCodexGroup.
366
+ function hasOwnCodexHooks(file) {
367
+ if (!file.hooks)
368
+ return false;
369
+ return Object.values(file.hooks).some((groups) => groups.some((g) => g.hooks.length > 0 && g.hooks.every(isOwnCodexEntry)));
370
+ }
371
+ // The one place that writes config.json -- enable() below (a pasted key)
372
+ // and, since task 12, login.ts (a minted one) both funnel through here so
373
+ // the file's shape and its permissions never drift between the two paths.
374
+ // {mode: 0o600} is a request, not a guarantee: Windows has no POSIX
375
+ // owner-only bit for writeFileSync to set, so this line is best-effort
376
+ // there and fully effective on Unix. Correct to pass on every platform
377
+ // regardless -- harmless where it cannot apply, the right thing where it
378
+ // can. Not routed through atomicWriteJson: that writer has no mode
379
+ // parameter, and this file is small, freshly created, and not something
380
+ // Claude Code itself reads concurrently the way settings.json is -- the
381
+ // atomic temp-file-plus-rename technique earns its complexity there, not
382
+ // here.
383
+ export function writeConfigJson(configDir, config) {
384
+ mkdirSync(configDir, { recursive: true }); // first run: ~/.waku-memory may not exist yet
385
+ const configPath = join(configDir, CONFIG_FILE_NAME);
386
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
387
+ }
388
+ // Only printable ASCII can be part of a bearer key. Non-printable
389
+ // characters at either *end* came from the terminal, not the person: on
390
+ // 2026-09-04 a Windows console delivered Ctrl+V's own control code (0x16)
391
+ // ahead of the key, `.trim()` left it in place, and every hook call then
392
+ // failed with an opaque "fetch failed" (undici refusing the Authorization
393
+ // header). Whitespace and non-ASCII (an IME's full-width space, say) are
394
+ // stripped from the ends for the same reason.
395
+ //
396
+ // Anything non-printable left *inside* is a different thing: the paste
397
+ // held more than the key ("Bearer mem_sk_..."), and stripping it would
398
+ // glue two tokens into one wrong key that looks right -- the 0.1.1 review
399
+ // reproduced exactly that, with the next hook's 401 landing in a
400
+ // background process nobody watches. So the interior count is returned,
401
+ // not repaired, and askForKey refuses on it.
402
+ export function sanitizeKey(raw) {
403
+ const printable = (c) => {
404
+ const code = c.charCodeAt(0);
405
+ return code >= 0x21 && code <= 0x7e;
406
+ };
407
+ const chars = [...raw];
408
+ let start = 0;
409
+ let end = chars.length;
410
+ while (start < end && !printable(chars[start]))
411
+ start++;
412
+ while (end > start && !printable(chars[end - 1]))
413
+ end--;
414
+ const kept = chars.slice(start, end);
415
+ return {
416
+ key: kept.join(''),
417
+ removed: chars.length - kept.length,
418
+ interior: kept.filter((c) => !printable(c)).length,
419
+ };
420
+ }
421
+ const KEY_ATTEMPTS = 3;
422
+ // What every key minted at KEYS_PAGE_URL starts with (api_key_repository.py:
423
+ // PREFIX + token_urlsafe(32)). A key without it is warned about, not
424
+ // refused: the server's opinion is the only one that counts, and a refusal
425
+ // here would make a future prefix change strand every user on this shim.
426
+ const KEY_PREFIX = 'mem_sk_';
427
+ // The sanitizeKey checks and their messages, factored out of askForKey so
428
+ // resolveCredential's `--key` path (fix round 1, finding 3) can apply the
429
+ // exact same rules to a key that arrived on the command line instead of
430
+ // through a prompt: a flag is not "typed", but it crosses a shell boundary
431
+ // the same way a paste crosses a terminal one, and it is exactly as capable
432
+ // of carrying a leading Ctrl+V byte or a "Bearer " prefix pasted by
433
+ // mistake -- a flag used to skip these checks entirely (finding 3's bug).
434
+ // `printEmptyHint` exists only to match askForKey's own choice to suppress
435
+ // the "paste it again" hint on its last attempt (no more attempts are
436
+ // coming); every one-shot caller (the `--key` path) passes false for the
437
+ // same reason -- there is no re-ask to hint toward. No branch below ever
438
+ // prints the key or any part of it -- counts and a prefix only.
439
+ function checkPastedKey(raw, printEmptyHint) {
440
+ const { key, removed, interior } = sanitizeKey(raw);
441
+ if (interior > 0) {
442
+ console.log(`The pasted text has ${interior} character(s) inside it that cannot be part of a key -- ` +
443
+ `more than the key was pasted. Paste the key itself, nothing else.`);
444
+ return null;
445
+ }
446
+ if (key === '') {
447
+ if (printEmptyHint) {
448
+ console.log('That did not contain a usable key -- paste the key itself, nothing else.');
449
+ }
450
+ return null;
451
+ }
452
+ if (removed > 0) {
453
+ console.log(`Removed ${removed} non-printable character(s) from the ends of the pasted key -- ` +
454
+ `a terminal can add Ctrl+V's own code ahead of what it pastes.`);
455
+ }
456
+ if (!key.startsWith(KEY_PREFIX)) {
457
+ console.log(`Note: the key does not start with ${KEY_PREFIX}, which every key minted at ${KEYS_PAGE_URL} does. ` +
458
+ `Saving it as pasted; if the hook reports 401, run this command again with a fresh key.`);
459
+ }
460
+ return key;
461
+ }
462
+ // Asks up to KEY_ATTEMPTS times. null means the person never pasted anything
463
+ // usable, and enable() then ends with the same "nothing was written"
464
+ // guarantee that declining the disclosure carries.
465
+ async function askForKey(deps) {
466
+ for (let attempt = 1; attempt <= KEY_ATTEMPTS; attempt++) {
467
+ const raw = await deps.prompt(`Paste an API key from ${KEYS_PAGE_URL} (it is shown once, at mint time): `);
468
+ const key = checkPastedKey(raw, attempt < KEY_ATTEMPTS);
469
+ if (key !== null)
470
+ return key;
471
+ }
472
+ return null;
473
+ }
474
+ // The Sign-in choice (spec 012 §7, controller ruling R13): the first thing
475
+ // enable() asks once the disclosure gate is past, whenever `credential` is
476
+ // { kind: 'ask' } and config.json holds no usable key yet (readStoredKey
477
+ // below). Verbatim -- capture.test.mjs and cli.test.mjs's usage text both
478
+ // depend on this exact string, so a wording change here is a break of that
479
+ // contract, not a cosmetic edit.
480
+ export const SIGN_IN_QUESTION = 'Sign in [1] in the browser (recommended) [2] by pasting a key: ';
481
+ // Same "stop pestering the person" budget askForKey's own KEY_ATTEMPTS
482
+ // gives a pasted key -- an answer that is neither "1" nor "2" three times
483
+ // running is treated the same as declining outright.
484
+ const SIGN_IN_ATTEMPTS = 3;
485
+ // Reads just the `key` field out of config.json, the way hook.ts's own
486
+ // readConfig reads {url, key} for the hook's real network calls: a
487
+ // non-empty string or nothing usable at all (missing file, malformed JSON,
488
+ // a `key` that is absent, empty, or not a string) -- R13's "already holds a
489
+ // usable key" test, and runLogin's own read-back of what deps.login() just
490
+ // wrote. Never prints what it finds.
491
+ function readStoredKey(configDir) {
492
+ try {
493
+ const parsed = JSON.parse(readFileSync(join(configDir, CONFIG_FILE_NAME), 'utf8'));
494
+ if (parsed !== null && typeof parsed === 'object') {
495
+ const key = parsed.key;
496
+ if (typeof key === 'string' && key.length > 0)
497
+ return key;
498
+ }
499
+ }
500
+ catch {
501
+ // Missing, unreadable, or not valid JSON -- no stored key.
502
+ }
503
+ return null;
504
+ }
505
+ // deps.login() is login.ts's login(): it already wrote config.json itself
506
+ // by the time it returns 'signed-in' (writeConfigJson, sanitizeKey -- both
507
+ // this file's own), so the key is read back here rather than threaded
508
+ // across the deps boundary a second way. A non-'signed-in' result
509
+ // ('timeout' or 'refused') means login() never reached that write --
510
+ // nothing is on disk, and the one sentence R14 gives verbatim is accurate.
511
+ // A 'signed-in' result whose key cannot be read back -- config.json is
512
+ // missing, unreadable, malformed, or its `key` field is empty or not a
513
+ // string; see readStoredKey -- is a refusal, not a silent empty-key
514
+ // continue: fix round 1 finding 2 found the old code here (`key: key ??
515
+ // ''`) letting enable() sail on through installHookCopy/verifyHook/
516
+ // writeSettingsJson with no working credential on disk and nothing printed.
517
+ async function runLogin(deps) {
518
+ const result = await deps.login();
519
+ if (result !== 'signed-in') {
520
+ console.log('Capture not enabled -- sign-in did not complete; nothing was written.');
521
+ return 'declined';
522
+ }
523
+ const key = readStoredKey(deps.configDir);
524
+ if (key === null) {
525
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
526
+ console.error(`Signed in, but the key could not be read back from ${configPath} -- nothing else was written.`);
527
+ return 'refused';
528
+ }
529
+ return { key, skipWriteConfig: true };
530
+ }
531
+ // The credential decision itself (controller rulings R13/R14). `credential`
532
+ // is enable()'s own third parameter, not a CaptureDeps field -- see
533
+ // Credential's own comment for why -- and is `undefined` only for the
534
+ // legacy call shape every enable() call predating task 13 used (a bare
535
+ // `enable(url, deps)`, still exactly what every test written before this
536
+ // task passes): that path is untouched, byte for byte, from what askForKey
537
+ // alone did before this function existed, which is what keeps those tests
538
+ // green without editing a single one of them.
539
+ // - undefined: ask for a pasted key directly (askForKey, unchanged).
540
+ // - { kind: 'key' }: nothing is asked, but the value still goes through
541
+ // checkPastedKey (fix round 1, finding 3) -- a flag is not typed, but it
542
+ // is exactly as capable of carrying a stray leading byte or an empty
543
+ // string as a paste is, and it used to skip validation entirely.
544
+ // - { kind: 'login' }: deps.login() runs immediately -- nothing is asked.
545
+ // - { kind: 'ask' }: a stored, usable key short-circuits everything with
546
+ // one line and no question (R13); otherwise the Sign-in choice is
547
+ // asked, up to SIGN_IN_ATTEMPTS times, and "1"/"2" branch into
548
+ // login()/askForKey() respectively. A third invalid answer declines,
549
+ // the same shape askForKey's own attempts loop already has.
550
+ async function resolveCredential(deps, credential) {
551
+ if (credential === undefined) {
552
+ const key = await askForKey(deps);
553
+ if (key === null) {
554
+ console.log('Capture not enabled -- no usable key was entered; nothing was written.');
555
+ return 'declined';
556
+ }
557
+ return { key, skipWriteConfig: false };
558
+ }
559
+ if (credential.kind === 'key') {
560
+ // No re-ask: a flag is not interactive, so a bad --key declines
561
+ // outright (printEmptyHint: false) instead of prompting for another one.
562
+ const key = checkPastedKey(credential.key, false);
563
+ if (key === null) {
564
+ console.log('Capture not enabled -- no usable key was entered; nothing was written.');
565
+ return 'declined';
566
+ }
567
+ return { key, skipWriteConfig: false };
568
+ }
569
+ if (credential.kind === 'login') {
570
+ return runLogin(deps);
571
+ }
572
+ // credential.kind === 'ask'
573
+ const stored = readStoredKey(deps.configDir);
574
+ if (stored !== null) {
575
+ console.log(`Using the key already stored in ${deps.configDir}/${CONFIG_FILE_NAME}.`);
576
+ return { key: stored, skipWriteConfig: false };
577
+ }
578
+ for (let attempt = 1; attempt <= SIGN_IN_ATTEMPTS; attempt++) {
579
+ const answer = (await deps.prompt(SIGN_IN_QUESTION)).trim();
580
+ if (answer === '1')
581
+ return runLogin(deps);
582
+ if (answer === '2') {
583
+ const key = await askForKey(deps);
584
+ if (key === null) {
585
+ console.log('Capture not enabled -- no usable key was entered; nothing was written.');
586
+ return 'declined';
587
+ }
588
+ return { key, skipWriteConfig: false };
589
+ }
590
+ if (attempt < SIGN_IN_ATTEMPTS)
591
+ console.log('Enter 1 or 2.');
592
+ }
593
+ console.log('Capture not enabled -- no valid answer was given; nothing was written.');
594
+ return 'declined';
595
+ }
596
+ // A parsed BootstrapAnswer still carries `invalid`; runBootstrap's own
597
+ // BootstrapSelection does not (its three real outcomes are `all`, `skip` and
598
+ // `exclude`) -- askBootstrapSelection's own re-ask loop below is where a
599
+ // still-invalid second answer becomes `skip`, so by the time this is called
600
+ // that case has already been decided; this is a plain, total mapping, never
601
+ // itself a decision point.
602
+ function toSelection(answer) {
603
+ if (answer.kind === 'exclude')
604
+ return { kind: 'exclude', numbers: answer.numbers };
605
+ if (answer.kind === 'all')
606
+ return { kind: 'all' };
607
+ return { kind: 'skip' }; // 'skip', or a still-invalid second answer
608
+ }
609
+ // The third question, spec 011 §8: asks '> ' once, and on an invalid answer
610
+ // prints the one-line hint and asks once more -- a second invalid answer is
611
+ // treated as skip rather than asked a third time, the same "stop pestering
612
+ // the person" reasoning A12's own parseBootstrapAnswer comment gives for
613
+ // only ever re-asking once.
614
+ async function askBootstrapSelection(projectCount, prompt) {
615
+ let answer = parseBootstrapAnswer(await prompt('> '), projectCount);
616
+ if (answer.kind === 'invalid') {
617
+ console.log('Enter, numbers, or n.');
618
+ answer = parseBootstrapAnswer(await prompt('> '), projectCount);
619
+ }
620
+ return toSelection(answer);
621
+ }
622
+ // The Codex TOML refusal, worded the same way harnesses.ts's mergeMcpConfig
623
+ // refuses a conflicting Claude Code entry (its own comment there: "someone
624
+ // configured this by hand ... silently replacing it would undo a deliberate
625
+ // choice with no trace"). mergeCodexToml's 'refused' result does not carry
626
+ // the conflicting url back (only the unchanged text), so this cannot name it
627
+ // the way mergeMcpConfig's thrown message does -- naming the table and the
628
+ // file it lives in is the closest equivalent.
629
+ function codexTomlRefusalMessage(codexConfigPath) {
630
+ return (`[mcp_servers.waku] in ${codexConfigPath} is already configured with a different URL. ` +
631
+ `Remove it or edit the file by hand.`);
632
+ }
143
633
  // The one gesture that turns capture on. Order is deliberate and each step
144
634
  // depends on the one before it having succeeded:
145
- // 1. refuse if Claude Code itself is not on this machine -- the directory
146
- // is the evidence, the shim's own edit-don't-create rule adapted from
147
- // cli.ts's harness handling;
148
- // 2. refuse if the existing settings.json cannot be safely read -- checked
149
- // here, before the disclosure, so a machine this can never work on
150
- // fails fast instead of making the user type through a consent flow
151
- // first only to hit a wall at the end;
152
- // 3. print the disclosure and demand a typed "y" -- before anything else,
635
+ // 1. refuse if neither harness is on this machine -- Claude Code's
636
+ // directory or Codex's config.toml is the evidence, the shim's own
637
+ // edit-don't-create rule adapted from cli.ts's harness handling. Widened
638
+ // from "Claude Code only" (spec 012 §4/§4.6, task 10): a Codex-only
639
+ // machine is now a real target, not a refusal.
640
+ // 2. when claudeDir exists, refuse if the existing settings.json cannot be
641
+ // safely read -- checked here, before the disclosure, so a machine this
642
+ // can never work on fails fast instead of making the user type through
643
+ // a consent flow first only to hit a wall at the end. Skipped
644
+ // altogether on a Codex-only machine: there is no settings.json to
645
+ // protect.
646
+ // 3. when codexConfigPath exists, detect the plugin (codexPluginPresent).
647
+ // If it is not there, compute the TOML merge (mergeCodexToml) and read
648
+ // hooks.json and marketplace.json -- all before the disclosure, for the
649
+ // same reason as step 2: a refusal (a foreign [mcp_servers.waku] table,
650
+ // or either file unreadable) must cost the user nothing and write
651
+ // nothing, exactly like a Claude Code refusal. If the plugin *is*
652
+ // there, none of this runs: it already carries the hooks and the MCP
653
+ // server, so writing our own beside it would be redundant at best and a
654
+ // second, conflicting route to the same events at worst -- the plugin
655
+ // wins, and step 8 below prints that instead of touching either file;
656
+ // 4. print the disclosure and demand a typed "y" -- before anything else,
153
657
  // including asking for the key, so declining costs the user nothing
154
658
  // and asks for nothing;
155
- // 4. only then ask for the key;
156
- // 5. write config.json, then merge and write settings.json. Everything
157
- // before this point (refusing, declining) writes nothing at all --
158
- // that guarantee is real. This step itself is not atomic across the
159
- // two files, though: config.json is committed to disk first, so a
160
- // failure on the settings.json write right after -- disk full, a
161
- // permission error -- does leave a real credential saved with no
162
- // hook actually installed, i.e. capture silently not active despite
163
- // config.json existing. Accepted rather than guarded against (no
164
- // rollback, no pre-write dry run): the failure still propagates out
165
- // of enable() to cli.ts's runCaptureCommand, which reports it and
166
- // sets a nonzero exit code, so it is not silent; it fails toward
167
- // "capture not enabled" rather than a consent bypass; and re-running
168
- // `enable` repairs it, since writing config.json again is a plain
169
- // overwrite (capture.test.mjs's "running twice" test).
170
- export async function enable(url, deps) {
171
- if (!existsSync(deps.claudeDir)) {
172
- console.error(`Claude Code not found at ${deps.claudeDir} -- install Claude Code first, then run ` +
173
- `"npx waku-memory capture enable" again.`);
659
+ // 5. only then resolve the credential -- the Sign-in question, deps.login(),
660
+ // askForKey, or the already-known --key value, decided by `credential`
661
+ // (task 13, spec 012 §7, resolveCredential's own comment has the full
662
+ // decision table);
663
+ // 6. install a local copy of the shim under configDir/hook/<version> and
664
+ // run it for real -- verifyHook -- before anything is wired into
665
+ // settings.json. A copy that does not even start (a corrupted copy, a
666
+ // Node too old for it, ...) must never become the thing Claude Code
667
+ // calls on every turn, so a failed verification removes the copy this
668
+ // call made under configDir/hook/<version> (and configDir/hook itself,
669
+ // if that copy was the only thing under it) and refuses right here,
670
+ // before config.json or settings.json is ever touched. A refusal
671
+ // removes only a copy this call made, though: installHookCopy is a
672
+ // no-op when hookSourceDir already *is* configDir/hook/<version> --
673
+ // enable running from inside the copy already installed there, which
674
+ // is what hookSourceDir (derived from the running cli.js's own
675
+ // import.meta.url) gives once a copy is in place -- and then this call
676
+ // installed nothing, so a failed verification leaves that directory
677
+ // alone: it may be the very copy settings.json still points at from an
678
+ // earlier, successful enable, and it is not this call's to delete.
679
+ // installHookCopy's mkdirSync(..., { recursive: true }) creates every
680
+ // ancestor directory on the way down, though, so the one thing a
681
+ // first-ever run can still leave behind after refusing here is an
682
+ // empty configDir -- not removed, since it is the config directory
683
+ // itself, not anything this step wrote into it;
684
+ // 7. write config.json, then (when claudeDir exists) merge and write
685
+ // settings.json with the now-verified invocation. Everything before
686
+ // this point (refusing, declining, a failed verification) writes
687
+ // nothing new to disk -- step 6's own cleanup is what makes that true
688
+ // when a failed verification follows a real copy, down to the
689
+ // empty-configDir exception described there; when installHookCopy was
690
+ // instead a no-op, this call had nothing of its own to remove, and the
691
+ // pre-existing copy it leaves in place was never new. This step itself
692
+ // is not atomic across the two files, though: config.json is committed
693
+ // to disk first, so a failure on the settings.json write right after --
694
+ // disk full, a permission error -- does leave a real credential saved
695
+ // with no hook actually installed, i.e. capture silently not active
696
+ // despite config.json existing. Accepted rather than guarded against
697
+ // (no rollback, no pre-write dry run): the failure still propagates out
698
+ // of enable() to cli.ts's runCaptureCommand, which reports it and sets
699
+ // a nonzero exit code, so it is not silent; it fails toward "capture
700
+ // not enabled" rather than a consent bypass; and re-running `enable`
701
+ // repairs it, since writing config.json again is a plain overwrite
702
+ // (capture.test.mjs's "running twice" test);
703
+ // 8. when codexConfigPath exists: print the plugin sentence and touch
704
+ // neither Codex file if the plugin is present; otherwise write the TOML
705
+ // table (only when step 3's merge actually changed it -- 'unchanged'
706
+ // writes nothing), merge and write hooks.json unconditionally (the same
707
+ // idempotent replace-ours-keep-foreign write settings.json always gets),
708
+ // and write the marketplace file only when mergeMarketplaceEntry says
709
+ // it changed;
710
+ // 9. prune every other hook/<version> copy now that settings.json (and, on
711
+ // Codex, hooks.json) point only at this one (pruneHookCopies) --
712
+ // best-effort, never fatal, and only after both writes so a prune
713
+ // failure can never leave either file pointing at a copy this step just
714
+ // deleted.
715
+ // 10. when deps.bootstrap.enabled (task 13, spec 011 §8): scan the machine
716
+ // for Claude Code's own memory files and transcripts (scanClaudeCode) --
717
+ // skipped, with an empty scan standing in, when claudeDir does not
718
+ // exist (task 11 replaces this with a scan over both harnesses; this
719
+ // task only has to keep a Codex-only machine from crashing on a scan
720
+ // path that is not there) -- and, only if that scan found something,
721
+ // print the one numbered list and ask the third and last question. This
722
+ // runs after the hooks are wired in and verified, deliberately:
723
+ // bootstrap is additional value once capture itself is real, not a
724
+ // precondition for it, and its own failure (a bad answer, a network
725
+ // error inside runBootstrap) must never undo a successful enable -- so
726
+ // nothing below this point can turn 'enabled' into anything else. An
727
+ // empty scan (nothing found, no user CLAUDE.md) says so and asks
728
+ // nothing at all: the person who has nothing to import should not be
729
+ // asked whether to skip importing it.
730
+ // 11. the trust line, last, only when this run actually wrote Codex's
731
+ // hooks.json (step 8's non-plugin branch): Codex will not run a hook it
732
+ // has not seen a person approve, so a run that wired the entries in but
733
+ // never said so would read as done when one more step remains.
734
+ export async function enable(url, deps, credential) {
735
+ const claudeDirExists = existsSync(deps.claudeDir);
736
+ const codexConfigExists = existsSync(deps.codexConfigPath);
737
+ if (!claudeDirExists && !codexConfigExists) {
738
+ console.error(`Neither Claude Code (${deps.claudeDir}) nor Codex (${deps.codexConfigPath}) was found on this machine -- ` +
739
+ `install one of them first, then run "npx waku-memory capture enable" again.`);
174
740
  return 'refused';
175
741
  }
176
- const existingSettings = readClaudeSettings(deps.settingsPath);
177
- if (existingSettings === undefined) {
178
- console.error(`${deps.settingsPath}'s content is not something this tool can safely merge into -- ` +
179
- `leaving it untouched. Nothing was written.`);
180
- return 'refused';
742
+ let existingSettings = {};
743
+ if (claudeDirExists) {
744
+ const read = readClaudeSettings(deps.settingsPath);
745
+ if (read === undefined) {
746
+ console.error(`${deps.settingsPath}'s content is not something this tool can safely merge into -- ` +
747
+ `leaving it untouched. Nothing was written.`);
748
+ return 'refused';
749
+ }
750
+ existingSettings = read;
751
+ }
752
+ // Step 3: everything Codex needs decided and checked before the
753
+ // disclosure -- see this function's own comment above for why. Left at
754
+ // their blank-slate defaults (false / undefined / {}) when codexConfigPath
755
+ // does not exist or the plugin is already present; step 8 below only reads
756
+ // codexTomlMerge/codexHooksFile/codexMarketplaceFile inside the branch
757
+ // where they were actually computed.
758
+ let codexPluginPresentHere = false;
759
+ let codexTomlMerge;
760
+ let codexHooksFile = {};
761
+ let codexMarketplaceFile;
762
+ if (codexConfigExists) {
763
+ const tomlText = readFileSync(deps.codexConfigPath, 'utf8');
764
+ codexPluginPresentHere = codexPluginPresent(tomlText, deps.codexPluginsCacheDir);
765
+ if (!codexPluginPresentHere) {
766
+ // deps.mcpUrl, not `url`: `url` is the ingest base (config.json's own
767
+ // value in real use), and the TOML's [mcp_servers.waku] table needs
768
+ // the MCP endpoint instead (spec 012 §4.2) -- see CaptureDeps.mcpUrl.
769
+ codexTomlMerge = mergeCodexToml(tomlText, deps.mcpUrl);
770
+ if (codexTomlMerge.result === 'refused') {
771
+ console.error(codexTomlRefusalMessage(deps.codexConfigPath));
772
+ return 'refused';
773
+ }
774
+ const readHooks = readCodexHooksFile(deps.codexHooksPath);
775
+ if (readHooks === undefined) {
776
+ console.error(`${deps.codexHooksPath}'s content is not something this tool can safely merge into -- ` +
777
+ `leaving it untouched. Nothing was written.`);
778
+ return 'refused';
779
+ }
780
+ codexHooksFile = readHooks;
781
+ const readMarketplace = readMarketplaceFile(deps.marketplacePath);
782
+ if (readMarketplace === 'invalid') {
783
+ console.error(`${deps.marketplacePath}'s content is not something this tool can safely merge into -- ` +
784
+ `leaving it untouched. Nothing was written.`);
785
+ return 'refused';
786
+ }
787
+ codexMarketplaceFile = readMarketplace;
788
+ }
181
789
  }
182
790
  console.log(DISCLOSURE);
183
791
  console.log('');
@@ -186,46 +794,281 @@ export async function enable(url, deps) {
186
794
  console.log('Capture not enabled -- nothing was written.');
187
795
  return 'declined';
188
796
  }
189
- const key = (await deps.prompt(`Paste an API key from ${KEYS_PAGE_URL} (it is shown once, at mint time): `)).trim();
190
- mkdirSync(deps.configDir, { recursive: true }); // first run: ~/.waku-memory may not exist yet
797
+ const credentialResult = await resolveCredential(deps, credential);
798
+ if (credentialResult === 'declined')
799
+ return 'declined';
800
+ // Fix round 1, finding 2: signed-in but config.json came back unreadable
801
+ // -- a bug, not "the person declined" -- so this is refused before ever
802
+ // reaching installHookCopy, exactly like every other pre-flight refusal
803
+ // above in this function.
804
+ if (credentialResult === 'refused')
805
+ return 'refused';
806
+ const { key, skipWriteConfig } = credentialResult;
191
807
  const configPath = join(deps.configDir, CONFIG_FILE_NAME);
192
- // {mode: 0o600} is a request, not a guarantee: Windows has no POSIX
193
- // owner-only bit for writeFileSync to set, so this line is best-effort
194
- // there and fully effective on Unix. Correct to pass on every platform
195
- // regardless -- harmless where it cannot apply, the right thing where it
196
- // can. Not routed through atomicWriteJson: that writer has no mode
197
- // parameter, and this file is small, freshly created, and not something
198
- // Claude Code itself reads concurrently the way settings.json is -- the
199
- // atomic temp-file-plus-rename technique earns its complexity there, not
200
- // here.
201
- writeFileSync(configPath, JSON.stringify({ url, key }, null, 2) + '\n', { mode: 0o600 });
202
- const merged = mergeHookSettings(existingSettings);
203
- deps.writeSettingsJson(deps.settingsPath, merged);
808
+ const indexPath = installHookCopy(deps.hookSourceDir, deps.configDir, deps.version);
809
+ const run = hookInvocation(deps.execPath, indexPath);
810
+ if (!deps.verifyHook(run)) {
811
+ // Undo the copy this call just installed -- but only if it made one.
812
+ // installHookCopy no-ops when hookSourceDir already *is* the target
813
+ // directory (enable re-run from inside the installed copy); deleting
814
+ // the target in that case would remove a pre-existing directory this
815
+ // call never wrote, possibly the very copy settings.json still points
816
+ // at from an earlier, successful enable.
817
+ if (resolve(deps.hookSourceDir) !== resolve(dirname(indexPath))) {
818
+ rmSync(dirname(indexPath), { recursive: true, force: true });
819
+ const hookRoot = join(deps.configDir, 'hook');
820
+ try {
821
+ if (readdirSync(hookRoot).length === 0)
822
+ rmSync(hookRoot, { recursive: true, force: true });
823
+ }
824
+ catch {
825
+ // hookRoot is already gone -- nothing left to prune
826
+ }
827
+ }
828
+ if (skipWriteConfig) {
829
+ // Controller ruling R14: the credential came from deps.login(), which
830
+ // already wrote config.json with a real, minted key before this run
831
+ // ever reached installHookCopy -- unlike every other credential path,
832
+ // where config.json is not written until after verifyHook succeeds
833
+ // (below). "nothing was written" would be false here, so this shares
834
+ // disable()'s own sentence (stillSavedMessage) instead of a second
835
+ // copy of it.
836
+ console.error(`The hook entry did not run (${run.command} ${(run.args ?? []).join(' ')}).`);
837
+ console.error(stillSavedMessage(configPath));
838
+ }
839
+ else {
840
+ console.error(`The hook entry did not run (${run.command} ${(run.args ?? []).join(' ')}) -- ` +
841
+ `nothing was written.`);
842
+ }
843
+ return 'refused';
844
+ }
845
+ if (!skipWriteConfig)
846
+ writeConfigJson(deps.configDir, { url, key });
847
+ if (claudeDirExists) {
848
+ const merged = mergeHookSettings(existingSettings, run);
849
+ deps.writeSettingsJson(deps.settingsPath, merged);
850
+ }
851
+ // Step 8: the Codex writes -- or, when the plugin already carries them,
852
+ // exactly one sentence and neither file touched. codexHooksWrittenThisRun
853
+ // gates the trust line (step 11): true whenever hooks.json was actually
854
+ // written this run, which -- unlike the TOML table and the marketplace
855
+ // entry, both conditioned on their own merge having changed anything -- is
856
+ // every time this branch runs at all (mergeCodexHooks's replace-ours
857
+ // semantics write the file even when the entries end up identical, the
858
+ // same way settings.json above always gets written).
859
+ let codexHooksWrittenThisRun = false;
860
+ if (codexConfigExists) {
861
+ if (codexPluginPresentHere) {
862
+ console.log('The Waku plugin carries the hooks and the MCP server for Codex; enable adds login and the history import');
863
+ }
864
+ else {
865
+ if (codexTomlMerge.result === 'wrote') {
866
+ deps.writeText(deps.codexConfigPath, codexTomlMerge.text);
867
+ }
868
+ const mergedHooks = mergeCodexHooks(codexHooksFile, indexPath);
869
+ deps.writeJsonFile(deps.codexHooksPath, mergedHooks);
870
+ codexHooksWrittenThisRun = true;
871
+ const marketplaceMerge = mergeMarketplaceEntry(codexMarketplaceFile);
872
+ if (marketplaceMerge.result === 'wrote') {
873
+ deps.writeJsonFile(deps.marketplacePath, marketplaceMerge.data);
874
+ }
875
+ }
876
+ }
877
+ pruneHookCopies(deps.configDir, deps.version);
204
878
  console.log('');
205
- console.log(`Capture enabled -- wrote ${configPath} and merged hooks into ${deps.settingsPath}.`);
206
- console.log('Start a new Claude Code session to pick up the hooks.');
879
+ const wrote = [configPath];
880
+ if (claudeDirExists)
881
+ wrote.push(deps.settingsPath);
882
+ if (codexConfigExists && !codexPluginPresentHere) {
883
+ if (codexTomlMerge.result === 'wrote')
884
+ wrote.push(deps.codexConfigPath);
885
+ wrote.push(deps.codexHooksPath);
886
+ }
887
+ console.log(`Capture enabled -- wrote ${wrote.join(', ')}.`);
888
+ if (claudeDirExists)
889
+ console.log('Start a new Claude Code session to pick up the hooks.');
890
+ if (deps.bootstrap.enabled) {
891
+ const { windowDays } = deps.bootstrap;
892
+ const stateDir = join(deps.configDir, 'state');
893
+ // task 11 (spec 012 §8): scanAll(null, ...) / scanAll(..., null) means
894
+ // "this harness is not on this machine" -- the same evidence
895
+ // (claudeDirExists / codexConfigExists) already used above to decide
896
+ // whether to touch each harness's own config at all.
897
+ const scan = scanAll(claudeDirExists ? deps.claudeDir : null, codexConfigExists ? dirname(deps.codexConfigPath) : null, stateDir, {
898
+ nowMs: Date.now(),
899
+ sinceMs: windowDays === null ? 0 : Date.now() - windowDays * 86_400_000,
900
+ liveWindowMs: LIVE_WINDOW_MS,
901
+ all: windowDays === null,
902
+ });
903
+ console.log('');
904
+ // renderBootstrapList already returns the one "nothing found" sentence
905
+ // for an empty scan (A12) -- reusing it here, rather than a second copy
906
+ // of that literal string, is what keeps the two from drifting apart.
907
+ // The question itself is skipped on exactly the same condition: a
908
+ // person with nothing to import should not be asked whether to skip it.
909
+ console.log(renderBootstrapList(scan, windowDays));
910
+ if (scan.projects.length > 0 || scan.userClaudeMd !== null) {
911
+ const selection = await askBootstrapSelection(scan.projects.length, deps.prompt);
912
+ const result = await runBootstrap(scan, selection, {
913
+ url,
914
+ key,
915
+ fetchImpl: deps.fetchImpl,
916
+ stateDir,
917
+ all: windowDays === null,
918
+ });
919
+ let line = `${result.memoryFiles} memory files queued, ${result.sessions} sessions queued in ${result.pieces} pieces`;
920
+ if (result.failed > 0)
921
+ line += `, ${result.failed} failed`;
922
+ console.log(line);
923
+ }
924
+ }
925
+ if (codexHooksWrittenThisRun) {
926
+ console.log('');
927
+ console.log("Codex runs new hooks only after you trust them: open /hooks in Codex and trust Waku's three entries.");
928
+ }
207
929
  return 'enabled';
208
930
  }
209
- // Removes exactly what enable() added and nothing else. Deliberately leaves
210
- // config.json in place: the key is the user's to revoke on the keys page,
211
- // not this tool's to delete -- disabling capture and revoking the
212
- // credential are two different decisions, and conflating them would make
213
- // "disable" destructive in a way its name does not promise.
931
+ // disable() takes no --url flag (never has -- see disable()'s own comment),
932
+ // so there is nothing on argv to derive the Codex MCP url from the way
933
+ // dispatch() derives it for enable(). The one record of what enable()
934
+ // actually used is config.json's own `url` field: whatever ingest base a
935
+ // person passed to "capture enable --url ..." (or the default, if they
936
+ // passed none) is exactly what got written there, and urlPair() rebuilds
937
+ // the matching mcpUrl from it the same way dispatch() built it forwards
938
+ // (fix round 2, re-review finding #2, controller ruling R9). Falling back to
939
+ // deps.mcpUrl (the default) only when config.json is missing, unreadable, or
940
+ // carries no usable url string -- a machine where the credential file was
941
+ // hand-deleted, or never existed because enable() itself failed partway --
942
+ // is a *best-effort* guess, not a promise: it can only be right when the
943
+ // machine was enabled with the default url in the first place, but it is no
944
+ // worse than fix round 1's own always-use-deps.mcpUrl behavior for that one
945
+ // case, and strictly better for every other one.
946
+ function resolveDisableMcpUrl(configDir, fallback) {
947
+ try {
948
+ const parsed = JSON.parse(readFileSync(join(configDir, CONFIG_FILE_NAME), 'utf8'));
949
+ if (parsed !== null && typeof parsed === 'object') {
950
+ const storedUrl = parsed.url;
951
+ if (typeof storedUrl === 'string' && storedUrl.length > 0)
952
+ return urlPair(storedUrl).mcpUrl;
953
+ }
954
+ }
955
+ catch {
956
+ // Missing, unreadable, or not valid JSON -- fall back below.
957
+ }
958
+ return fallback;
959
+ }
960
+ // Removes exactly what enable() added and nothing else, from both harnesses
961
+ // (ruling R6, task 10): Claude Code's hook entries when claudeDir exists
962
+ // (unchanged from before this task), Codex's hooks.json entries and its
963
+ // [mcp_servers.waku] TOML table when codexConfigPath exists. Deliberately
964
+ // leaves config.json and the marketplace entry in place in every case: the
965
+ // key is the user's to revoke on the keys page, not this tool's to delete --
966
+ // disabling capture and revoking the credential are two different
967
+ // decisions, and conflating them would make "disable" destructive in a way
968
+ // its name does not promise; the marketplace entry only makes the plugin
969
+ // *available*, the same reasoning applied one level further out.
214
970
  export async function disable(deps) {
971
+ const codexConfigExists = existsSync(deps.codexConfigPath);
215
972
  const existingSettings = readClaudeSettings(deps.settingsPath);
216
973
  if (existingSettings === undefined) {
217
974
  console.error(`${deps.settingsPath}'s content is not something this tool can safely modify -- leaving it untouched.`);
218
975
  return 'refused';
219
976
  }
220
- const removed = removeHookSettings(existingSettings);
221
- if (isDeepStrictEqual(removed, existingSettings)) {
977
+ let codexHooksFile = {};
978
+ if (codexConfigExists) {
979
+ const read = readCodexHooksFile(deps.codexHooksPath);
980
+ if (read === undefined) {
981
+ console.error(`${deps.codexHooksPath}'s content is not something this tool can safely modify -- leaving it untouched.`);
982
+ return 'refused';
983
+ }
984
+ codexHooksFile = read;
985
+ }
986
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
987
+ let changed = false;
988
+ const removedSettings = removeHookSettings(existingSettings);
989
+ if (!isDeepStrictEqual(removedSettings, existingSettings)) {
990
+ deps.writeSettingsJson(deps.settingsPath, removedSettings);
991
+ console.log(`Capture disabled -- removed the hook entries from ${deps.settingsPath}.`);
992
+ changed = true;
993
+ }
994
+ if (codexConfigExists) {
995
+ const removedHooks = removeCodexHooks(codexHooksFile);
996
+ const hooksChanged = !isDeepStrictEqual(removedHooks, codexHooksFile);
997
+ if (hooksChanged) {
998
+ deps.writeJsonFile(deps.codexHooksPath, removedHooks);
999
+ }
1000
+ // removeCodexToml only removes the table when its url is ours --
1001
+ // resolveDisableMcpUrl (fix round 2, spec 012 §4.2, re-review finding #2),
1002
+ // not deps.mcpUrl directly: disable() has no --url flag, so deps.mcpUrl
1003
+ // is always whatever default dispatch() filled in, regardless of what
1004
+ // "capture enable --url ..." actually used on this machine. config.json
1005
+ // is the record of that real value; see resolveDisableMcpUrl's own
1006
+ // comment for the fallback this takes when that record is gone.
1007
+ const tomlText = readFileSync(deps.codexConfigPath, 'utf8');
1008
+ const mcpUrlForToml = resolveDisableMcpUrl(deps.configDir, deps.mcpUrl);
1009
+ const removedToml = removeCodexToml(tomlText, mcpUrlForToml);
1010
+ const tomlChanged = removedToml.result === 'removed';
1011
+ if (tomlChanged) {
1012
+ deps.writeText(deps.codexConfigPath, removedToml.text);
1013
+ }
1014
+ // One honest sentence per file actually touched (fix round 2, re-review
1015
+ // finding #2): the old single message here always said "removed the
1016
+ // Codex hook entries" whenever *either* file changed, which claimed the
1017
+ // TOML table was gone even on the runs where only hooks.json changed and
1018
+ // removeCodexToml's own url comparison left the table exactly where it
1019
+ // was -- silently, since success was reported either way.
1020
+ if (hooksChanged) {
1021
+ console.log(`Capture disabled -- removed the Codex hook entries from ${deps.codexHooksPath}.`);
1022
+ changed = true;
1023
+ }
1024
+ if (tomlChanged) {
1025
+ console.log(`Capture disabled -- removed the Codex MCP server entry from ${deps.codexConfigPath}.`);
1026
+ changed = true;
1027
+ }
1028
+ }
1029
+ if (!changed) {
222
1030
  console.log('Capture was not enabled here -- nothing to remove.');
223
1031
  return 'nothing-to-disable';
224
1032
  }
225
- deps.writeSettingsJson(deps.settingsPath, removed);
226
- const configPath = join(deps.configDir, CONFIG_FILE_NAME);
227
- console.log(`Capture disabled -- removed the hook entries from ${deps.settingsPath}.`);
228
- console.log(`Your API key is still saved at ${configPath}. Revoke it at ${KEYS_PAGE_URL} if you want to ` +
229
- `fully remove access.`);
1033
+ console.log(stillSavedMessage(configPath));
230
1034
  return 'disabled';
231
1035
  }
1036
+ // Ruling R5 (task 10, spec 012 §4.6): one line per detected harness -- its
1037
+ // directory or config file exists -- naming whether our hooks are actually
1038
+ // installed there, as opposed to merely detected (setup()'s own "found but
1039
+ // nothing to change" language, harnesses.ts's getHarnesses()), then exactly
1040
+ // one line for the key. Reads files only, through the same helpers
1041
+ // enable()/disable() use to decide what is theirs; never writes, and never
1042
+ // prints the key itself, only whether one is stored.
1043
+ export async function status(deps) {
1044
+ if (existsSync(deps.claudeDir)) {
1045
+ const settings = readClaudeSettings(deps.settingsPath);
1046
+ const installed = settings !== undefined && hasOwnHooks(settings);
1047
+ console.log(installed ? `Claude Code: hooks installed (${deps.settingsPath})` : 'Claude Code: not configured');
1048
+ }
1049
+ if (existsSync(deps.codexConfigPath)) {
1050
+ const tomlText = readFileSync(deps.codexConfigPath, 'utf8');
1051
+ if (codexPluginPresent(tomlText, deps.codexPluginsCacheDir)) {
1052
+ console.log('Codex: plugin route');
1053
+ }
1054
+ else {
1055
+ const hooksFile = readCodexHooksFile(deps.codexHooksPath);
1056
+ const installed = hooksFile !== undefined && hasOwnCodexHooks(hooksFile);
1057
+ console.log(installed ? `Codex: hooks installed (${deps.codexHooksPath})` : 'Codex: not configured');
1058
+ }
1059
+ }
1060
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
1061
+ let keyStored = false;
1062
+ try {
1063
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
1064
+ keyStored =
1065
+ parsed !== null &&
1066
+ typeof parsed === 'object' &&
1067
+ typeof parsed.key === 'string' &&
1068
+ parsed.key.length > 0;
1069
+ }
1070
+ catch {
1071
+ keyStored = false;
1072
+ }
1073
+ console.log(keyStored ? 'Key: stored' : 'Key: none');
1074
+ }