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