waku-memory 0.2.0 → 0.4.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/cli.js CHANGED
@@ -13,16 +13,17 @@
13
13
  // --help, and the found/wrote/error tri-state collapsing into one boolean
14
14
  // -- are pinned by plain assertions on return values, not by mocking
15
15
  // console.log.
16
- import { spawnSync } from 'node:child_process';
17
- import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
16
+ import { spawn, spawnSync } from 'node:child_process';
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
19
  import { basename, dirname, join } from 'node:path';
20
20
  import { createInterface } from 'node:readline';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { DEFAULT_WINDOW_DAYS } from "./bootstrap.js";
23
- import { getHarnesses, getManualHarnesses, harnessConfigExists, mergeMcpConfig, } from "./harnesses.js";
23
+ import { getHarnesses, harnessConfigExists, mergeMcpConfig, profileFor, } from "./harnesses.js";
24
24
  import { SHIM_VERSION, handleHookEvent } from "./hook.js";
25
- import { KEYS_PAGE_URL, disable, enable } from "./capture.js";
25
+ import { DISCLOSURE, KEYS_PAGE_URL, disable, enable, status, urlPair, } from "./capture.js";
26
+ import { login } from "./login.js";
26
27
  export const DEFAULT_URL = 'https://api.waku.one/mcp';
27
28
  export const DEFAULT_NAME = 'waku';
28
29
  // The ingest base capture.ts writes into config.json -- deliberately not
