waku-memory 0.2.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
@@ -23,8 +23,51 @@
23
23
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
24
  import { dirname, join, resolve } from 'node:path';
25
25
  import { isDeepStrictEqual } from 'node:util';
26
- import { LIVE_WINDOW_MS, parseBootstrapAnswer, renderBootstrapList, runBootstrap, scanClaudeCode, } from "./bootstrap.js";
26
+ import { LIVE_WINDOW_MS, parseBootstrapAnswer, renderBootstrapList, runBootstrap, scanAll, } from "./bootstrap.js";
27
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
+ }
28
71
  // `Stop` is where captured content is actually sent -- "async": true so it
29
72
  // runs in the background without blocking the turn (600s default budget,
30
73
  // research doc §2). `SessionStart` is synchronous instead, with a 5s
@@ -86,6 +129,13 @@ export function isOwnEntry(entry) {
86
129
  function isOwnGroup(group) {
87
130
  return group.hooks.length > 0 && group.hooks.every(isOwnEntry);
88
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
+ }
89
139
  // Replaces any group of ours on each event -- an older version's entry, in
90
140
  // either form -- with one built from `run`, and keeps every foreign group
91
141
  // untouched. Idempotent: merging the same invocation twice yields the same
@@ -188,17 +238,27 @@ export function hookInvocation(execPath, indexPath) {
188
238
  // commands it runs stay local *unless the agent's own reply quotes them
189
239
  // back* (the one path by which their content can still leave, so the
190
240
  // 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
241
+ // hook actually draws); reasoning is never sent, full stop. The next
192
242
  // sentence previews enable()'s own third question (task 13, spec 011 §8):
193
243
  // "the next question" is literal, not rhetorical -- renderBootstrapList
194
244
  // (bootstrap.ts) is the list it points at, printed immediately after this
195
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()).
196
255
  export const DISCLOSURE = 'Captured content is sent to our servers and to Anthropic for extraction. ' +
197
256
  'This is an alpha whose data can be lost. ' +
198
257
  'What you type, what the agent replies, and the names of the tools it uses are sent; ' +
199
258
  'the files it reads and the commands it runs are not, except where the agent quotes them in its reply, ' +
200
259
  '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.';
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.';
202
262
  // Exported so cli.ts's usage text can point at the same URL without a
203
263
  // second copy of it drifting out of sync.
204
264
  //
@@ -210,6 +270,15 @@ export const DISCLOSURE = 'Captured content is sent to our servers and to Anthro
210
270
  // free to move now. tech.md calls the Vercel host an earlier one, kept and
211
271
  // not retired; this is the product's own address.
212
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
+ }
213
282
  // Reads settings.json the same way applyToHarness (cli.ts) reads a harness
214
283
  // config: missing or blank is a blank slate (undefined here would wrongly
215
284
  // refuse the single most common first run -- no settings.json yet at all);
@@ -237,6 +306,85 @@ function readClaudeSettings(settingsPath) {
237
306
  return undefined;
238
307
  }
239
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
+ }
240
388
  // Only printable ASCII can be part of a bearer key. Non-printable
241
389
  // characters at either *end* came from the terminal, not the person: on
242
390
  // 2026-09-04 a Windows console delivered Ctrl+V's own control code (0x16)
@@ -276,36 +424,174 @@ const KEY_ATTEMPTS = 3;
276
424
  // refused: the server's opinion is the only one that counts, and a refusal
277
425
  // here would make a future prefix change strand every user on this shim.
278
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
+ }
279
462
  // Asks up to KEY_ATTEMPTS times. null means the person never pasted anything
280
463
  // 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.
464
+ // guarantee that declining the disclosure carries.
283
465
  async function askForKey(deps) {
284
466
  for (let attempt = 1; attempt <= KEY_ATTEMPTS; attempt++) {
285
467
  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;
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;
291
498
  }
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;
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';
297
556
  }
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.`);
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';
301
566
  }
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.`);
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 };
305
589
  }
