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/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +256 -64
- package/dist/capture.js +660 -112
- package/dist/cli.js +351 -64
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +2 -2
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +103 -74
- package/dist/login.js +312 -0
- package/hooks/hooks.json +63 -0
- package/package.json +2 -2
- package/skills/waku/SKILL.md +23 -0
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,
|
|
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
|
|
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.
|
|
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
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
590
|
+
if (attempt < SIGN_IN_ATTEMPTS)
|
|
591
|
+
console.log('Enter 1 or 2.');
|
|
307
592
|
}
|
|
308
|
-
|
|
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
|
|
339
|
-
// is the evidence, the shim's own
|
|
340
|
-
// cli.ts's harness handling
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
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
|
-
//
|
|
349
|
-
//
|
|
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
|
-
//
|
|
371
|
-
// now-verified invocation. Everything before
|
|
372
|
-
// declining, a failed verification) writes
|
|
373
|
-
//
|
|
374
|
-
// follows a real copy, down to the
|
|
375
|
-
// there; when installHookCopy was
|
|
376
|
-
// nothing of its own to remove, and the
|
|
377
|
-
// place was never new. This step
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// only
|
|
392
|
-
//
|
|
393
|
-
// settings.json
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
425
|
-
if (
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
// "
|
|
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
|
-
|
|
516
|
-
if (
|
|
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
|
-
|
|
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
|
+
}
|