@@ -73,6 +74,9 @@ export function parseArgs(argv) {
73
74
  let since;
74
75
  let all = false;
75
76
  let noBootstrap = false;
77
+ let harness;
78
+ let login = false;
79
+ let key;
76
80
  const positional = [];
77
81
  for (let i = 0; i < argv.length; i++) {
78
82
  const arg = argv[i];
@@ -85,12 +89,28 @@ export function parseArgs(argv) {
85
89
  else if (arg === '--since' && i + 1 < argv.length) {
86
90
  since = argv[++i];
87
91
  }
92
+ else if (arg === '--harness' && i + 1 < argv.length) {
93
+ harness = argv[++i];
94
+ }
88
95
  else if (arg === '--all') {
89
96
  all = true;
90
97
  }
91
98
  else if (arg === '--no-bootstrap') {
92
99
  noBootstrap = true;
93
100
  }
101
+ else if (arg === '--login') {
102
+ login = true;
103
+ }
104
+ else if (arg === '--key') {
105
+ // Unlike --name/--url/--since/--harness above, a value starting with
106
+ // "--" is deliberately NOT consumed here -- "--key --url x" must
107
+ // leave --url for its own branch to see, not swallow it as the key's
108
+ // literal value. Left undefined (with the token itself still in argv,
109
+ // for dispatch()'s argv.includes('--key') check) either way: no next
110
+ // token at all, or the next token is itself a flag.
111
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('--'))
112
+ key = argv[++i];
113
+ }
94
114
  else if (!arg.startsWith('--')) {
95
115
  positional.push(arg);
96
116
  }
@@ -98,21 +118,34 @@ export function parseArgs(argv) {
98
118
  // subcommand is only meaningful to the caller for "capture enable" /
99
119
  // "capture disable" -- setup and hook both ignore it, same as they always
100
120
  // ignored any positional beyond the first.
101
- return { command: positional[0], subcommand: positional[1], name, url, since, all, noBootstrap };
121
+ return { command: positional[0], subcommand: positional[1], name, url, since, all, noBootstrap, harness, login, key };
102
122
  }
103
123
  export function printUsage() {
124
+ console.log('Usage: waku-memory login [--url <ingest-base>]');
125
+ console.log('');
126
+ console.log(' Signs in through the browser and mints an API key, without you ever seeing');
127
+ console.log(' it -- the same flow "capture enable" offers as its first question, run on');
128
+ console.log(' its own. Shows the disclosure first; prints "Signed in as <email>" on');
129
+ console.log(` success, and stores the key in ~/.waku-memory/config.json.`);
130
+ console.log('');
104
131
  console.log('Usage: npx waku-memory setup [--name <name>] [--url <url>]');
105
132
  console.log('');
106
133
  console.log(` --name key to add under mcpServers (default: "${DEFAULT_NAME}")`);
107
134
  console.log(` --url server URL to write (default: ${DEFAULT_URL})`);
108
135
  console.log('');
109
- console.log('Usage: waku-memory hook');
136
+ console.log(' If a harness was found, setup then asks whether to also turn on automatic');
137
+ console.log(' capture -- the same flow as "capture enable", asked so you do not have to');
138
+ console.log(' type a second command. Default is no; press Enter to skip it.');
139
+ console.log('');
140
+ console.log('Usage: waku-memory hook [--harness <claude_code|codex>]');
110
141
  console.log('');
111
- console.log(' Reads one Claude Code hook event as JSON on stdin and reports it to');
112
- console.log(' the server named in ~/.waku-memory/config.json. Invoked by Claude Code');
113
- console.log(' itself (see "capture enable") -- not meant to be run by hand.');
142
+ console.log(' Reads one hook event as JSON on stdin and reports it to the server named');
143
+ console.log(' in ~/.waku-memory/config.json. Invoked by the harness itself (see');
144
+ console.log(' "capture enable") -- not meant to be run by hand.');
114
145
  console.log('');
115
- console.log('Usage: waku-memory capture enable [--url <ingest-base>] [--since <N>d] [--all] [--no-bootstrap]');
146
+ console.log(' --harness which harness sent the event (default: "claude_code")');
147
+ console.log('');
148
+ console.log('Usage: waku-memory capture enable [--url <ingest-base>] [--since <N>d] [--all] [--no-bootstrap] [--login] [--key <key>]');
116
149
  console.log('');
117
150
  console.log(' Turns on automatic capture: every turn of your Claude Code sessions is');
118
151
  console.log(' sent to our server and on to Anthropic for extraction. Shows that');
@@ -127,11 +160,18 @@ export function printUsage() {
127
160
  console.log(` --since <N>d only scan transcripts from the last N days (default: ${DEFAULT_WINDOW_DAYS}d)`);
128
161
  console.log(' --all scan full history instead, ignoring --since');
129
162
  console.log(' --no-bootstrap skip the scan and the third question entirely');
163
+ console.log(' --login sign in in the browser without asking');
164
+ console.log(' --key <key> use this key without asking');
130
165
  console.log('');
131
166
  console.log('Usage: waku-memory capture disable');
132
167
  console.log('');
133
168
  console.log(' Removes exactly the hook entries "capture enable" added. Your saved key');
134
169
  console.log(' is left in place -- revoke it on the keys page if you want it gone too.');
170
+ console.log('');
171
+ console.log('Usage: waku-memory capture status');
172
+ console.log('');
173
+ console.log(' Prints whether capture is installed for Claude Code and for Codex, and');
174
+ console.log(' whether a key is saved. Reads only -- never writes.');
135
175
  }
136
176
  // The default third question: on, with the full DEFAULT_WINDOW_DAYS window
137
177
  // (spec 011 §8/§9, task 14). Used both as dispatch()'s no-flags-given result
@@ -158,23 +198,64 @@ export function dispatch(argv) {
158
198
  // existed to pin it. See cli.test.mjs's dispatch block.
159
199
  if (argv.includes('--help') || argv.includes('-h'))
160
200
  return { kind: 'help' };
161
- const { command, subcommand, name, url, since, all, noBootstrap } = parseArgs(argv);
201
+ const { command, subcommand, name, url, since, all, noBootstrap, harness, login: loginFlag, key } = parseArgs(argv);
162
202
  if (command === undefined)
163
203
  return { kind: 'no-command' };
164
- if (command === 'hook')
165
- return { kind: 'hook' };
204
+ if (command === 'login') {
205
+ // Same urlPair() rule "capture enable" uses -- see its own comment
206
+ // below: whatever shape --url was typed in, the ingest base is what
207
+ // realLoginDeps(d.url) (run()) and login.ts's own apiBase param need,
208
+ // never the "/mcp"-suffixed mcpUrl.
209
+ return { kind: 'login', url: urlPair(url).ingestUrl };
210
+ }
211
+ if (command === 'hook') {
212
+ // --harness with no value: parseArgs only consumes it when a next token
213
+ // exists, so a trailing "--harness" leaves `harness` undefined the same
214
+ // way a trailing "--since" does -- but unlike --since (which falls back
215
+ // to a default), a bare --harness is treated the same as an unknown id,
216
+ // per the brief: both are the caller trying to say something this shim
217
+ // does not understand.
218
+ if (argv.includes('--harness') && harness === undefined) {
219
+ return {
220
+ kind: 'bad-flag',
221
+ message: 'waku-memory hook: --harness requires a value (e.g. "codex") -- got none.',
222
+ };
223
+ }
224
+ const harnessId = harness ?? 'claude_code';
225
+ if (!profileFor(harnessId)) {
226
+ return {
227
+ kind: 'bad-flag',
228
+ message: `waku-memory hook: unknown harness "${harnessId}" -- expected one of: claude_code, codex.`,
229
+ };
230
+ }
231
+ return { kind: 'hook', harness: harnessId };
232
+ }
166
233
  if (command === 'capture') {
167
- if (subcommand !== 'enable' && subcommand !== 'disable')
234
+ if (subcommand !== 'enable' && subcommand !== 'disable' && subcommand !== 'status') {
168
235
  return { kind: 'unknown', command: 'capture' };
169
- // parseArgs's `url` defaults to DEFAULT_URL (setup's "/mcp" endpoint)
170
- // whenever --url is absent -- the wrong default here. Checking argv
171
- // directly for the flag, rather than trusting that default, is the
172
- // cheapest way to tell "the user passed --url" apart from "parseArgs's
173
- // unrelated default happened to apply" without giving parseArgs a
174
- // second, command-dependent default of its own.
175
- const ingestUrl = argv.includes('--url') ? url : DEFAULT_INGEST_URL;
236
+ }
237
+ if (subcommand === 'status')
238
+ return { kind: 'capture', action: 'status' };
239
+ // parseArgs's `url` already defaults to DEFAULT_URL (the "/mcp" literal)
240
+ // whenever --url is absent, so no argv.includes('--url') check is needed
241
+ // here any more (fix round 1 had one, to avoid trusting parseArgs's
242
+ // setup-shaped default -- round 2 replaces the whole thing with
243
+ // urlPair(), which produces the right pair from DEFAULT_URL exactly the
244
+ // same way it does from any --url a person types).
245
+ //
246
+ // Fix round 2 (re-review finding #1, spec 012 §4.2): urlPair() (capture.ts)
247
+ // is the one place that decides "given this one URL, what is the ingest
248
+ // base and what is the MCP endpoint" -- round 1's own rule (strip a
249
+ // trailing "/mcp" from mcpUrl to get ingestUrl) only worked when the
250
+ // input was already MCP-shaped; an ingest-shaped --url (no "/mcp" to
251
+ // strip, exactly the shape cli.ts's own usage text documents:
252
+ // "capture enable [--url <ingest-base>]") left mcpUrl === ingestUrl,
253
+ // reproducing the original defect. urlPair always derives the ingest
254
+ // base first and always appends "/mcp" to build mcpUrl, so every shape
255
+ // (bare, trailing slash, "/mcp"-suffixed) converges on the same pair.
256
+ const { ingestUrl, mcpUrl } = urlPair(url);
176
257
  if (subcommand === 'disable') {
177
- return { kind: 'capture', action: 'disable', url: ingestUrl, bootstrap: DEFAULT_BOOTSTRAP };
258
+ return { kind: 'capture', action: 'disable', url: ingestUrl, mcpUrl, bootstrap: DEFAULT_BOOTSTRAP };
178
259
  }
179
260
  // --since is validated unconditionally (even alongside --all) so a typo
180
261
  // is never silently swallowed by an --all that happened to be present
@@ -195,7 +276,33 @@ export function dispatch(argv) {
195
276
  }
196
277
  if (all)
197
278
  windowDays = null;
198
- return { kind: 'capture', action: 'enable', url: ingestUrl, bootstrap: { windowDays, enabled: !noBootstrap } };
279
+ // --login / --key <key> (task 13, spec 012 §7): the credential decision
280
+ // capture.ts's enable() now takes as its own parameter -- see
281
+ // Credential's own comment (capture.ts) for why it lives there and not
282
+ // on CaptureDeps. Both bad-flag checks run unconditionally, the same
283
+ // way --since's does above, so a scripted caller gets a loud, specific
284
+ // error instead of the question silently being asked anyway.
285
+ if (loginFlag && key !== undefined) {
286
+ return {
287
+ kind: 'bad-flag',
288
+ message: 'waku-memory capture enable: --login and --key cannot be used together -- pick one.',
289
+ };
290
+ }
291
+ if (argv.includes('--key') && key === undefined) {
292
+ return {
293
+ kind: 'bad-flag',
294
+ message: 'waku-memory capture enable: --key requires a value -- got none.',
295
+ };
296
+ }
297
+ const credential = key !== undefined ? { kind: 'key', key } : loginFlag ? { kind: 'login' } : { kind: 'ask' };
298
+ return {
299
+ kind: 'capture',
300
+ action: 'enable',
301
+ url: ingestUrl,
302
+ mcpUrl,
303
+ bootstrap: { windowDays, enabled: !noBootstrap },
304
+ credential,
305
+ };
199
306
  }
200
307
  if (command !== 'setup')
201
308
  return { kind: 'unknown', command };
@@ -209,12 +316,22 @@ export function dispatch(argv) {
209
316
  // a single byte back; a crash, a kill, or a full disk in that window
210
317
  // leaves a real, in-use config -- ~78 KB and ~80 keys, by measurement --
211
318
  // truncated or corrupted.
212
- export const atomicWriteJson = (configPath, data) => {
213
- const dir = dirname(configPath);
214
- const tmpPath = join(dir, `.${basename(configPath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
319
+ //
320
+ // Shared by every writer below it (task 10, spec 012 §4.6): atomicWriteJson
321
+ // (settings.json, config files), writeJsonFile (Codex's hooks.json and the
322
+ // personal marketplace file) and writeCodexText (Codex's config.toml, whose
323
+ // content is already a complete file, not a value to serialise). The
324
+ // mkdirSync up front is new with those two Codex JSON writers -- neither
325
+ // ~/.codex nor ~/.agents/plugins is guaranteed to exist yet the first time
326
+ // capture enable runs on a Codex machine, unlike ~/.claude (whose presence
327
+ // is what enable() already checked before writing anything under it).
328
+ function atomicWrite(path, content) {
329
+ const dir = dirname(path);
330
+ mkdirSync(dir, { recursive: true });
331
+ const tmpPath = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
215
332
  try {
216
- writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
217
- renameSync(tmpPath, configPath);
333
+ writeFileSync(tmpPath, content, 'utf8');
334
+ renameSync(tmpPath, path);
218
335
  }
219
336
  catch (err) {
220
337
  try {
@@ -228,7 +345,21 @@ export const atomicWriteJson = (configPath, data) => {
228
345
  }
229
346
  throw err;
230
347
  }
231
- };
348
+ }
349
+ export const atomicWriteJson = (configPath, data) => atomicWrite(configPath, JSON.stringify(data, null, 2) + '\n');
350
+ // capture.ts's CaptureDeps.writeJsonFile (task 10): the same atomic write as
351
+ // atomicWriteJson above, generalised to `unknown` -- capture.ts has no more
352
+ // opinion about CodexHooksFile vs MarketplaceFile than atomicWriteJson has
353
+ // about McpConfig beyond JSON.stringify, which is exactly why a function
354
+ // typed `(data: unknown) => void` is assignable wherever `WriteJson` is
355
+ // expected (parameter types are contravariant) and the reverse is not worth
356
+ // forcing through a cast.
357
+ export const writeJsonFile = (path, data) => atomicWrite(path, JSON.stringify(data, null, 2) + '\n');
358
+ // capture.ts's CaptureDeps.writeText (task 10): writes `text` exactly as
359
+ // given, no JSON.stringify and no added newline -- mergeCodexToml
360
+ // (codex-config.ts) already returns the complete file text, trailing
361
+ // newline included when the original file had one.
362
+ export const writeCodexText = (path, text) => atomicWrite(path, text);
232
363
  // Adds `name` -> `url` to one harness's config file. Never throws: every
233
364
  // fallible step -- read, parse, merge, write -- is caught right here, so a
234
365
  // bad harness cannot stop the others from being tried and cannot crash the
@@ -337,7 +468,49 @@ export function summarize(results) {
337
468
  hadError: results.some((r) => r.status === 'error'),
338
469
  };
339
470
  }
340
- export function setup(name, url, harnesses = getHarnesses(), manualHarnesses = getManualHarnesses()) {
471
+ // The one-shot readline prompt setup()'s own new question needs, real
472
+ // default for SetupPrompt below -- a plain rl.question(), not the
473
+ // async-iterator dance runCaptureCommand's own prompt uses for enable()'s
474
+ // two sequential questions (see that function's own comment): setup() only
475
+ // ever asks one question of its own before handing off to runCaptureEnable,
476
+ // which opens (and closes) its own readline in turn, so there is no shared
477
+ // interface for two overlapping .question() calls to race over.
478
+ function realSetupPrompt(question) {
479
+ return new Promise((resolve) => {
480
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
481
+ rl.question(question, (answer) => {
482
+ rl.close();
483
+ resolve(answer);
484
+ });
485
+ });
486
+ }
487
+ // Verbatim -- cli.test.mjs depends on this exact string, the same contract
488
+ // capture.ts's own SIGN_IN_QUESTION carries.
489
+ export const TURN_ON_CAPTURE_QUESTION = 'Also turn on automatic capture? It sends what you type and what the agent replies to Waku. [y/N]: ';
490
+ export async function setup(name, url, harnesses = getHarnesses(),
491
+ // R20 (final review, spec 012): the real default, injected the same way
492
+ // realCaptureDeps below builds it -- so cli.test.mjs can point this at a
493
+ // temp path instead of the machine's actual ~/.codex/config.toml, the
494
+ // same reason every other real path in this file is a parameter with a
495
+ // real default rather than a bare homedir() call inline.
496
+ codexConfigPath = join(homedir(), '.codex', 'config.toml'),
497
+ // The two additions that make setup() async (this task): the question
498
+ // itself (realSetupPrompt, above) and what a "y" answer actually runs.
499
+ // Both injectable, the same reason every real path/writer/fetch in this
500
+ // file is a parameter with a real default rather than a bare call inline
501
+ // -- so cli.test.mjs never opens real stdin and never runs a real
502
+ // capture enable. The real runCaptureEnable below is nothing but the
503
+ // existing "capture enable" command path (runCaptureCommand, same as
504
+ // dispatch()'s own 'capture'/'enable' case), called with no flags -- the
505
+ // Sign-in question, enable()'s own disclosure and typed "y" gate, the
506
+ // credential step, the hooks, and the bootstrap question all run exactly
507
+ // as they do from the command line. Nothing here may skip, weaken, or
508
+ // pre-answer any of that: the only thing this buys the person is not
509
+ // having to type "npx waku-memory capture enable" a second time.
510
+ prompt = realSetupPrompt, runCaptureEnable = () => {
511
+ const { ingestUrl, mcpUrl } = urlPair(url);
512
+ return runCaptureCommand('enable', ingestUrl, mcpUrl, DEFAULT_BOOTSTRAP, { kind: 'ask' });
513
+ }) {
341
514
  const results = runSetup(harnesses, name, url);
342
515
  for (const r of results) {
343
516
  if (r.status === 'error')
@@ -345,42 +518,40 @@ export function setup(name, url, harnesses = getHarnesses(), manualHarnesses = g
345
518
  else
346
519
  console.log(r.message);
347
520
  }
348
- // Only the ones actually on this machine. A manual harness is reported
349
- // as found -- it just cannot be written to -- so it counts toward
350
- // "did we find anything at all" below, and specifically toward whether
351
- // the import step is worth mentioning: someone who follows the
352
- // instructions has a working server, same as an auto-configured one.
353
- const manualPresent = manualHarnesses.filter(harnessConfigExists);
354
- for (const h of manualPresent) {
355
- console.log('');
356
- console.log(h.note(name, url));
357
- }
358
521
  console.log('');
359
- const { foundAny: autoFound, wroteAny, hadError } = summarize(results);
360
- const foundAny = autoFound || manualPresent.length > 0;
522
+ // Task 14 (spec 012 §9): setup() no longer has a manual-harness list to
523
+ // consult here -- Codex was its only member, and Codex now has a real,
524
+ // automatic route of its own ("capture enable", task 10), so there is
525
+ // nothing left this function can detect but not write. getHarnesses()
526
+ // still does not carry Codex (harnesses.ts's own comment has the fuller
527
+ // account), so a Codex-only machine still falls through to the plain "no
528
+ // supported harness config was found" branch below -- R20 below is what
529
+ // stops that from being a flatly false thing to print on such a machine.
530
+ const { foundAny, wroteAny, hadError } = summarize(results);
361
531
  if (wroteAny) {
362
532
  console.log('Next: open the harness and use a waku tool. The first call will get a 401, ' +
363
533
  'the harness will open your browser to sign in, and the call will then go ' +
364
534
  'through on retry -- that browser popup is expected, not an error.');
365
535
  }
366
- else if (autoFound && !hadError) {
536
+ else if (foundAny && !hadError) {
367
537
  console.log('Already configured correctly -- nothing to change.');
368
538
  }
369
- else if (autoFound && hadError) {
539
+ else if (foundAny && hadError) {
370
540
  console.log('Could not update every harness found -- see the messages above.');
371
541
  }
372
- else if (manualPresent.length > 0) {
373
- // Deliberately its own branch rather than falling into "already
374
- // configured": a manual harness being *present* is not the same as it
375
- // being *configured*, and telling someone nothing needs changing when
376
- // they still have a TOML block to paste is the more expensive of the
377
- // two possible wrong messages.
378
- console.log('Nothing was written: the only harness found here is one this shim does not ' +
379
- 'configure automatically -- follow the instructions above to finish.');
380
- }
381
542
  else {
382
543
  console.log('Nothing was added: no supported harness config was found on this machine.');
383
544
  }
545
+ // R20 (final review, spec 012): the regression this whole-branch review
546
+ // caught -- "no supported harness config was found" is false on a machine
547
+ // that has ~/.codex/config.toml. setup() still writes nothing for Codex
548
+ // (getHarnesses() does not carry it, above), so this is advice, not a
549
+ // result: it fires independently of the found/wrote/error branch above,
550
+ // including alongside a real Claude Code result on a machine with both.
551
+ if (existsSync(codexConfigPath)) {
552
+ console.log('');
553
+ console.log('Codex was found. Run "npx waku-memory capture enable" to configure it.');
554
+ }
384
555
  // Printed whenever a usable server exists on this machine -- which
385
556
  // includes the already-configured re-run, since a configured server with
386
557
  // nothing imported is exactly the state a re-runner is most likely to be
@@ -392,6 +563,20 @@ export function setup(name, url, harnesses = getHarnesses(), manualHarnesses = g
392
563
  }
393
564
  if (hadError)
394
565
  process.exitCode = 1;
566
+ // The new question this task adds: asked only once there is something to
567
+ // turn capture on for -- a machine with no harness found has nothing for
568
+ // "capture enable" to install into either, so asking would just repeat
569
+ // the "nothing was added" message above in question form. Default is no
570
+ // (an empty answer, from pressing Enter, keeps today's behaviour exactly)
571
+ // -- capture is a much bigger step than writing an MCP config, so unlike
572
+ // enable()'s own SIGN_IN_QUESTION (whose default path still requires
573
+ // choosing 1 or 2), this one must never be opted into by accident.
574
+ if (foundAny) {
575
+ const answer = (await prompt(TURN_ON_CAPTURE_QUESTION)).trim().toLowerCase();
576
+ if (answer === 'y') {
577
+ await runCaptureEnable();
578
+ }
579
+ }
395
580
  }
396
581
  function errorMessage(err) {
397
582
  return err instanceof Error ? err.message : String(err);
@@ -415,7 +600,7 @@ function readStdin() {
415
600
  // actual config dir, the real global fetch). Deliberately never awaited by
416
601
  // run() below -- see that call site's comment for why that is safe here
417
602
  // specifically, which is not the same as it being safe in general.
418
- async function runHookCommand() {
603
+ async function runHookCommand(harness) {
419
604
  let raw;
420
605
  try {
421
606
  raw = await readStdin();
@@ -429,13 +614,99 @@ async function runHookCommand() {
429
614
  event = JSON.parse(raw);
430
615
  }
431
616
  catch (err) {
432
- // Malformed input is a no-op, not a crash: some future Claude Code
433
- // hook event this shim was not written against, or a hand test of
434
- // `waku-memory hook` with no stdin piped in at all.
617
+ // Malformed input is a no-op, not a crash: some future hook event this
618
+ // shim was not written against, or a hand test of `waku-memory hook`
619
+ // with no stdin piped in at all.
435
620
  console.error(`waku-memory hook: stdin was not valid JSON -- ${errorMessage(err)}.`);
436
621
  return;
437
622
  }
438
- await handleHookEvent(event, { configDir: join(homedir(), '.waku-memory'), fetchImpl: fetch });
623
+ await handleHookEvent(event, { configDir: join(homedir(), '.waku-memory'), fetchImpl: fetch, harness });
624
+ }
625
+ // The platform opener login.ts's LoginDeps.openBrowser needs (spec 012 §7):
626
+ // spawned detached and unref'd so it never blocks -- or waits on -- this
627
+ // process, and never throws: a failed spawn (no browser installed, no
628
+ // `xdg-open` on a headless machine) is fine, because the authorize URL is
629
+ // always printed first (login.ts's own login(), right before this runs).
630
+ // The 'error' listener exists for the same reason as the try/catch: a spawn
631
+ // failure can surface asynchronously (an ENOENT for a missing xdg-open,
632
+ // say) rather than as a synchronous throw, and an unhandled 'error' event
633
+ // on a ChildProcess crashes the process if nothing is listening for it.
634
+ // The argv/options openBrowser (below) hands to child_process.spawn, kept
635
+ // as a pure function of (platform, url) so a unit test can assert the exact
636
+ // shape without spawning a real process or opening a real browser (fix
637
+ // round 1, finding 1). The previous win32 shape --
638
+ // `spawn('cmd', ['/c', 'start', '""', url])`, no shell, no verbatim-argument
639
+ // flag -- was Critical-broken for any real authorize URL: Node's default
640
+ // Windows argv quoting only wraps an argument in quotes when it contains
641
+ // whitespace, so an `&`-separated OAuth URL passed through untouched, and
642
+ // cmd.exe then read each `&` as its own command separator, truncating the
643
+ // URL at the first `&` and failing on every parameter after it as an
644
+ // unrecognized command.
645
+ //
646
+ // Chosen fix: shape (a) from the controller's ruling, not (b)
647
+ // (`rundll32 url.dll,FileProtocolHandler`) -- `cmd /c start` is the
648
+ // documented way to hand a URL to the OS's own default-handler resolution
649
+ // (same as double-clicking a link), while rundll32's undocumented internal
650
+ // entry point is a narrower target to keep behaving identically across
651
+ // Windows versions.
652
+ // `windowsVerbatimArguments: true` stops Node from re-quoting each argv
653
+ // entry (the exact thing that let `""` and the URL arrive at cmd.exe
654
+ // unquoted in the first place); wrapping the URL in its own literal double
655
+ // quotes (`` `"${url}"` ``) is then what makes cmd.exe treat everything
656
+ // between them -- `&` included -- as one argument instead of a command
657
+ // separator. The leading `'""'` argument is unrelated to the URL: it is
658
+ // `start`'s own window-title argument, required so `start` does not
659
+ // mistake a quoted first argument (the URL, if it came first) for a title
660
+ // instead of the command to run. Reproduced directly from Bash on this
661
+ // machine, adapted from the reviewer's own repro (see cli.test.mjs's
662
+ // nearby test and this task's report for the transcript): `cmd /c echo ""
663
+ // "https://x/a?b=1&c=2"` under `windowsVerbatimArguments: true` prints the
664
+ // URL whole, `&` and all -- `echo` standing in for `start` the same way the
665
+ // reviewer's own reproduction used it.
666
+ //
667
+ // darwin/linux are untouched: `open`/`xdg-open` never go through a shell,
668
+ // so Node's own (correct, non-Windows) argv handling was never the problem
669
+ // there.
670
+ export function buildBrowserOpener(platform, url) {
671
+ if (platform === 'win32') {
672
+ return {
673
+ command: 'cmd',
674
+ args: ['/c', 'start', '""', `"${url}"`],
675
+ options: { detached: true, stdio: 'ignore', windowsVerbatimArguments: true },
676
+ };
677
+ }
678
+ if (platform === 'darwin') {
679
+ return { command: 'open', args: [url], options: { detached: true, stdio: 'ignore' } };
680
+ }
681
+ return { command: 'xdg-open', args: [url], options: { detached: true, stdio: 'ignore' } };
682
+ }
683
+ function openBrowser(url) {
684
+ try {
685
+ const { command, args, options } = buildBrowserOpener(process.platform, url);
686
+ const child = spawn(command, args, options);
687
+ child.on('error', () => {
688
+ // Never throws -- the URL was already printed (login.ts's login()).
689
+ });
690
+ child.unref();
691
+ }
692
+ catch {
693
+ // Same reasoning as the 'error' listener above, for the rarer case
694
+ // where spawn() itself throws synchronously.
695
+ }
696
+ }
697
+ // login.ts's LoginDeps, built the one way this shim ever builds it -- both
698
+ // `waku-memory login` (run()'s own 'login' case) and "capture enable"'s
699
+ // login Credential (realCaptureDeps below) call this with the same
700
+ // ingest-base `apiBase` dispatch() already computed.
701
+ function realLoginDeps(apiBase) {
702
+ return {
703
+ apiBase,
704
+ configDir: join(homedir(), '.waku-memory'),
705
+ fetchImpl: fetch,
706
+ openBrowser,
707
+ stdout: (text) => console.log(text),
708
+ version: SHIM_VERSION,
709
+ };
439
710
  }
440
711
  // The real deps enable()/disable() run against outside a test: real paths
441
712
  // under the user's home directory, an injected prompt (built by the two
@@ -460,8 +731,22 @@ async function runHookCommand() {
460
731
  // --since/--all/--no-bootstrap (or their absence) into this same shape, so
461
732
  // this function's only job is to place it on the deps bag unchanged, the
462
733
  // same way `prompt` already arrives built by its own call site below.
463
- function realCaptureDeps(prompt, bootstrap) {
734
+ function realCaptureDeps(prompt, bootstrap,
735
+ // Fix round 1 (spec 012 §4.2): the MCP-shaped url (dispatch()'s mcpUrl,
736
+ // computed from --url BEFORE it was stripped to the ingest base) --
737
+ // passed alongside bootstrap the same way disable()'s and status()'s call
738
+ // sites already pass a bootstrap they never use, so every caller builds
739
+ // deps the same way regardless of whether its own action reads the field.
740
+ mcpUrl,
741
+ // Task 13's addition (spec 012 §7): the ingest base (dispatch()'s
742
+ // ingestUrl) realLoginDeps needs for deps.login below -- every call site
743
+ // already has this value (it is enable()'s own `url` parameter, or
744
+ // disable's/status's respective stand-ins), so, like bootstrap and mcpUrl
745
+ // above, it is threaded through unconditionally rather than only for the
746
+ // one action that can actually reach the credential question.
747
+ ingestUrl) {
464
748
  const claudeDir = join(homedir(), '.claude');
749
+ const codexDir = join(homedir(), '.codex');
465
750
  return {
466
751
  claudeDir,
467
752
  settingsPath: join(claudeDir, 'settings.json'),
@@ -474,6 +759,20 @@ function realCaptureDeps(prompt, bootstrap) {
474
759
  writeSettingsJson: (path, data) => atomicWriteJson(path, data),
475
760
  fetchImpl: fetch,
476
761
  bootstrap,
762
+ // Codex (task 10, spec 012 §4.6): detected the same way claudeDir is --
763
+ // existence of the path is the evidence. codexPluginsCacheDir matches
764
+ // codexPluginPresent's own comment (codex-config.ts) on where Codex
765
+ // caches an installed plugin; marketplacePath is the personal
766
+ // marketplace every machine gets one of, regardless of which harnesses
767
+ // it has (~/.agents is not itself Codex-specific).
768
+ codexConfigPath: join(codexDir, 'config.toml'),
769
+ codexHooksPath: join(codexDir, 'hooks.json'),
770
+ codexPluginsCacheDir: join(codexDir, 'plugins', 'cache'),
771
+ marketplacePath: join(homedir(), '.agents', 'plugins', 'marketplace.json'),
772
+ mcpUrl,
773
+ writeText: writeCodexText,
774
+ writeJsonFile,
775
+ login: () => login(realLoginDeps(ingestUrl)),
477
776
  };
478
777
  }
479
778
  // Not awaited by run() below, for the same structural reason runHookCommand
@@ -484,7 +783,12 @@ function realCaptureDeps(prompt, bootstrap) {
484
783
  // safely touch) is the one outcome that should make a scripted caller
485
784
  // notice. 'declined' and the disable no-op are both a normal, successful
486
785
  // run that did exactly what was asked -- nothing.
487
- async function runCaptureCommand(action, url, bootstrap) {
786
+ async function runCaptureCommand(action, url, mcpUrl, bootstrap,
787
+ // undefined for 'disable' (Dispatch's disable variant carries no
788
+ // credential at all -- see its own comment); always one of the three
789
+ // Credential kinds for 'enable', built by dispatch()'s capture-enable
790
+ // branch from --login/--key or their absence.
791
+ credential) {
488
792
  if (action === 'disable') {
489
793
  // disable() never prompts (see its own comment) -- no readline interface
490
794
  // is created at all, and this stub is a fail-loud guard against that
@@ -493,7 +797,7 @@ async function runCaptureCommand(action, url, bootstrap) {
493
797
  // itself never reads deps.bootstrap either.
494
798
  const deps = realCaptureDeps(() => {
495
799
  throw new Error('waku-memory capture disable: unexpectedly tried to prompt.');
496
- }, bootstrap);
800
+ }, bootstrap, mcpUrl, url);
497
801
  const result = await disable(deps);
498
802
  if (result === 'refused')
499
803
  process.exitCode = 1;
@@ -531,7 +835,7 @@ async function runCaptureCommand(action, url, bootstrap) {
531
835
  const { value, done } = await lines.next();
532
836
  return done ? '' : value; // stdin closed before an answer arrived -- treated as "declined"
533
837
  };
534
- const result = await enable(url, realCaptureDeps(prompt, bootstrap));
838
+ const result = await enable(url, realCaptureDeps(prompt, bootstrap, mcpUrl, url), credential);
535
839
  if (result === 'refused')
536
840
  process.exitCode = 1;
537
841
  }
@@ -539,6 +843,17 @@ async function runCaptureCommand(action, url, bootstrap) {
539
843
  rl.close();
540
844
  }
541
845
  }
846
+ // status() never prompts, never touches deps.bootstrap, and (ruling R8,
847
+ // fix round 1) needs no url at all -- same as disable() -- see
848
+ // runCaptureCommand's own stub above), so the prompt stub, DEFAULT_BOOTSTRAP
849
+ // and DEFAULT_URL here exist only to satisfy realCaptureDeps's shape, the
850
+ // same reason disable's call site needs them.
851
+ async function runCaptureStatusCommand() {
852
+ const deps = realCaptureDeps(() => {
853
+ throw new Error('waku-memory capture status: unexpectedly tried to prompt.');
854
+ }, DEFAULT_BOOTSTRAP, DEFAULT_URL, DEFAULT_INGEST_URL);
855
+ await status(deps);
856
+ }
542
857
  export function run(argv) {
543
858
  const d = dispatch(argv);
544
859
  switch (d.kind) {
@@ -561,7 +876,29 @@ export function run(argv) {
561
876
  process.exitCode = 1;
562
877
  return;
563
878
  case 'setup':
564
- setup(d.name, d.url);
879
+ void setup(d.name, d.url).catch((err) => {
880
+ console.error(`waku-memory setup: unexpected failure -- ${errorMessage(err)}.`);
881
+ process.exitCode = 1;
882
+ });
883
+ return;
884
+ case 'login':
885
+ // Ruling R15: the disclosure (and a blank line), no y/n gate -- unlike
886
+ // "capture enable", signing in on its own asks nothing about consent
887
+ // to *capture*, only about signing in, so there is no gate to hold it
888
+ // behind. Exit-code mapping mirrors "capture enable"'s own (see
889
+ // runCaptureCommand's own comment): 0 only on the fully successful
890
+ // 'signed-in', 1 for 'timeout' or 'refused' -- the one outcome
891
+ // distinction worth a nonzero exit for a scripted caller.
892
+ console.log(DISCLOSURE);
893
+ console.log('');
894
+ void login(realLoginDeps(d.url))
895
+ .then((result) => {
896
+ process.exitCode = result === 'signed-in' ? 0 : 1;
897
+ })
898
+ .catch((err) => {
899
+ console.error(`waku-memory login: unexpected failure -- ${errorMessage(err)}.`);
900
+ process.exitCode = 1;
901
+ });
565
902
  return;
566
903
  case 'hook':
567
904
  // Exits 0 always, per the brief: set up front, not left to whatever
@@ -573,12 +910,19 @@ export function run(argv) {
573
910
  // read and the JSON.parse that happen before it. The .catch() here is
574
911
  // a second backstop, not the mechanism this relies on.
575
912
  process.exitCode = 0;
576
- void runHookCommand().catch((err) => {
913
+ void runHookCommand(d.harness).catch((err) => {
577
914
  console.error(`waku-memory hook: unexpected failure -- ${errorMessage(err)}.`);
578
915
  });
579
916
  return;
580
917
  case 'capture':
581
- void runCaptureCommand(d.action, d.url, d.bootstrap).catch((err) => {
918
+ if (d.action === 'status') {
919
+ void runCaptureStatusCommand().catch((err) => {
920
+ console.error(`waku-memory capture: unexpected failure -- ${errorMessage(err)}.`);
921
+ process.exitCode = 1;
922
+ });
923
+ return;
924
+ }
925
+ void runCaptureCommand(d.action, d.url, d.mcpUrl, d.bootstrap, d.action === 'enable' ? d.credential : undefined).catch((err) => {
582
926
  console.error(`waku-memory capture: unexpected failure -- ${errorMessage(err)}.`);
583
927
  process.exitCode = 1;
584
928
  });