306
- return key;
590
+ if (attempt < SIGN_IN_ATTEMPTS)
591
+ console.log('Enter 1 or 2.');
307
592
  }
308
- return null;
593
+ console.log('Capture not enabled -- no valid answer was given; nothing was written.');
594
+ return 'declined';
309
595
  }
310
596
  // A parsed BootstrapAnswer still carries `invalid`; runBootstrap's own
311
597
  // BootstrapSelection does not (its three real outcomes are `all`, `skip` and
@@ -333,20 +619,48 @@ async function askBootstrapSelection(projectCount, prompt) {
333
619
  }
334
620
  return toSelection(answer);
335
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
+ }
336
633
  // The one gesture that turns capture on. Order is deliberate and each step
337
634
  // depends on the one before it having succeeded:
338
- // 1. refuse if Claude Code itself is not on this machine -- the directory
339
- // is the evidence, the shim's own edit-don't-create rule adapted from
340
- // cli.ts's harness handling;
341
- // 2. refuse if the existing settings.json cannot be safely read -- checked
342
- // here, before the disclosure, so a machine this can never work on
343
- // fails fast instead of making the user type through a consent flow
344
- // first only to hit a wall at the end;
345
- // 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,
346
657
  // including asking for the key, so declining costs the user nothing
347
658
  // and asks for nothing;
348
- // 4. only then ask for the key;
349
- // 5. install a local copy of the shim under configDir/hook/<version> and
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
350
664
  // run it for real -- verifyHook -- before anything is wired into
351
665
  // settings.json. A copy that does not even start (a corrupted copy, a
352
666
  // Node too old for it, ...) must never become the thing Claude Code
@@ -367,52 +681,111 @@ async function askBootstrapSelection(projectCount, prompt) {
367
681
  // first-ever run can still leave behind after refusing here is an
368
682
  // empty configDir -- not removed, since it is the config directory
369
683
  // 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
380
- // failure on the settings.json write right after -- disk full, a
381
- // permission error -- does leave a real credential saved with no
382
- // hook actually installed, i.e. capture silently not active despite
383
- // config.json existing. Accepted rather than guarded against (no
384
- // rollback, no pre-write dry run): the failure still propagates out
385
- // of enable() to cli.ts's runCaptureCommand, which reports it and
386
- // sets a nonzero exit code, so it is not silent; it fails toward
387
- // "capture not enabled" rather than a consent bypass; and re-running
388
- // `enable` repairs it, since writing config.json again is a plain
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.
405
- export async function enable(url, deps) {
406
- if (!existsSync(deps.claudeDir)) {
407
- console.error(`Claude Code not found at ${deps.claudeDir} -- install Claude Code first, then run ` +
408
- `"npx waku-memory capture enable" again.`);
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.`);
409
740
  return 'refused';
410
741
  }
411
- const existingSettings = readClaudeSettings(deps.settingsPath);
412
- if (existingSettings === undefined) {
413
- console.error(`${deps.settingsPath}'s content is not something this tool can safely merge into -- ` +
414
- `leaving it untouched. Nothing was written.`);
415
- 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
+ }
416
789
  }
417
790
  console.log(DISCLOSURE);
418
791
  console.log('');
@@ -421,11 +794,17 @@ export async function enable(url, deps) {
421
794
  console.log('Capture not enabled -- nothing was written.');
422
795
  return 'declined';
423
796
  }
424
- const key = await askForKey(deps);
425
- if (key === null) {
426
- console.log('Capture not enabled -- no usable key was entered; nothing was written.');
797
+ const credentialResult = await resolveCredential(deps, credential);
798
+ if (credentialResult === 'declined')
427
799
  return 'declined';
428
- }
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;
807
+ const configPath = join(deps.configDir, CONFIG_FILE_NAME);
429
808
  const indexPath = installHookCopy(deps.hookSourceDir, deps.configDir, deps.version);
