waku-memory 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +949 -0
- package/dist/capture.js +960 -117
- package/dist/cli.js +440 -63
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +139 -0
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +324 -144
- package/dist/login.js +312 -0
- package/dist/project.js +43 -0
- package/hooks/hooks.json +63 -0
- package/package.json +2 -2
- package/skills/waku/SKILL.md +23 -0
package/dist/cli.js
CHANGED
|
@@ -13,13 +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 {
|
|
16
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
17
18
|
import { homedir } from 'node:os';
|
|
18
19
|
import { basename, dirname, join } from 'node:path';
|
|
19
20
|
import { createInterface } from 'node:readline';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { DEFAULT_WINDOW_DAYS } from "./bootstrap.js";
|
|
23
|
+
import { getHarnesses, harnessConfigExists, mergeMcpConfig, profileFor, } from "./harnesses.js";
|
|
24
|
+
import { SHIM_VERSION, handleHookEvent } from "./hook.js";
|
|
25
|
+
import { DISCLOSURE, KEYS_PAGE_URL, disable, enable, status, urlPair, } from "./capture.js";
|
|
26
|
+
import { login } from "./login.js";
|
|
23
27
|
export const DEFAULT_URL = 'https://api.waku.one/mcp';
|
|
24
28
|
export const DEFAULT_NAME = 'waku';
|
|
25
29
|
// The ingest base capture.ts writes into config.json -- deliberately not
|
|
@@ -67,6 +71,12 @@ export function importNote() {
|
|
|
67
71
|
export function parseArgs(argv) {
|
|
68
72
|
let name = DEFAULT_NAME;
|
|
69
73
|
let url = DEFAULT_URL;
|
|
74
|
+
let since;
|
|
75
|
+
let all = false;
|
|
76
|
+
let noBootstrap = false;
|
|
77
|
+
let harness;
|
|
78
|
+
let login = false;
|
|
79
|
+
let key;
|
|
70
80
|
const positional = [];
|
|
71
81
|
for (let i = 0; i < argv.length; i++) {
|
|
72
82
|
const arg = argv[i];
|
|
@@ -76,6 +86,31 @@ export function parseArgs(argv) {
|
|
|
76
86
|
else if (arg === '--url' && i + 1 < argv.length) {
|
|
77
87
|
url = argv[++i];
|
|
78
88
|
}
|
|
89
|
+
else if (arg === '--since' && i + 1 < argv.length) {
|
|
90
|
+
since = argv[++i];
|
|
91
|
+
}
|
|
92
|
+
else if (arg === '--harness' && i + 1 < argv.length) {
|
|
93
|
+
harness = argv[++i];
|
|
94
|
+
}
|
|
95
|
+
else if (arg === '--all') {
|
|
96
|
+
all = true;
|
|
97
|
+
}
|
|
98
|
+
else if (arg === '--no-bootstrap') {
|
|
99
|
+
noBootstrap = true;
|
|
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
|
+
}
|
|
79
114
|
else if (!arg.startsWith('--')) {
|
|
80
115
|
positional.push(arg);
|
|
81
116
|
}
|
|
@@ -83,21 +118,30 @@ export function parseArgs(argv) {
|
|
|
83
118
|
// subcommand is only meaningful to the caller for "capture enable" /
|
|
84
119
|
// "capture disable" -- setup and hook both ignore it, same as they always
|
|
85
120
|
// ignored any positional beyond the first.
|
|
86
|
-
return { command: positional[0], subcommand: positional[1], name, url };
|
|
121
|
+
return { command: positional[0], subcommand: positional[1], name, url, since, all, noBootstrap, harness, login, key };
|
|
87
122
|
}
|
|
88
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('');
|
|
89
131
|
console.log('Usage: npx waku-memory setup [--name <name>] [--url <url>]');
|
|
90
132
|
console.log('');
|
|
91
133
|
console.log(` --name key to add under mcpServers (default: "${DEFAULT_NAME}")`);
|
|
92
134
|
console.log(` --url server URL to write (default: ${DEFAULT_URL})`);
|
|
93
135
|
console.log('');
|
|
94
|
-
console.log('Usage: waku-memory hook');
|
|
136
|
+
console.log('Usage: waku-memory hook [--harness <claude_code|codex>]');
|
|
137
|
+
console.log('');
|
|
138
|
+
console.log(' Reads one hook event as JSON on stdin and reports it to the server named');
|
|
139
|
+
console.log(' in ~/.waku-memory/config.json. Invoked by the harness itself (see');
|
|
140
|
+
console.log(' "capture enable") -- not meant to be run by hand.');
|
|
95
141
|
console.log('');
|
|
96
|
-
console.log('
|
|
97
|
-
console.log(' the server named in ~/.waku-memory/config.json. Invoked by Claude Code');
|
|
98
|
-
console.log(' itself (see "capture enable") -- not meant to be run by hand.');
|
|
142
|
+
console.log(' --harness which harness sent the event (default: "claude_code")');
|
|
99
143
|
console.log('');
|
|
100
|
-
console.log('Usage: waku-memory capture enable [--url <ingest-base>]');
|
|
144
|
+
console.log('Usage: waku-memory capture enable [--url <ingest-base>] [--since <N>d] [--all] [--no-bootstrap] [--login] [--key <key>]');
|
|
101
145
|
console.log('');
|
|
102
146
|
console.log(' Turns on automatic capture: every turn of your Claude Code sessions is');
|
|
103
147
|
console.log(' sent to our server and on to Anthropic for extraction. Shows that');
|
|
@@ -105,10 +149,42 @@ export function printUsage() {
|
|
|
105
149
|
console.log(` asks for an API key (mint one at ${KEYS_PAGE_URL})`);
|
|
106
150
|
console.log(' and installs the hook into ~/.claude/settings.json.');
|
|
107
151
|
console.log('');
|
|
152
|
+
console.log(' A third question then offers to import the memory and history Claude');
|
|
153
|
+
console.log(' Code already keeps on this machine -- its own memory files and session');
|
|
154
|
+
console.log(' transcripts -- asked only when it finds anything to offer.');
|
|
155
|
+
console.log('');
|
|
156
|
+
console.log(` --since <N>d only scan transcripts from the last N days (default: ${DEFAULT_WINDOW_DAYS}d)`);
|
|
157
|
+
console.log(' --all scan full history instead, ignoring --since');
|
|
158
|
+
console.log(' --no-bootstrap skip the scan and the third question entirely');
|
|
159
|
+
console.log(' --login sign in in the browser without asking');
|
|
160
|
+
console.log(' --key <key> use this key without asking');
|
|
161
|
+
console.log('');
|
|
108
162
|
console.log('Usage: waku-memory capture disable');
|
|
109
163
|
console.log('');
|
|
110
164
|
console.log(' Removes exactly the hook entries "capture enable" added. Your saved key');
|
|
111
165
|
console.log(' is left in place -- revoke it on the keys page if you want it gone too.');
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log('Usage: waku-memory capture status');
|
|
168
|
+
console.log('');
|
|
169
|
+
console.log(' Prints whether capture is installed for Claude Code and for Codex, and');
|
|
170
|
+
console.log(' whether a key is saved. Reads only -- never writes.');
|
|
171
|
+
}
|
|
172
|
+
// The default third question: on, with the full DEFAULT_WINDOW_DAYS window
|
|
173
|
+
// (spec 011 §8/§9, task 14). Used both as dispatch()'s no-flags-given result
|
|
174
|
+
// and, for "capture disable", unconditionally -- disable() never reads
|
|
175
|
+
// deps.bootstrap (see its own comment), so there is nothing to gain and a
|
|
176
|
+
// real footgun to avoid in parsing --since/--all/--no-bootstrap for an
|
|
177
|
+
// action that would silently ignore them.
|
|
178
|
+
const DEFAULT_BOOTSTRAP = { windowDays: DEFAULT_WINDOW_DAYS, enabled: true };
|
|
179
|
+
// Accepts a bare integer ("90") or an integer with a trailing "d" ("90d") --
|
|
180
|
+
// the brief's two named shapes, spec 011 §9. Anything else (empty, signed,
|
|
181
|
+
// fractional, trailing garbage after the "d") is null, which dispatch()
|
|
182
|
+
// turns into a 'bad-flag' rather than a silent fallback to the default: a
|
|
183
|
+
// person who typed --since clearly meant to change the window, so getting
|
|
184
|
+
// no error and the default anyway would be the more confusing wrong answer.
|
|
185
|
+
export function parseSinceDays(raw) {
|
|
186
|
+
const match = /^(\d+)d?$/.exec(raw);
|
|
187
|
+
return match ? Number(match[1]) : null;
|
|
112
188
|
}
|
|
113
189
|
export function dispatch(argv) {
|
|
114
190
|
// Checked ahead of parseArgs deliberately: --help/-h are flags, so
|
|
@@ -118,21 +194,111 @@ export function dispatch(argv) {
|
|
|
118
194
|
// existed to pin it. See cli.test.mjs's dispatch block.
|
|
119
195
|
if (argv.includes('--help') || argv.includes('-h'))
|
|
120
196
|
return { kind: 'help' };
|
|
121
|
-
const { command, subcommand, name, url } = parseArgs(argv);
|
|
197
|
+
const { command, subcommand, name, url, since, all, noBootstrap, harness, login: loginFlag, key } = parseArgs(argv);
|
|
122
198
|
if (command === undefined)
|
|
123
199
|
return { kind: 'no-command' };
|
|
124
|
-
if (command === '
|
|
125
|
-
|
|
200
|
+
if (command === 'login') {
|
|
201
|
+
// Same urlPair() rule "capture enable" uses -- see its own comment
|
|
202
|
+
// below: whatever shape --url was typed in, the ingest base is what
|
|
203
|
+
// realLoginDeps(d.url) (run()) and login.ts's own apiBase param need,
|
|
204
|
+
// never the "/mcp"-suffixed mcpUrl.
|
|
205
|
+
return { kind: 'login', url: urlPair(url).ingestUrl };
|
|
206
|
+
}
|
|
207
|
+
if (command === 'hook') {
|
|
208
|
+
// --harness with no value: parseArgs only consumes it when a next token
|
|
209
|
+
// exists, so a trailing "--harness" leaves `harness` undefined the same
|
|
210
|
+
// way a trailing "--since" does -- but unlike --since (which falls back
|
|
211
|
+
// to a default), a bare --harness is treated the same as an unknown id,
|
|
212
|
+
// per the brief: both are the caller trying to say something this shim
|
|
213
|
+
// does not understand.
|
|
214
|
+
if (argv.includes('--harness') && harness === undefined) {
|
|
215
|
+
return {
|
|
216
|
+
kind: 'bad-flag',
|
|
217
|
+
message: 'waku-memory hook: --harness requires a value (e.g. "codex") -- got none.',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const harnessId = harness ?? 'claude_code';
|
|
221
|
+
if (!profileFor(harnessId)) {
|
|
222
|
+
return {
|
|
223
|
+
kind: 'bad-flag',
|
|
224
|
+
message: `waku-memory hook: unknown harness "${harnessId}" -- expected one of: claude_code, codex.`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { kind: 'hook', harness: harnessId };
|
|
228
|
+
}
|
|
126
229
|
if (command === 'capture') {
|
|
127
|
-
if (subcommand !== 'enable' && subcommand !== 'disable')
|
|
230
|
+
if (subcommand !== 'enable' && subcommand !== 'disable' && subcommand !== 'status') {
|
|
128
231
|
return { kind: 'unknown', command: 'capture' };
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
232
|
+
}
|
|
233
|
+
if (subcommand === 'status')
|
|
234
|
+
return { kind: 'capture', action: 'status' };
|
|
235
|
+
// parseArgs's `url` already defaults to DEFAULT_URL (the "/mcp" literal)
|
|
236
|
+
// whenever --url is absent, so no argv.includes('--url') check is needed
|
|
237
|
+
// here any more (fix round 1 had one, to avoid trusting parseArgs's
|
|
238
|
+
// setup-shaped default -- round 2 replaces the whole thing with
|
|
239
|
+
// urlPair(), which produces the right pair from DEFAULT_URL exactly the
|
|
240
|
+
// same way it does from any --url a person types).
|
|
241
|
+
//
|
|
242
|
+
// Fix round 2 (re-review finding #1, spec 012 §4.2): urlPair() (capture.ts)
|
|
243
|
+
// is the one place that decides "given this one URL, what is the ingest
|
|
244
|
+
// base and what is the MCP endpoint" -- round 1's own rule (strip a
|
|
245
|
+
// trailing "/mcp" from mcpUrl to get ingestUrl) only worked when the
|
|
246
|
+
// input was already MCP-shaped; an ingest-shaped --url (no "/mcp" to
|
|
247
|
+
// strip, exactly the shape cli.ts's own usage text documents:
|
|
248
|
+
// "capture enable [--url <ingest-base>]") left mcpUrl === ingestUrl,
|
|
249
|
+
// reproducing the original defect. urlPair always derives the ingest
|
|
250
|
+
// base first and always appends "/mcp" to build mcpUrl, so every shape
|
|
251
|
+
// (bare, trailing slash, "/mcp"-suffixed) converges on the same pair.
|
|
252
|
+
const { ingestUrl, mcpUrl } = urlPair(url);
|
|
253
|
+
if (subcommand === 'disable') {
|
|
254
|
+
return { kind: 'capture', action: 'disable', url: ingestUrl, mcpUrl, bootstrap: DEFAULT_BOOTSTRAP };
|
|
255
|
+
}
|
|
256
|
+
// --since is validated unconditionally (even alongside --all) so a typo
|
|
257
|
+
// is never silently swallowed by an --all that happened to be present
|
|
258
|
+
// too; --all is then applied on top, since "scan everything" is the
|
|
259
|
+
// least surprising way to read the two stacked together (untested by
|
|
260
|
+
// the brief -- there is no one obviously-right answer here, but this at
|
|
261
|
+
// least never hides a bad --since value).
|
|
262
|
+
let windowDays = DEFAULT_WINDOW_DAYS;
|
|
263
|
+
if (since !== undefined) {
|
|
264
|
+
const parsed = parseSinceDays(since);
|
|
265
|
+
if (parsed === null) {
|
|
266
|
+
return {
|
|
267
|
+
kind: 'bad-flag',
|
|
268
|
+
message: `waku-memory capture enable: invalid --since value "${since}" -- expected a number of days, e.g. "90d" or "90".`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
windowDays = parsed;
|
|
272
|
+
}
|
|
273
|
+
if (all)
|
|
274
|
+
windowDays = null;
|
|
275
|
+
// --login / --key <key> (task 13, spec 012 §7): the credential decision
|
|
276
|
+
// capture.ts's enable() now takes as its own parameter -- see
|
|
277
|
+
// Credential's own comment (capture.ts) for why it lives there and not
|
|
278
|
+
// on CaptureDeps. Both bad-flag checks run unconditionally, the same
|
|
279
|
+
// way --since's does above, so a scripted caller gets a loud, specific
|
|
280
|
+
// error instead of the question silently being asked anyway.
|
|
281
|
+
if (loginFlag && key !== undefined) {
|
|
282
|
+
return {
|
|
283
|
+
kind: 'bad-flag',
|
|
284
|
+
message: 'waku-memory capture enable: --login and --key cannot be used together -- pick one.',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (argv.includes('--key') && key === undefined) {
|
|
288
|
+
return {
|
|
289
|
+
kind: 'bad-flag',
|
|
290
|
+
message: 'waku-memory capture enable: --key requires a value -- got none.',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const credential = key !== undefined ? { kind: 'key', key } : loginFlag ? { kind: 'login' } : { kind: 'ask' };
|
|
294
|
+
return {
|
|
295
|
+
kind: 'capture',
|
|
296
|
+
action: 'enable',
|
|
297
|
+
url: ingestUrl,
|
|
298
|
+
mcpUrl,
|
|
299
|
+
bootstrap: { windowDays, enabled: !noBootstrap },
|
|
300
|
+
credential,
|
|
301
|
+
};
|
|
136
302
|
}
|
|
137
303
|
if (command !== 'setup')
|
|
138
304
|
return { kind: 'unknown', command };
|
|
@@ -146,12 +312,22 @@ export function dispatch(argv) {
|
|
|
146
312
|
// a single byte back; a crash, a kill, or a full disk in that window
|
|
147
313
|
// leaves a real, in-use config -- ~78 KB and ~80 keys, by measurement --
|
|
148
314
|
// truncated or corrupted.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
315
|
+
//
|
|
316
|
+
// Shared by every writer below it (task 10, spec 012 §4.6): atomicWriteJson
|
|
317
|
+
// (settings.json, config files), writeJsonFile (Codex's hooks.json and the
|
|
318
|
+
// personal marketplace file) and writeCodexText (Codex's config.toml, whose
|
|
319
|
+
// content is already a complete file, not a value to serialise). The
|
|
320
|
+
// mkdirSync up front is new with those two Codex JSON writers -- neither
|
|
321
|
+
// ~/.codex nor ~/.agents/plugins is guaranteed to exist yet the first time
|
|
322
|
+
// capture enable runs on a Codex machine, unlike ~/.claude (whose presence
|
|
323
|
+
// is what enable() already checked before writing anything under it).
|
|
324
|
+
function atomicWrite(path, content) {
|
|
325
|
+
const dir = dirname(path);
|
|
326
|
+
mkdirSync(dir, { recursive: true });
|
|
327
|
+
const tmpPath = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
|
|
152
328
|
try {
|
|
153
|
-
writeFileSync(tmpPath,
|
|
154
|
-
renameSync(tmpPath,
|
|
329
|
+
writeFileSync(tmpPath, content, 'utf8');
|
|
330
|
+
renameSync(tmpPath, path);
|
|
155
331
|
}
|
|
156
332
|
catch (err) {
|
|
157
333
|
try {
|
|
@@ -165,7 +341,21 @@ export const atomicWriteJson = (configPath, data) => {
|
|
|
165
341
|
}
|
|
166
342
|
throw err;
|
|
167
343
|
}
|
|
168
|
-
}
|
|
344
|
+
}
|
|
345
|
+
export const atomicWriteJson = (configPath, data) => atomicWrite(configPath, JSON.stringify(data, null, 2) + '\n');
|
|
346
|
+
// capture.ts's CaptureDeps.writeJsonFile (task 10): the same atomic write as
|
|
347
|
+
// atomicWriteJson above, generalised to `unknown` -- capture.ts has no more
|
|
348
|
+
// opinion about CodexHooksFile vs MarketplaceFile than atomicWriteJson has
|
|
349
|
+
// about McpConfig beyond JSON.stringify, which is exactly why a function
|
|
350
|
+
// typed `(data: unknown) => void` is assignable wherever `WriteJson` is
|
|
351
|
+
// expected (parameter types are contravariant) and the reverse is not worth
|
|
352
|
+
// forcing through a cast.
|
|
353
|
+
export const writeJsonFile = (path, data) => atomicWrite(path, JSON.stringify(data, null, 2) + '\n');
|
|
354
|
+
// capture.ts's CaptureDeps.writeText (task 10): writes `text` exactly as
|
|
355
|
+
// given, no JSON.stringify and no added newline -- mergeCodexToml
|
|
356
|
+
// (codex-config.ts) already returns the complete file text, trailing
|
|
357
|
+
// newline included when the original file had one.
|
|
358
|
+
export const writeCodexText = (path, text) => atomicWrite(path, text);
|
|
169
359
|
// Adds `name` -> `url` to one harness's config file. Never throws: every
|
|
170
360
|
// fallible step -- read, parse, merge, write -- is caught right here, so a
|
|
171
361
|
// bad harness cannot stop the others from being tried and cannot crash the
|
|
@@ -274,7 +464,13 @@ export function summarize(results) {
|
|
|
274
464
|
hadError: results.some((r) => r.status === 'error'),
|
|
275
465
|
};
|
|
276
466
|
}
|
|
277
|
-
export function setup(name, url, harnesses = getHarnesses(),
|
|
467
|
+
export function setup(name, url, harnesses = getHarnesses(),
|
|
468
|
+
// R20 (final review, spec 012): the real default, injected the same way
|
|
469
|
+
// realCaptureDeps below builds it -- so cli.test.mjs can point this at a
|
|
470
|
+
// temp path instead of the machine's actual ~/.codex/config.toml, the
|
|
471
|
+
// same reason every other real path in this file is a parameter with a
|
|
472
|
+
// real default rather than a bare homedir() call inline.
|
|
473
|
+
codexConfigPath = join(homedir(), '.codex', 'config.toml')) {
|
|
278
474
|
const results = runSetup(harnesses, name, url);
|
|
279
475
|
for (const r of results) {
|
|
280
476
|
if (r.status === 'error')
|
|
@@ -282,42 +478,40 @@ export function setup(name, url, harnesses = getHarnesses(), manualHarnesses = g
|
|
|
282
478
|
else
|
|
283
479
|
console.log(r.message);
|
|
284
480
|
}
|
|
285
|
-
// Only the ones actually on this machine. A manual harness is reported
|
|
286
|
-
// as found -- it just cannot be written to -- so it counts toward
|
|
287
|
-
// "did we find anything at all" below, and specifically toward whether
|
|
288
|
-
// the import step is worth mentioning: someone who follows the
|
|
289
|
-
// instructions has a working server, same as an auto-configured one.
|
|
290
|
-
const manualPresent = manualHarnesses.filter(harnessConfigExists);
|
|
291
|
-
for (const h of manualPresent) {
|
|
292
|
-
console.log('');
|
|
293
|
-
console.log(h.note(name, url));
|
|
294
|
-
}
|
|
295
481
|
console.log('');
|
|
296
|
-
|
|
297
|
-
|
|
482
|
+
// Task 14 (spec 012 §9): setup() no longer has a manual-harness list to
|
|
483
|
+
// consult here -- Codex was its only member, and Codex now has a real,
|
|
484
|
+
// automatic route of its own ("capture enable", task 10), so there is
|
|
485
|
+
// nothing left this function can detect but not write. getHarnesses()
|
|
486
|
+
// still does not carry Codex (harnesses.ts's own comment has the fuller
|
|
487
|
+
// account), so a Codex-only machine still falls through to the plain "no
|
|
488
|
+
// supported harness config was found" branch below -- R20 below is what
|
|
489
|
+
// stops that from being a flatly false thing to print on such a machine.
|
|
490
|
+
const { foundAny, wroteAny, hadError } = summarize(results);
|
|
298
491
|
if (wroteAny) {
|
|
299
492
|
console.log('Next: open the harness and use a waku tool. The first call will get a 401, ' +
|
|
300
493
|
'the harness will open your browser to sign in, and the call will then go ' +
|
|
301
494
|
'through on retry -- that browser popup is expected, not an error.');
|
|
302
495
|
}
|
|
303
|
-
else if (
|
|
496
|
+
else if (foundAny && !hadError) {
|
|
304
497
|
console.log('Already configured correctly -- nothing to change.');
|
|
305
498
|
}
|
|
306
|
-
else if (
|
|
499
|
+
else if (foundAny && hadError) {
|
|
307
500
|
console.log('Could not update every harness found -- see the messages above.');
|
|
308
501
|
}
|
|
309
|
-
else if (manualPresent.length > 0) {
|
|
310
|
-
// Deliberately its own branch rather than falling into "already
|
|
311
|
-
// configured": a manual harness being *present* is not the same as it
|
|
312
|
-
// being *configured*, and telling someone nothing needs changing when
|
|
313
|
-
// they still have a TOML block to paste is the more expensive of the
|
|
314
|
-
// two possible wrong messages.
|
|
315
|
-
console.log('Nothing was written: the only harness found here is one this shim does not ' +
|
|
316
|
-
'configure automatically -- follow the instructions above to finish.');
|
|
317
|
-
}
|
|
318
502
|
else {
|
|
319
503
|
console.log('Nothing was added: no supported harness config was found on this machine.');
|
|
320
504
|
}
|
|
505
|
+
// R20 (final review, spec 012): the regression this whole-branch review
|
|
506
|
+
// caught -- "no supported harness config was found" is false on a machine
|
|
507
|
+
// that has ~/.codex/config.toml. setup() still writes nothing for Codex
|
|
508
|
+
// (getHarnesses() does not carry it, above), so this is advice, not a
|
|
509
|
+
// result: it fires independently of the found/wrote/error branch above,
|
|
510
|
+
// including alongside a real Claude Code result on a machine with both.
|
|
511
|
+
if (existsSync(codexConfigPath)) {
|
|
512
|
+
console.log('');
|
|
513
|
+
console.log('Codex was found. Run "npx waku-memory capture enable" to configure it.');
|
|
514
|
+
}
|
|
321
515
|
// Printed whenever a usable server exists on this machine -- which
|
|
322
516
|
// includes the already-configured re-run, since a configured server with
|
|
323
517
|
// nothing imported is exactly the state a re-runner is most likely to be
|
|
@@ -352,7 +546,7 @@ function readStdin() {
|
|
|
352
546
|
// actual config dir, the real global fetch). Deliberately never awaited by
|
|
353
547
|
// run() below -- see that call site's comment for why that is safe here
|
|
354
548
|
// specifically, which is not the same as it being safe in general.
|
|
355
|
-
async function runHookCommand() {
|
|
549
|
+
async function runHookCommand(harness) {
|
|
356
550
|
let raw;
|
|
357
551
|
try {
|
|
358
552
|
raw = await readStdin();
|
|
@@ -366,13 +560,99 @@ async function runHookCommand() {
|
|
|
366
560
|
event = JSON.parse(raw);
|
|
367
561
|
}
|
|
368
562
|
catch (err) {
|
|
369
|
-
// Malformed input is a no-op, not a crash: some future
|
|
370
|
-
//
|
|
371
|
-
//
|
|
563
|
+
// Malformed input is a no-op, not a crash: some future hook event this
|
|
564
|
+
// shim was not written against, or a hand test of `waku-memory hook`
|
|
565
|
+
// with no stdin piped in at all.
|
|
372
566
|
console.error(`waku-memory hook: stdin was not valid JSON -- ${errorMessage(err)}.`);
|
|
373
567
|
return;
|
|
374
568
|
}
|
|
375
|
-
await handleHookEvent(event, { configDir: join(homedir(), '.waku-memory'), fetchImpl: fetch });
|
|
569
|
+
await handleHookEvent(event, { configDir: join(homedir(), '.waku-memory'), fetchImpl: fetch, harness });
|
|
570
|
+
}
|
|
571
|
+
// The platform opener login.ts's LoginDeps.openBrowser needs (spec 012 §7):
|
|
572
|
+
// spawned detached and unref'd so it never blocks -- or waits on -- this
|
|
573
|
+
// process, and never throws: a failed spawn (no browser installed, no
|
|
574
|
+
// `xdg-open` on a headless machine) is fine, because the authorize URL is
|
|
575
|
+
// always printed first (login.ts's own login(), right before this runs).
|
|
576
|
+
// The 'error' listener exists for the same reason as the try/catch: a spawn
|
|
577
|
+
// failure can surface asynchronously (an ENOENT for a missing xdg-open,
|
|
578
|
+
// say) rather than as a synchronous throw, and an unhandled 'error' event
|
|
579
|
+
// on a ChildProcess crashes the process if nothing is listening for it.
|
|
580
|
+
// The argv/options openBrowser (below) hands to child_process.spawn, kept
|
|
581
|
+
// as a pure function of (platform, url) so a unit test can assert the exact
|
|
582
|
+
// shape without spawning a real process or opening a real browser (fix
|
|
583
|
+
// round 1, finding 1). The previous win32 shape --
|
|
584
|
+
// `spawn('cmd', ['/c', 'start', '""', url])`, no shell, no verbatim-argument
|
|
585
|
+
// flag -- was Critical-broken for any real authorize URL: Node's default
|
|
586
|
+
// Windows argv quoting only wraps an argument in quotes when it contains
|
|
587
|
+
// whitespace, so an `&`-separated OAuth URL passed through untouched, and
|
|
588
|
+
// cmd.exe then read each `&` as its own command separator, truncating the
|
|
589
|
+
// URL at the first `&` and failing on every parameter after it as an
|
|
590
|
+
// unrecognized command.
|
|
591
|
+
//
|
|
592
|
+
// Chosen fix: shape (a) from the controller's ruling, not (b)
|
|
593
|
+
// (`rundll32 url.dll,FileProtocolHandler`) -- `cmd /c start` is the
|
|
594
|
+
// documented way to hand a URL to the OS's own default-handler resolution
|
|
595
|
+
// (same as double-clicking a link), while rundll32's undocumented internal
|
|
596
|
+
// entry point is a narrower target to keep behaving identically across
|
|
597
|
+
// Windows versions.
|
|
598
|
+
// `windowsVerbatimArguments: true` stops Node from re-quoting each argv
|
|
599
|
+
// entry (the exact thing that let `""` and the URL arrive at cmd.exe
|
|
600
|
+
// unquoted in the first place); wrapping the URL in its own literal double
|
|
601
|
+
// quotes (`` `"${url}"` ``) is then what makes cmd.exe treat everything
|
|
602
|
+
// between them -- `&` included -- as one argument instead of a command
|
|
603
|
+
// separator. The leading `'""'` argument is unrelated to the URL: it is
|
|
604
|
+
// `start`'s own window-title argument, required so `start` does not
|
|
605
|
+
// mistake a quoted first argument (the URL, if it came first) for a title
|
|
606
|
+
// instead of the command to run. Reproduced directly from Bash on this
|
|
607
|
+
// machine, adapted from the reviewer's own repro (see cli.test.mjs's
|
|
608
|
+
// nearby test and this task's report for the transcript): `cmd /c echo ""
|
|
609
|
+
// "https://x/a?b=1&c=2"` under `windowsVerbatimArguments: true` prints the
|
|
610
|
+
// URL whole, `&` and all -- `echo` standing in for `start` the same way the
|
|
611
|
+
// reviewer's own reproduction used it.
|
|
612
|
+
//
|
|
613
|
+
// darwin/linux are untouched: `open`/`xdg-open` never go through a shell,
|
|
614
|
+
// so Node's own (correct, non-Windows) argv handling was never the problem
|
|
615
|
+
// there.
|
|
616
|
+
export function buildBrowserOpener(platform, url) {
|
|
617
|
+
if (platform === 'win32') {
|
|
618
|
+
return {
|
|
619
|
+
command: 'cmd',
|
|
620
|
+
args: ['/c', 'start', '""', `"${url}"`],
|
|
621
|
+
options: { detached: true, stdio: 'ignore', windowsVerbatimArguments: true },
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (platform === 'darwin') {
|
|
625
|
+
return { command: 'open', args: [url], options: { detached: true, stdio: 'ignore' } };
|
|
626
|
+
}
|
|
627
|
+
return { command: 'xdg-open', args: [url], options: { detached: true, stdio: 'ignore' } };
|
|
628
|
+
}
|
|
629
|
+
function openBrowser(url) {
|
|
630
|
+
try {
|
|
631
|
+
const { command, args, options } = buildBrowserOpener(process.platform, url);
|
|
632
|
+
const child = spawn(command, args, options);
|
|
633
|
+
child.on('error', () => {
|
|
634
|
+
// Never throws -- the URL was already printed (login.ts's login()).
|
|
635
|
+
});
|
|
636
|
+
child.unref();
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// Same reasoning as the 'error' listener above, for the rarer case
|
|
640
|
+
// where spawn() itself throws synchronously.
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// login.ts's LoginDeps, built the one way this shim ever builds it -- both
|
|
644
|
+
// `waku-memory login` (run()'s own 'login' case) and "capture enable"'s
|
|
645
|
+
// login Credential (realCaptureDeps below) call this with the same
|
|
646
|
+
// ingest-base `apiBase` dispatch() already computed.
|
|
647
|
+
function realLoginDeps(apiBase) {
|
|
648
|
+
return {
|
|
649
|
+
apiBase,
|
|
650
|
+
configDir: join(homedir(), '.waku-memory'),
|
|
651
|
+
fetchImpl: fetch,
|
|
652
|
+
openBrowser,
|
|
653
|
+
stdout: (text) => console.log(text),
|
|
654
|
+
version: SHIM_VERSION,
|
|
655
|
+
};
|
|
376
656
|
}
|
|
377
657
|
// The real deps enable()/disable() run against outside a test: real paths
|
|
378
658
|
// under the user's home directory, an injected prompt (built by the two
|
|
@@ -383,14 +663,62 @@ async function runHookCommand() {
|
|
|
383
663
|
// JSON-shaped types meet. Safe in practice: atomicWriteJson only ever does
|
|
384
664
|
// JSON.stringify(data) -- it has no opinion about McpConfig vs
|
|
385
665
|
// ClaudeSettings beyond the type checker's.
|
|
386
|
-
|
|
666
|
+
//
|
|
667
|
+
// hookSourceDir is this module's own directory, not a fixed constant: as
|
|
668
|
+
// published, cli.ts compiles to dist/cli.js and runs from there, so
|
|
669
|
+
// import.meta.url resolves to dist -- exactly the directory installHookCopy
|
|
670
|
+
// needs (spec 011 §6, task 11). verifyHook actually runs the entry once
|
|
671
|
+
// with a throwaway event before enable() trusts it, so a corrupted copy or
|
|
672
|
+
// a Node too old for it is caught before settings.json is ever touched.
|
|
673
|
+
//
|
|
674
|
+
// fetchImpl is task 13's addition (spec 011 §8): the real global fetch, for
|
|
675
|
+
// runBootstrap's own network calls. bootstrap is now a parameter rather than
|
|
676
|
+
// a hardcoded default (task 14): dispatch() has already turned
|
|
677
|
+
// --since/--all/--no-bootstrap (or their absence) into this same shape, so
|
|
678
|
+
// this function's only job is to place it on the deps bag unchanged, the
|
|
679
|
+
// same way `prompt` already arrives built by its own call site below.
|
|
680
|
+
function realCaptureDeps(prompt, bootstrap,
|
|
681
|
+
// Fix round 1 (spec 012 §4.2): the MCP-shaped url (dispatch()'s mcpUrl,
|
|
682
|
+
// computed from --url BEFORE it was stripped to the ingest base) --
|
|
683
|
+
// passed alongside bootstrap the same way disable()'s and status()'s call
|
|
684
|
+
// sites already pass a bootstrap they never use, so every caller builds
|
|
685
|
+
// deps the same way regardless of whether its own action reads the field.
|
|
686
|
+
mcpUrl,
|
|
687
|
+
// Task 13's addition (spec 012 §7): the ingest base (dispatch()'s
|
|
688
|
+
// ingestUrl) realLoginDeps needs for deps.login below -- every call site
|
|
689
|
+
// already has this value (it is enable()'s own `url` parameter, or
|
|
690
|
+
// disable's/status's respective stand-ins), so, like bootstrap and mcpUrl
|
|
691
|
+
// above, it is threaded through unconditionally rather than only for the
|
|
692
|
+
// one action that can actually reach the credential question.
|
|
693
|
+
ingestUrl) {
|
|
387
694
|
const claudeDir = join(homedir(), '.claude');
|
|
695
|
+
const codexDir = join(homedir(), '.codex');
|
|
388
696
|
return {
|
|
389
697
|
claudeDir,
|
|
390
698
|
settingsPath: join(claudeDir, 'settings.json'),
|
|
391
699
|
configDir: join(homedir(), '.waku-memory'),
|
|
700
|
+
hookSourceDir: dirname(fileURLToPath(import.meta.url)),
|
|
701
|
+
version: SHIM_VERSION,
|
|
702
|
+
execPath: process.execPath,
|
|
703
|
+
verifyHook: (run) => spawnSync(run.command, run.args ?? [], { input: '{"hook_event_name":"Nope"}\n', timeout: 10_000 }).status === 0,
|
|
392
704
|
prompt,
|
|
393
705
|
writeSettingsJson: (path, data) => atomicWriteJson(path, data),
|
|
706
|
+
fetchImpl: fetch,
|
|
707
|
+
bootstrap,
|
|
708
|
+
// Codex (task 10, spec 012 §4.6): detected the same way claudeDir is --
|
|
709
|
+
// existence of the path is the evidence. codexPluginsCacheDir matches
|
|
710
|
+
// codexPluginPresent's own comment (codex-config.ts) on where Codex
|
|
711
|
+
// caches an installed plugin; marketplacePath is the personal
|
|
712
|
+
// marketplace every machine gets one of, regardless of which harnesses
|
|
713
|
+
// it has (~/.agents is not itself Codex-specific).
|
|
714
|
+
codexConfigPath: join(codexDir, 'config.toml'),
|
|
715
|
+
codexHooksPath: join(codexDir, 'hooks.json'),
|
|
716
|
+
codexPluginsCacheDir: join(codexDir, 'plugins', 'cache'),
|
|
717
|
+
marketplacePath: join(homedir(), '.agents', 'plugins', 'marketplace.json'),
|
|
718
|
+
mcpUrl,
|
|
719
|
+
writeText: writeCodexText,
|
|
720
|
+
writeJsonFile,
|
|
721
|
+
login: () => login(realLoginDeps(ingestUrl)),
|
|
394
722
|
};
|
|
395
723
|
}
|
|
396
724
|
// Not awaited by run() below, for the same structural reason runHookCommand
|
|
@@ -401,14 +729,21 @@ function realCaptureDeps(prompt) {
|
|
|
401
729
|
// safely touch) is the one outcome that should make a scripted caller
|
|
402
730
|
// notice. 'declined' and the disable no-op are both a normal, successful
|
|
403
731
|
// run that did exactly what was asked -- nothing.
|
|
404
|
-
async function runCaptureCommand(action, url
|
|
732
|
+
async function runCaptureCommand(action, url, mcpUrl, bootstrap,
|
|
733
|
+
// undefined for 'disable' (Dispatch's disable variant carries no
|
|
734
|
+
// credential at all -- see its own comment); always one of the three
|
|
735
|
+
// Credential kinds for 'enable', built by dispatch()'s capture-enable
|
|
736
|
+
// branch from --login/--key or their absence.
|
|
737
|
+
credential) {
|
|
405
738
|
if (action === 'disable') {
|
|
406
739
|
// disable() never prompts (see its own comment) -- no readline interface
|
|
407
740
|
// is created at all, and this stub is a fail-loud guard against that
|
|
408
|
-
// ever silently stopping being true.
|
|
741
|
+
// ever silently stopping being true. bootstrap is dispatch()'s
|
|
742
|
+
// DEFAULT_BOOTSTRAP here regardless of what was on argv -- disable()
|
|
743
|
+
// itself never reads deps.bootstrap either.
|
|
409
744
|
const deps = realCaptureDeps(() => {
|
|
410
745
|
throw new Error('waku-memory capture disable: unexpectedly tried to prompt.');
|
|
411
|
-
});
|
|
746
|
+
}, bootstrap, mcpUrl, url);
|
|
412
747
|
const result = await disable(deps);
|
|
413
748
|
if (result === 'refused')
|
|
414
749
|
process.exitCode = 1;
|
|
@@ -446,7 +781,7 @@ async function runCaptureCommand(action, url) {
|
|
|
446
781
|
const { value, done } = await lines.next();
|
|
447
782
|
return done ? '' : value; // stdin closed before an answer arrived -- treated as "declined"
|
|
448
783
|
};
|
|
449
|
-
const result = await enable(url, realCaptureDeps(prompt));
|
|
784
|
+
const result = await enable(url, realCaptureDeps(prompt, bootstrap, mcpUrl, url), credential);
|
|
450
785
|
if (result === 'refused')
|
|
451
786
|
process.exitCode = 1;
|
|
452
787
|
}
|
|
@@ -454,6 +789,17 @@ async function runCaptureCommand(action, url) {
|
|
|
454
789
|
rl.close();
|
|
455
790
|
}
|
|
456
791
|
}
|
|
792
|
+
// status() never prompts, never touches deps.bootstrap, and (ruling R8,
|
|
793
|
+
// fix round 1) needs no url at all -- same as disable() -- see
|
|
794
|
+
// runCaptureCommand's own stub above), so the prompt stub, DEFAULT_BOOTSTRAP
|
|
795
|
+
// and DEFAULT_URL here exist only to satisfy realCaptureDeps's shape, the
|
|
796
|
+
// same reason disable's call site needs them.
|
|
797
|
+
async function runCaptureStatusCommand() {
|
|
798
|
+
const deps = realCaptureDeps(() => {
|
|
799
|
+
throw new Error('waku-memory capture status: unexpectedly tried to prompt.');
|
|
800
|
+
}, DEFAULT_BOOTSTRAP, DEFAULT_URL, DEFAULT_INGEST_URL);
|
|
801
|
+
await status(deps);
|
|
802
|
+
}
|
|
457
803
|
export function run(argv) {
|
|
458
804
|
const d = dispatch(argv);
|
|
459
805
|
switch (d.kind) {
|
|
@@ -470,9 +816,33 @@ export function run(argv) {
|
|
|
470
816
|
printUsage();
|
|
471
817
|
process.exitCode = 1;
|
|
472
818
|
return;
|
|
819
|
+
case 'bad-flag':
|
|
820
|
+
console.error(d.message);
|
|
821
|
+
printUsage();
|
|
822
|
+
process.exitCode = 1;
|
|
823
|
+
return;
|
|
473
824
|
case 'setup':
|
|
474
825
|
setup(d.name, d.url);
|
|
475
826
|
return;
|
|
827
|
+
case 'login':
|
|
828
|
+
// Ruling R15: the disclosure (and a blank line), no y/n gate -- unlike
|
|
829
|
+
// "capture enable", signing in on its own asks nothing about consent
|
|
830
|
+
// to *capture*, only about signing in, so there is no gate to hold it
|
|
831
|
+
// behind. Exit-code mapping mirrors "capture enable"'s own (see
|
|
832
|
+
// runCaptureCommand's own comment): 0 only on the fully successful
|
|
833
|
+
// 'signed-in', 1 for 'timeout' or 'refused' -- the one outcome
|
|
834
|
+
// distinction worth a nonzero exit for a scripted caller.
|
|
835
|
+
console.log(DISCLOSURE);
|
|
836
|
+
console.log('');
|
|
837
|
+
void login(realLoginDeps(d.url))
|
|
838
|
+
.then((result) => {
|
|
839
|
+
process.exitCode = result === 'signed-in' ? 0 : 1;
|
|
840
|
+
})
|
|
841
|
+
.catch((err) => {
|
|
842
|
+
console.error(`waku-memory login: unexpected failure -- ${errorMessage(err)}.`);
|
|
843
|
+
process.exitCode = 1;
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
476
846
|
case 'hook':
|
|
477
847
|
// Exits 0 always, per the brief: set up front, not left to whatever
|
|
478
848
|
// the default happens to be, and never touched again on any failure
|
|
@@ -483,12 +853,19 @@ export function run(argv) {
|
|
|
483
853
|
// read and the JSON.parse that happen before it. The .catch() here is
|
|
484
854
|
// a second backstop, not the mechanism this relies on.
|
|
485
855
|
process.exitCode = 0;
|
|
486
|
-
void runHookCommand().catch((err) => {
|
|
856
|
+
void runHookCommand(d.harness).catch((err) => {
|
|
487
857
|
console.error(`waku-memory hook: unexpected failure -- ${errorMessage(err)}.`);
|
|
488
858
|
});
|
|
489
859
|
return;
|
|
490
860
|
case 'capture':
|
|
491
|
-
|
|
861
|
+
if (d.action === 'status') {
|
|
862
|
+
void runCaptureStatusCommand().catch((err) => {
|
|
863
|
+
console.error(`waku-memory capture: unexpected failure -- ${errorMessage(err)}.`);
|
|
864
|
+
process.exitCode = 1;
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
void runCaptureCommand(d.action, d.url, d.mcpUrl, d.bootstrap, d.action === 'enable' ? d.credential : undefined).catch((err) => {
|
|
492
869
|
console.error(`waku-memory capture: unexpected failure -- ${errorMessage(err)}.`);
|
|
493
870
|
process.exitCode = 1;
|
|
494
871
|
});
|