430
809
  const run = hookInvocation(deps.execPath, indexPath);
431
810
  if (!deps.verifyHook(run)) {
@@ -446,32 +825,76 @@ export async function enable(url, deps) {
446
825
  // hookRoot is already gone -- nothing left to prune
447
826
  }
448
827
  }
449
- console.error(`The hook entry did not run (${run.command} ${(run.args ?? []).join(' ')}) -- ` +
450
- `nothing was written.`);
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
+ }
451
843
  return 'refused';
452
844
  }
453
- mkdirSync(deps.configDir, { recursive: true }); // first run: ~/.waku-memory may not exist yet
454
- const configPath = join(deps.configDir, CONFIG_FILE_NAME);
455
- // {mode: 0o600} is a request, not a guarantee: Windows has no POSIX
456
- // owner-only bit for writeFileSync to set, so this line is best-effort
457
- // there and fully effective on Unix. Correct to pass on every platform
458
- // regardless -- harmless where it cannot apply, the right thing where it
459
- // can. Not routed through atomicWriteJson: that writer has no mode
460
- // parameter, and this file is small, freshly created, and not something
461
- // Claude Code itself reads concurrently the way settings.json is -- the
462
- // atomic temp-file-plus-rename technique earns its complexity there, not
463
- // here.
464
- writeFileSync(configPath, JSON.stringify({ url, key }, null, 2) + '\n', { mode: 0o600 });
465
- const merged = mergeHookSettings(existingSettings, run);
466
- deps.writeSettingsJson(deps.settingsPath, merged);
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
+ }
467
877
  pruneHookCopies(deps.configDir, deps.version);
468
878
  console.log('');
469
- console.log(`Capture enabled -- wrote ${configPath} and merged hooks into ${deps.settingsPath}.`);
470
- 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.');
471
890
  if (deps.bootstrap.enabled) {
472
891
  const { windowDays } = deps.bootstrap;
473
892
  const stateDir = join(deps.configDir, 'state');
474
- const scan = scanClaudeCode(deps.claudeDir, stateDir, {
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, {
475
898
  nowMs: Date.now(),
476
899
  sinceMs: windowDays === null ? 0 : Date.now() - windowDays * 86_400_000,
477
900
  liveWindowMs: LIVE_WINDOW_MS,
@@ -499,28 +922,153 @@ export async function enable(url, deps) {
499
922
  console.log(line);
500
923
  }
501
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
+ }
502
929
  return 'enabled';
503
930
  }
504
- // Removes exactly what enable() added and nothing else. Deliberately leaves
505
- // config.json in place: the key is the user's to revoke on the keys page,
506
- // not this tool's to delete -- disabling capture and revoking the
507
- // credential are two different decisions, and conflating them would make
508
- // "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.
509
970
  export async function disable(deps) {
971
+ const codexConfigExists = existsSync(deps.codexConfigPath);
510
972
  const existingSettings = readClaudeSettings(deps.settingsPath);
511
973
  if (existingSettings === undefined) {
512
974
  console.error(`${deps.settingsPath}'s content is not something this tool can safely modify -- leaving it untouched.`);
513
975
  return 'refused';
514
976
  }
515
- const removed = removeHookSettings(existingSettings);
516
- 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) {
517
1030
  console.log('Capture was not enabled here -- nothing to remove.');
518
1031
  return 'nothing-to-disable';
519
1032
  }
520
- deps.writeSettingsJson(deps.settingsPath, removed);
521
- const configPath = join(deps.configDir, CONFIG_FILE_NAME);
522
- console.log(`Capture disabled -- removed the hook entries from ${deps.settingsPath}.`);
523
- console.log(`Your API key is still saved at ${configPath}. Revoke it at ${KEYS_PAGE_URL} if you want to ` +
524
- `fully remove access.`);
1033
+ console.log(stillSavedMessage(configPath));
525
1034
  return 'disabled';
526
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
+ }