forget-connect 0.5.2 → 0.6.1
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/README.md +77 -21
- package/assets/hooks/forget_bstate.py +229 -0
- package/assets/hooks/forget_capture.py +42 -6
- package/assets/hooks/forget_sessionstart.py +41 -4
- package/assets/hooks/forget_toolrecall.py +118 -0
- package/assets/hooks/forget_turnrecall.py +664 -45
- package/assets/plugins/memory-agent/.claude-plugin/plugin.json +15 -0
- package/assets/plugins/memory-agent/.codex-plugin/plugin.json +29 -0
- package/assets/plugins/memory-agent/README.md +9 -0
- package/assets/plugins/memory-agent/skills/memory-agent/SKILL.md +22 -0
- package/assets/plugins/memory-agent/skills/memory-agent/agents/openai.yaml +6 -0
- package/assets/proxy/forget_proxy_watchdog.py +215 -0
- package/assets/skills/memory-agent/SKILL.md +22 -0
- package/assets/skills/memory-agent/agents/openai.yaml +6 -0
- package/package.json +7 -3
- package/src/cli.js +447 -58
- package/src/core.js +28 -9
- package/src/doctor.js +23 -0
- package/src/hooks.js +50 -20
- package/src/proxy.js +526 -0
- package/src/skills.js +95 -0
package/src/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import readline from "node:readline/promises";
|
|
4
5
|
import { stdin, stdout, stderr } from "node:process";
|
|
5
6
|
import {
|
|
@@ -20,12 +21,13 @@ import {
|
|
|
20
21
|
validateScopeId,
|
|
21
22
|
validateApiKey,
|
|
22
23
|
} from "./core.js";
|
|
23
|
-
import { doctorRemote } from "./doctor.js";
|
|
24
|
+
import { CODEX_REQUIRED_TOOLS, doctorRemote } from "./doctor.js";
|
|
24
25
|
import {
|
|
25
26
|
commandsDirFor,
|
|
26
27
|
connectHooksSettings,
|
|
27
28
|
disconnectHooksSettings,
|
|
28
29
|
hooksDirFor,
|
|
30
|
+
hooksInstalled,
|
|
29
31
|
inspectHooks,
|
|
30
32
|
installCommandAssets,
|
|
31
33
|
installHookScripts,
|
|
@@ -33,6 +35,29 @@ import {
|
|
|
33
35
|
removeHookScripts,
|
|
34
36
|
settingsPathFor,
|
|
35
37
|
} from "./hooks.js";
|
|
38
|
+
import {
|
|
39
|
+
PROXY_BASE_URL,
|
|
40
|
+
PROXY_LABEL,
|
|
41
|
+
WATCHDOG_LABEL,
|
|
42
|
+
captureInfo,
|
|
43
|
+
legacyLine,
|
|
44
|
+
purgeCaptureBefore,
|
|
45
|
+
formatBytes,
|
|
46
|
+
purgeCapture,
|
|
47
|
+
findExecutable,
|
|
48
|
+
installProxyServices,
|
|
49
|
+
readWiringState,
|
|
50
|
+
removeProxyServices,
|
|
51
|
+
unwireProxyEnv,
|
|
52
|
+
wireProxyEnv,
|
|
53
|
+
writeWiringState,
|
|
54
|
+
} from "./proxy.js";
|
|
55
|
+
import {
|
|
56
|
+
inspectMemoryAgentSkill,
|
|
57
|
+
installMemoryAgentSkill,
|
|
58
|
+
removeMemoryAgentSkill,
|
|
59
|
+
skillDirFor,
|
|
60
|
+
} from "./skills.js";
|
|
36
61
|
|
|
37
62
|
const CLIENT_IDS = new Set(["claude-code", "codex", "claude-desktop"]);
|
|
38
63
|
|
|
@@ -44,6 +69,7 @@ Usage:
|
|
|
44
69
|
forget-connect status [options]
|
|
45
70
|
forget-connect doctor [options]
|
|
46
71
|
forget-connect disconnect [options]
|
|
72
|
+
forget-connect capture purge --before <YYYY-MM-DD> delete older capture day files (asks yes)
|
|
47
73
|
|
|
48
74
|
Options:
|
|
49
75
|
--client <ids> Comma-separated: claude-code,codex,claude-desktop,all
|
|
@@ -55,13 +81,25 @@ Options:
|
|
|
55
81
|
|
|
56
82
|
Scope:
|
|
57
83
|
Local connections default to one canonical scoped endpoint (all clients share it):
|
|
58
|
-
/mcp
|
|
59
|
-
|
|
60
|
-
--no-scope. An explicit --url is
|
|
84
|
+
/mcp/forget/http/<os-username>. This keeps each user's vault isolated while
|
|
85
|
+
Codex and Claude retain distinct provider profiles and provenance. Override
|
|
86
|
+
with --user-id/--app-id, or opt out with --no-scope. An explicit --url is
|
|
87
|
+
installed verbatim.
|
|
61
88
|
--no-auth Connect without a Bearer token
|
|
89
|
+
--local-auth Send FORGET_API_KEY to an explicit loopback server
|
|
90
|
+
(required by credential-bound Memory Agent tools)
|
|
62
91
|
--no-rules Do not manage CLAUDE.md or AGENTS.md instruction blocks
|
|
63
|
-
--no-
|
|
64
|
-
|
|
92
|
+
--no-skill Do not install the shared Memory Agent skill
|
|
93
|
+
--no-hooks Do not install Codex/Claude Code memory hooks (session
|
|
94
|
+
capsule, per-turn recall, session capture)
|
|
95
|
+
--proxy OPT-IN (macOS only): wire the local capture proxy (launchd
|
|
96
|
+
service + ANTHROPIC_BASE_URL override + health watchdog).
|
|
97
|
+
It records every Claude Code request and response
|
|
98
|
+
(secret-scrubbed) in ~/.forget/proxy/stream/, kept 7 days,
|
|
99
|
+
max 2 GB. Off unless you pass --proxy.
|
|
100
|
+
--no-proxy Accepted for compatibility; the proxy is already off by default
|
|
101
|
+
--purge-capture With disconnect: also delete ~/.forget/proxy/stream
|
|
102
|
+
(otherwise only its size is reported and it is kept)
|
|
65
103
|
--no-migrate-enacta Keep matching legacy config and rules blocks
|
|
66
104
|
--dry-run Show the files that would change without writing them
|
|
67
105
|
--timeout <seconds> Doctor network timeout (default: 10)
|
|
@@ -73,6 +111,7 @@ Scope:
|
|
|
73
111
|
Authentication:
|
|
74
112
|
The default local server needs no token. For --hosted (legacy) set
|
|
75
113
|
FORGET_API_KEY, or run interactively and paste the key when prompted.
|
|
114
|
+
Credential-bound local tools require FORGET_API_KEY plus --local-auth.
|
|
76
115
|
Keys are intentionally not accepted as command-line arguments.
|
|
77
116
|
|
|
78
117
|
Examples:
|
|
@@ -125,8 +164,12 @@ export function parseArgs(argv, env = process.env) {
|
|
|
125
164
|
scope: null,
|
|
126
165
|
defaultScope: null,
|
|
127
166
|
auth: true,
|
|
167
|
+
localAuth: false,
|
|
128
168
|
rules: true,
|
|
169
|
+
skills: true,
|
|
129
170
|
hooks: true,
|
|
171
|
+
proxy: false,
|
|
172
|
+
purgeCapture: false,
|
|
130
173
|
migrateLegacy: true,
|
|
131
174
|
dryRun: false,
|
|
132
175
|
yes: false,
|
|
@@ -139,9 +182,16 @@ export function parseArgs(argv, env = process.env) {
|
|
|
139
182
|
let actionSeen = false;
|
|
140
183
|
for (let index = 0; index < argv.length; index += 1) {
|
|
141
184
|
const arg = argv[index];
|
|
142
|
-
if (["connect", "disconnect", "status", "doctor"].includes(arg) && !actionSeen) {
|
|
185
|
+
if (["connect", "disconnect", "status", "doctor", "capture"].includes(arg) && !actionSeen) {
|
|
143
186
|
options.action = arg;
|
|
144
187
|
actionSeen = true;
|
|
188
|
+
} else if (options.action === "capture" && arg === "purge") {
|
|
189
|
+
options.captureSub = "purge";
|
|
190
|
+
} else if (arg === "--before") {
|
|
191
|
+
options.before = requireValue(argv, index, arg);
|
|
192
|
+
index += 1;
|
|
193
|
+
} else if (arg.startsWith("--before=")) {
|
|
194
|
+
options.before = arg.slice("--before=".length);
|
|
145
195
|
} else if (arg === "--client") {
|
|
146
196
|
options.clientValues.push(requireValue(argv, index, arg));
|
|
147
197
|
index += 1;
|
|
@@ -170,10 +220,20 @@ export function parseArgs(argv, env = process.env) {
|
|
|
170
220
|
options.noScope = true;
|
|
171
221
|
} else if (arg === "--no-auth") {
|
|
172
222
|
options.auth = false;
|
|
223
|
+
} else if (arg === "--local-auth") {
|
|
224
|
+
options.localAuth = true;
|
|
173
225
|
} else if (arg === "--no-rules") {
|
|
174
226
|
options.rules = false;
|
|
227
|
+
} else if (arg === "--no-skill") {
|
|
228
|
+
options.skills = false;
|
|
175
229
|
} else if (arg === "--no-hooks") {
|
|
176
230
|
options.hooks = false;
|
|
231
|
+
} else if (arg === "--proxy") {
|
|
232
|
+
options.proxy = true;
|
|
233
|
+
} else if (arg === "--no-proxy") {
|
|
234
|
+
options.proxy = false;
|
|
235
|
+
} else if (arg === "--purge-capture") {
|
|
236
|
+
options.purgeCapture = true;
|
|
177
237
|
} else if (arg === "--no-migrate-enacta") {
|
|
178
238
|
options.migrateLegacy = false;
|
|
179
239
|
} else if (arg === "--dry-run") {
|
|
@@ -211,6 +271,12 @@ export function parseArgs(argv, env = process.env) {
|
|
|
211
271
|
}
|
|
212
272
|
options.baseUrl = normalizeUrl(options.url);
|
|
213
273
|
options.hosted = isHostedBaseUrl(options.baseUrl);
|
|
274
|
+
if (options.localAuth && !options.auth) {
|
|
275
|
+
throw new ConfigError("--local-auth and --no-auth are mutually exclusive.");
|
|
276
|
+
}
|
|
277
|
+
if (options.localAuth && !isLoopbackUrl(options.baseUrl)) {
|
|
278
|
+
throw new ConfigError("--local-auth is only allowed for a loopback MCP URL.");
|
|
279
|
+
}
|
|
214
280
|
if (Boolean(options.userId) !== Boolean(options.appId)) {
|
|
215
281
|
throw new ConfigError("--user-id and --app-id must be provided together.");
|
|
216
282
|
}
|
|
@@ -226,12 +292,10 @@ export function parseArgs(argv, env = process.env) {
|
|
|
226
292
|
options.url = options.scope
|
|
227
293
|
? scopedMcpUrl(options.baseUrl, options.scope)
|
|
228
294
|
: options.baseUrl;
|
|
229
|
-
// Cold-install default:
|
|
230
|
-
// /mcp
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// default. An explicit --url stays verbatim, and hosted keeps requiring an
|
|
234
|
-
// explicit account identity — the OS username is not a hosted account.
|
|
295
|
+
// Cold-install default: all providers use one personal vault at
|
|
296
|
+
// /mcp/forget/http/<os-username>; the query profile keeps their tool surface
|
|
297
|
+
// and provenance separate. The unscoped /mcp endpoint pools data into the
|
|
298
|
+
// server fallback, so it remains an explicit opt-in (--no-scope).
|
|
235
299
|
options.defaultScope = null;
|
|
236
300
|
if (!options.scope && !options.noScope && !options.hosted && !options.urlExplicit) {
|
|
237
301
|
const osUser = defaultScopeUserId(env);
|
|
@@ -259,17 +323,23 @@ function defaultScopeUserId(env) {
|
|
|
259
323
|
}
|
|
260
324
|
|
|
261
325
|
export function urlForClient(options, client) {
|
|
262
|
-
|
|
326
|
+
let resolved = options.url;
|
|
327
|
+
if (options.scope) resolved = options.url;
|
|
263
328
|
if (options.defaultScope && client) {
|
|
264
329
|
// Every client shares the canonical pool; which tool wrote a memory is
|
|
265
330
|
// provenance, not a scope boundary (issue #27). A per-client pool made
|
|
266
331
|
// Codex writes invisible to Claude and vice versa.
|
|
267
|
-
|
|
332
|
+
resolved = scopedMcpUrl(options.baseUrl, {
|
|
268
333
|
userId: options.defaultScope.userId,
|
|
269
334
|
appId: CANONICAL_APP_ID,
|
|
270
335
|
});
|
|
271
336
|
}
|
|
272
|
-
|
|
337
|
+
if (client?.id === "codex" || client?.id === "claude-code") {
|
|
338
|
+
const parsed = new URL(normalizeUrl(resolved));
|
|
339
|
+
parsed.searchParams.set("profile", client.id === "codex" ? "codex" : "claude");
|
|
340
|
+
return parsed.toString().replace(/\/$/, "");
|
|
341
|
+
}
|
|
342
|
+
return resolved;
|
|
273
343
|
}
|
|
274
344
|
|
|
275
345
|
function requestedClientIds(values) {
|
|
@@ -379,6 +449,16 @@ function isLoopbackUrl(value) {
|
|
|
379
449
|
return LOOPBACK_HOSTS.has(new URL(value).hostname.toLowerCase());
|
|
380
450
|
}
|
|
381
451
|
|
|
452
|
+
export async function readLocalApiKey(env = process.env) {
|
|
453
|
+
// $FORGET_HOME/api-key is written (0600) by `forget install-service`.
|
|
454
|
+
const home = env.FORGET_HOME || path.join(env.HOME ?? env.USERPROFILE ?? os.homedir(), ".forget");
|
|
455
|
+
try {
|
|
456
|
+
return (await readFile(path.join(home, "api-key"), "utf8")).trim();
|
|
457
|
+
} catch {
|
|
458
|
+
return "";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
382
462
|
async function apiKeyFor(options, env) {
|
|
383
463
|
if (!["connect", "doctor"].includes(options.action) || !options.auth || options.dryRun) return "";
|
|
384
464
|
const fromEnv = env.FORGET_API_KEY?.trim();
|
|
@@ -389,12 +469,23 @@ async function apiKeyFor(options, env) {
|
|
|
389
469
|
"Bearer authentication requires HTTPS. Use --no-auth for a local HTTP server.",
|
|
390
470
|
);
|
|
391
471
|
}
|
|
392
|
-
|
|
393
|
-
//
|
|
472
|
+
if (options.localAuth) return validateApiKey(fromEnv);
|
|
473
|
+
// A leftover hosted key must not silently become a local credential.
|
|
474
|
+
// Credential-bound local features opt in with --local-auth.
|
|
394
475
|
stderr.write(
|
|
395
476
|
"Note: FORGET_API_KEY is set but the target is a loopback HTTP server; connecting without a token.\n",
|
|
396
477
|
);
|
|
397
478
|
}
|
|
479
|
+
// Local server: send the install's own key. Harmless while the server does
|
|
480
|
+
// not require auth, and required once it does (phase 2). Not used by doctor,
|
|
481
|
+
// which checks it separately and only warns.
|
|
482
|
+
if (!options.hosted && isLoopbackUrl(options.url) && options.action === "connect") {
|
|
483
|
+
const localKey = await readLocalApiKey(env);
|
|
484
|
+
if (localKey) return validateApiKey(localKey);
|
|
485
|
+
}
|
|
486
|
+
if (options.localAuth) {
|
|
487
|
+
throw new ConfigError("Set FORGET_API_KEY when using --local-auth.");
|
|
488
|
+
}
|
|
398
489
|
if (!options.hosted) return "";
|
|
399
490
|
const prompted = (await promptHidden("Paste your Forget API key: ")).trim();
|
|
400
491
|
if (!prompted) {
|
|
@@ -445,7 +536,13 @@ function printDoctor(result) {
|
|
|
445
536
|
const hooksDetail = result.hooks.registered
|
|
446
537
|
? `registered, scripts ${result.hooks.scripts_present ? "present" : "missing"}, python3 ${result.hooks.python3 ? "ok" : "missing"}`
|
|
447
538
|
: "not installed (use connect without --no-hooks)";
|
|
448
|
-
stdout.write(`${result.hooks.ok ? "✓" : "✗"}
|
|
539
|
+
stdout.write(`${result.hooks.ok ? "✓" : "✗"} Claude Code hooks: ${hooksDetail}\n`);
|
|
540
|
+
}
|
|
541
|
+
if (result.codex_hooks) {
|
|
542
|
+
const hooksDetail = result.codex_hooks.registered
|
|
543
|
+
? `registered, scripts ${result.codex_hooks.scripts_present ? "present" : "missing"}, python3 ${result.codex_hooks.python3 ? "ok" : "missing"}`
|
|
544
|
+
: "not installed (use connect without --no-hooks)";
|
|
545
|
+
stdout.write(`${result.codex_hooks.ok ? "✓" : "✗"} Codex hooks: ${hooksDetail}\n`);
|
|
449
546
|
}
|
|
450
547
|
for (const client of result.clients) {
|
|
451
548
|
const details = [
|
|
@@ -465,6 +562,30 @@ function printDoctor(result) {
|
|
|
465
562
|
}
|
|
466
563
|
stdout.write(`${client.ok ? "✓" : "✗"} ${client.name}: ${details.join(", ")}\n`);
|
|
467
564
|
}
|
|
565
|
+
if (result.capture) {
|
|
566
|
+
const c = result.capture;
|
|
567
|
+
const state = c.service_wired ? (c.capture_env ? "ON" : "passthrough (capture off)") : "off (proxy not installed)";
|
|
568
|
+
stdout.write(`${c.service_wired ? "!" : "○"} Capture proxy: ${state}\n`);
|
|
569
|
+
if (c.exists) {
|
|
570
|
+
stdout.write(` folder ${c.dir}: ${formatBytes(c.bytes)} in ${c.files} day file(s), oldest ${c.oldest ?? "-"}, retention ${c.retention_days} days, cap ${formatBytes(c.max_bytes)}\n`);
|
|
571
|
+
const legacy = legacyLine(c);
|
|
572
|
+
if (legacy) stdout.write(` ${legacy}\n`);
|
|
573
|
+
if (!c.service_wired && c.files) {
|
|
574
|
+
stdout.write(" not being written to now; delete with: forget-connect disconnect --purge-capture\n");
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
stdout.write(" no capture folder\n");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const aw = result.auth_warnings;
|
|
581
|
+
if (aw?.server_has_key && (aw.clients_without_key.length || aw.hooks_without_key)) {
|
|
582
|
+
const who = [...aw.clients_without_key, ...(aw.hooks_without_key ? ["hooks"] : [])].join(", ");
|
|
583
|
+
stdout.write(
|
|
584
|
+
`! Local API key: the server has a key ($FORGET_HOME/api-key) but ${who} do not send it.\n`
|
|
585
|
+
+ " Auth is not required yet, so nothing is broken. It will be required in a later release;\n"
|
|
586
|
+
+ " run `npx forget-connect` (connect) again to update clients and hooks.\n",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
468
589
|
if (result.remote.skipped) {
|
|
469
590
|
stdout.write("○ MCP: not checked until local configuration is fixed\n");
|
|
470
591
|
return;
|
|
@@ -483,6 +604,67 @@ function printDoctor(result) {
|
|
|
483
604
|
}
|
|
484
605
|
}
|
|
485
606
|
|
|
607
|
+
async function confirmYes(question) {
|
|
608
|
+
if (!(stdin.isTTY && stdout.isTTY)) return false;
|
|
609
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
610
|
+
try {
|
|
611
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
612
|
+
return answer === "y" || answer === "yes";
|
|
613
|
+
} finally {
|
|
614
|
+
rl.close();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function runCapturePurge(options, env) {
|
|
619
|
+
if (options.captureSub !== "purge" || !options.before) {
|
|
620
|
+
throw new ConfigError("Usage: forget-connect capture purge --before <YYYY-MM-DD>");
|
|
621
|
+
}
|
|
622
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(options.before)) {
|
|
623
|
+
throw new ConfigError("--before needs a date like 2026-10-01");
|
|
624
|
+
}
|
|
625
|
+
const info = await captureInfo({ env });
|
|
626
|
+
if (!info.exists || !info.files) {
|
|
627
|
+
stdout.write("No capture files.\n");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
stdout.write(`Capture folder ${info.dir}: ${formatBytes(info.bytes)}, ${info.files} day file(s), ${info.oldest}~${info.newest}\n`);
|
|
631
|
+
stdout.write(`Will delete every day file dated before ${options.before}.\n`);
|
|
632
|
+
if (!(await confirmYes("Delete them? [y/N] "))) {
|
|
633
|
+
stdout.write("Nothing deleted (an interactive yes is required).\n");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const removed = await purgeCaptureBefore({ env }, options.before);
|
|
637
|
+
stdout.write(`Deleted ${removed.length} file(s).\n`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Disconnect never deletes captured conversations silently: it reports the
|
|
641
|
+
// size, and deletes only with --purge-capture or an interactive "yes".
|
|
642
|
+
async function reportCapture(options, env) {
|
|
643
|
+
const info = await captureInfo({ env });
|
|
644
|
+
if (!info.exists || !info.files) return;
|
|
645
|
+
stdout.write(
|
|
646
|
+
` capture folder kept: ${info.dir} (${formatBytes(info.bytes)}, ${info.files} day file(s), oldest ${info.oldest})\n`,
|
|
647
|
+
);
|
|
648
|
+
const legacy = legacyLine(info);
|
|
649
|
+
if (legacy) stdout.write(` ${legacy}\n`);
|
|
650
|
+
let purge = options.purgeCapture;
|
|
651
|
+
if (!purge && stdin.isTTY && stdout.isTTY) {
|
|
652
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
653
|
+
try {
|
|
654
|
+
const answer = (await rl.question("Delete these captured conversations now? [y/N] ")).trim().toLowerCase();
|
|
655
|
+
purge = answer === "y" || answer === "yes";
|
|
656
|
+
} finally {
|
|
657
|
+
rl.close();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (purge) {
|
|
661
|
+
await purgeCapture({ env });
|
|
662
|
+
stdout.write(` deleted ${info.dir}\n`);
|
|
663
|
+
} else {
|
|
664
|
+
stdout.write(" to delete it: forget-connect disconnect --purge-capture\n");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
486
668
|
async function version() {
|
|
487
669
|
const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
488
670
|
return JSON.parse(raw).version;
|
|
@@ -499,6 +681,11 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
499
681
|
return;
|
|
500
682
|
}
|
|
501
683
|
|
|
684
|
+
if (options.action === "capture") {
|
|
685
|
+
await runCapturePurge(options, env);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
502
689
|
const allClients = getClients({ env });
|
|
503
690
|
const clients = await selectClients(options, allClients);
|
|
504
691
|
if (options.action === "status") {
|
|
@@ -524,7 +711,13 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
524
711
|
}
|
|
525
712
|
const installed = scopeFromUrl(configuredServerUrl(client, raw));
|
|
526
713
|
if (!installed) continue;
|
|
527
|
-
|
|
714
|
+
const installedScopedUrl = scopedMcpUrl(installed.baseUrl, installed);
|
|
715
|
+
urlOverrides.set(client.id, urlForClient({
|
|
716
|
+
...options,
|
|
717
|
+
url: installedScopedUrl,
|
|
718
|
+
scope: installed,
|
|
719
|
+
defaultScope: null,
|
|
720
|
+
}, client));
|
|
528
721
|
if (!first) first = installed;
|
|
529
722
|
}
|
|
530
723
|
if (first) {
|
|
@@ -543,8 +736,19 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
543
736
|
if (options.action === "doctor") {
|
|
544
737
|
const probeUrl = clients.length ? urlFor(clients[0]) : options.url;
|
|
545
738
|
const probeScope = options.scope ?? scopeFromUrl(probeUrl);
|
|
739
|
+
// The server may hold a key (install-service wrote one). Clients that
|
|
740
|
+
// already send it are fine; clients that don't only get a warning.
|
|
741
|
+
const localKey = isLoopbackUrl(probeUrl) && !options.hosted ? await readLocalApiKey(env) : "";
|
|
742
|
+
const plainStatuses = await inspectClients(clients, { url: options.url, apiKey, urlFor });
|
|
743
|
+
const withKey = localKey
|
|
744
|
+
? await inspectClients(clients, { url: options.url, apiKey: localKey, urlFor })
|
|
745
|
+
: [];
|
|
546
746
|
const local = localDoctorResults(
|
|
547
|
-
|
|
747
|
+
plainStatuses.map((s, i) => (
|
|
748
|
+
localKey && withKey[i]?.authMatches && !s.authMatches
|
|
749
|
+
? { ...s, authMatches: true, transportValid: s.transportValid || withKey[i].transportValid }
|
|
750
|
+
: s
|
|
751
|
+
)),
|
|
548
752
|
{ requireRules: options.rules },
|
|
549
753
|
);
|
|
550
754
|
const remote = local.every((client) => client.ok)
|
|
@@ -555,6 +759,7 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
555
759
|
clientVersion: await version(),
|
|
556
760
|
expectedScope: probeScope,
|
|
557
761
|
requireScope: options.hosted,
|
|
762
|
+
...(clients[0]?.id === "codex" ? { requiredTools: CODEX_REQUIRED_TOOLS } : {}),
|
|
558
763
|
})
|
|
559
764
|
: {
|
|
560
765
|
ok: false,
|
|
@@ -574,16 +779,40 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
574
779
|
skipped: true,
|
|
575
780
|
},
|
|
576
781
|
};
|
|
782
|
+
const keyWarnings = [];
|
|
783
|
+
if (localKey) {
|
|
784
|
+
withKey.forEach((item) => {
|
|
785
|
+
if (item.config && !item.authMatches) keyWarnings.push(item.client.name);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
577
788
|
const hooksStatus = clients.some((client) => client.id === "claude-code")
|
|
578
|
-
? await inspectHooks({ env })
|
|
789
|
+
? await inspectHooks({ env, clientId: "claude-code" })
|
|
790
|
+
: null;
|
|
791
|
+
const codexHooksStatus = clients.some((client) => client.id === "codex")
|
|
792
|
+
? await inspectHooks({ env, clientId: "codex" })
|
|
579
793
|
: null;
|
|
794
|
+
const skillStatus = await Promise.all(
|
|
795
|
+
clients
|
|
796
|
+
.filter((client) => ["claude-code", "codex"].includes(client.id))
|
|
797
|
+
.map(async (client) => ({ client: client.id, ...await inspectMemoryAgentSkill(client.id, { env }) })),
|
|
798
|
+
);
|
|
580
799
|
const result = {
|
|
581
|
-
ok: local.every((client) => client.ok) && remote.ok
|
|
800
|
+
ok: local.every((client) => client.ok) && remote.ok
|
|
801
|
+
&& (hooksStatus?.ok ?? true) && (codexHooksStatus?.ok ?? true)
|
|
802
|
+
&& (!options.skills || skillStatus.every((skill) => skill.installed && skill.current)),
|
|
582
803
|
url: redactUrlForDisplay(probeUrl),
|
|
583
804
|
scope: { configured: Boolean(probeScope) },
|
|
584
805
|
clients: local,
|
|
806
|
+
auth_warnings: {
|
|
807
|
+
server_has_key: Boolean(localKey),
|
|
808
|
+
clients_without_key: keyWarnings,
|
|
809
|
+
hooks_without_key: [hooksStatus, codexHooksStatus].some((h) => h && h.sends_key === false),
|
|
810
|
+
},
|
|
585
811
|
hooks: hooksStatus,
|
|
812
|
+
codex_hooks: codexHooksStatus,
|
|
813
|
+
skills: skillStatus,
|
|
586
814
|
remote,
|
|
815
|
+
capture: await captureInfo({ env }),
|
|
587
816
|
};
|
|
588
817
|
if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
589
818
|
else printDoctor(result);
|
|
@@ -598,12 +827,15 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
598
827
|
migrateLegacy: options.migrateLegacy,
|
|
599
828
|
legacyUrls: [...new Set([options.url, options.baseUrl, HOSTED_MCP_URL])],
|
|
600
829
|
});
|
|
830
|
+
const skillClients = clients.filter((client) => ["claude-code", "codex"].includes(client.id));
|
|
831
|
+
const manageSkills = skillClients.length > 0
|
|
832
|
+
&& (options.action === "disconnect" || options.skills);
|
|
601
833
|
|
|
602
|
-
// Claude Code
|
|
603
|
-
//
|
|
604
|
-
// them up, even when the install used --no-hooks.
|
|
834
|
+
// Codex and Claude Code use different lifecycle event sets but share the
|
|
835
|
+
// same fail-open scripts. Disconnect removes only our entries.
|
|
605
836
|
const claudeCode = clients.find((client) => client.id === "claude-code");
|
|
606
|
-
|
|
837
|
+
const hookClients = clients.filter((client) => ["claude-code", "codex"].includes(client.id));
|
|
838
|
+
let manageHooks = hookClients.length > 0
|
|
607
839
|
&& (options.action === "disconnect" || options.hooks);
|
|
608
840
|
if (manageHooks && process.platform === "win32" && options.action === "connect") {
|
|
609
841
|
// The hook commands are POSIX shell strings and the scripts need
|
|
@@ -611,33 +843,112 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
611
843
|
stderr.write("Note: memory hooks are not yet supported on Windows; skipping hook install.\n");
|
|
612
844
|
manageHooks = false;
|
|
613
845
|
}
|
|
846
|
+
// The capture proxy (zero-config memory capture) rides the same
|
|
847
|
+
// settings.json: env.ANTHROPIC_BASE_URL points Claude Code at the local
|
|
848
|
+
// forget-proxy. launchd-only for now, so connect wires it on macOS;
|
|
849
|
+
// disconnect always unwires — a no-op wherever connect never ran.
|
|
850
|
+
let manageProxy = Boolean(claudeCode)
|
|
851
|
+
&& (options.action === "disconnect" || options.proxy);
|
|
852
|
+
if (manageProxy && options.action === "connect" && process.platform !== "darwin") {
|
|
853
|
+
stderr.write("Note: the capture proxy service requires launchd (macOS); skipping proxy wiring.\n");
|
|
854
|
+
manageProxy = false;
|
|
855
|
+
}
|
|
856
|
+
if (manageProxy && options.action === "connect" && !options.dryRun
|
|
857
|
+
&& !(await findExecutable("forget-proxy", { env }))) {
|
|
858
|
+
// Checked before any file changes: an env override pointing at a proxy
|
|
859
|
+
// that cannot start would break the user's Claude, not just capture.
|
|
860
|
+
stderr.write("Note: forget-proxy not found on PATH; skipping proxy wiring (pip install 'forget-ai[server]').\n");
|
|
861
|
+
manageProxy = false;
|
|
862
|
+
}
|
|
863
|
+
|
|
614
864
|
const hooksDir = manageHooks ? hooksDirFor({ env }) : "";
|
|
865
|
+
const hookSettings = new Map();
|
|
866
|
+
let proxyPlan = null;
|
|
615
867
|
if (manageHooks) {
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
868
|
+
for (const client of hookClients) {
|
|
869
|
+
const settingsPath = settingsPathFor({ env, clientId: client.id });
|
|
870
|
+
let settingsRaw = "";
|
|
871
|
+
try {
|
|
872
|
+
settingsRaw = await readFile(settingsPath, "utf8");
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
875
|
+
}
|
|
876
|
+
const settingsNext = options.action === "connect"
|
|
877
|
+
? connectHooksSettings(settingsRaw, {
|
|
878
|
+
hooksDir,
|
|
879
|
+
url: urlFor(client),
|
|
880
|
+
clientId: client.id,
|
|
881
|
+
})
|
|
882
|
+
: disconnectHooksSettings(settingsRaw, { clientId: client.id });
|
|
883
|
+
hookSettings.set(client.id, { client, settingsPath, settingsRaw, settingsNext });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (manageProxy) {
|
|
887
|
+
const settingsPath = settingsPathFor({ env, clientId: "claude-code" });
|
|
888
|
+
let record = hookSettings.get("claude-code");
|
|
889
|
+
if (!record) {
|
|
890
|
+
let settingsRaw = "";
|
|
891
|
+
try {
|
|
892
|
+
settingsRaw = await readFile(settingsPath, "utf8");
|
|
893
|
+
} catch (error) {
|
|
894
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
895
|
+
}
|
|
896
|
+
record = { client: claudeCode, settingsPath, settingsRaw, settingsNext: settingsRaw };
|
|
897
|
+
}
|
|
898
|
+
let settingsNext = record.settingsNext;
|
|
899
|
+
if (manageProxy && options.action === "connect") {
|
|
900
|
+
proxyPlan = wireProxyEnv(settingsNext);
|
|
901
|
+
if (proxyPlan.status === "skip") {
|
|
902
|
+
// (c) an odd configuration skips the whole wiring, loudly — being
|
|
903
|
+
// invisible must never mean silently breaking the user's setup.
|
|
904
|
+
stderr.write(`Warning: ${proxyPlan.reason}; skipping proxy wiring. Nothing was changed.\n`);
|
|
905
|
+
manageProxy = false;
|
|
906
|
+
proxyPlan = null;
|
|
907
|
+
} else {
|
|
908
|
+
settingsNext = proxyPlan.next;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (manageProxy && options.action === "disconnect") {
|
|
912
|
+
const wiring = await readWiringState({ env });
|
|
913
|
+
settingsNext = unwireProxyEnv(settingsNext, {
|
|
914
|
+
original: wiring?.original_base_url ?? null,
|
|
915
|
+
}).next;
|
|
916
|
+
}
|
|
917
|
+
hookSettings.set("claude-code", { ...record, settingsNext });
|
|
918
|
+
}
|
|
919
|
+
for (const record of hookSettings.values()) {
|
|
920
|
+
if (record.settingsRaw !== record.settingsNext) {
|
|
627
921
|
changes.push({
|
|
628
|
-
client:
|
|
629
|
-
filePath: settingsPath,
|
|
630
|
-
before: settingsRaw,
|
|
631
|
-
after: settingsNext,
|
|
632
|
-
kind: "
|
|
922
|
+
client: record.client,
|
|
923
|
+
filePath: record.settingsPath,
|
|
924
|
+
before: record.settingsRaw,
|
|
925
|
+
after: record.settingsNext,
|
|
926
|
+
kind: "settings",
|
|
633
927
|
sensitive: false,
|
|
634
928
|
backup: options.action === "connect",
|
|
635
929
|
});
|
|
636
930
|
}
|
|
637
931
|
}
|
|
932
|
+
let removeSharedHookScripts = false;
|
|
933
|
+
if (manageHooks && options.action === "disconnect") {
|
|
934
|
+
let anyRegistered = false;
|
|
935
|
+
for (const clientId of ["claude-code", "codex"]) {
|
|
936
|
+
const staged = hookSettings.get(clientId);
|
|
937
|
+
let raw = staged?.settingsNext ?? "";
|
|
938
|
+
if (!staged) {
|
|
939
|
+
try {
|
|
940
|
+
raw = await readFile(settingsPathFor({ env, clientId }), "utf8");
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (hooksInstalled(raw, { hooksDir, clientId })) anyRegistered = true;
|
|
946
|
+
}
|
|
947
|
+
removeSharedHookScripts = !anyRegistered;
|
|
948
|
+
}
|
|
638
949
|
|
|
639
950
|
if (options.dryRun) {
|
|
640
|
-
if (!changes.length && !manageHooks) {
|
|
951
|
+
if (!changes.length && !manageHooks && !manageProxy && !manageSkills) {
|
|
641
952
|
stdout.write("No changes needed.\n");
|
|
642
953
|
return;
|
|
643
954
|
}
|
|
@@ -646,29 +957,91 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
646
957
|
}
|
|
647
958
|
if (manageHooks && options.action === "connect") {
|
|
648
959
|
stdout.write(`Would install hook scripts into ${hooksDir}\n`);
|
|
649
|
-
|
|
960
|
+
if (claudeCode) {
|
|
961
|
+
stdout.write(`Would install slash commands (/forget, /forget-settings) into ${commandsDirFor({ env })}\n`);
|
|
962
|
+
}
|
|
650
963
|
}
|
|
651
964
|
if (manageHooks && options.action === "disconnect") {
|
|
652
|
-
stdout.write(`Would remove hook scripts from ${hooksDir}\n`);
|
|
653
|
-
|
|
965
|
+
if (removeSharedHookScripts) stdout.write(`Would remove hook scripts from ${hooksDir}\n`);
|
|
966
|
+
if (claudeCode) {
|
|
967
|
+
stdout.write(`Would remove our slash commands from ${commandsDirFor({ env })} (user-edited files are kept)\n`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (manageProxy && options.action === "connect") {
|
|
971
|
+
stdout.write(`Would register launchd services ${PROXY_LABEL} + ${WATCHDOG_LABEL} (capture proxy at ${PROXY_BASE_URL})\n`);
|
|
972
|
+
if (proxyPlan?.upstream) {
|
|
973
|
+
stdout.write(`Would chain the existing ANTHROPIC_BASE_URL as the proxy upstream: ${proxyPlan.upstream}\n`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (manageProxy && options.action === "disconnect") {
|
|
977
|
+
stdout.write(`Would remove launchd services ${PROXY_LABEL} + ${WATCHDOG_LABEL} and restore ANTHROPIC_BASE_URL\n`);
|
|
978
|
+
}
|
|
979
|
+
if (manageSkills) {
|
|
980
|
+
for (const client of skillClients) {
|
|
981
|
+
const verb = options.action === "connect" ? "install" : "remove";
|
|
982
|
+
stdout.write(`Would ${verb} the Memory Agent skill at ${skillDirFor(client.id, { env })}\n`);
|
|
983
|
+
}
|
|
654
984
|
}
|
|
655
985
|
return;
|
|
656
986
|
}
|
|
657
987
|
|
|
658
988
|
const result = await applyPlan(changes);
|
|
989
|
+
let skillPaths = [];
|
|
990
|
+
if (manageSkills) {
|
|
991
|
+
for (const client of skillClients) {
|
|
992
|
+
const skillResult = options.action === "connect"
|
|
993
|
+
? await installMemoryAgentSkill(client.id, { env })
|
|
994
|
+
: await removeMemoryAgentSkill(client.id, { env });
|
|
995
|
+
skillPaths = skillPaths.concat(skillResult.written ?? skillResult.removed ?? []);
|
|
996
|
+
if ((skillResult.skipped ?? skillResult.kept ?? []).length) {
|
|
997
|
+
stderr.write(`Note: kept an existing unowned Memory Agent skill for ${client.name}.\n`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
659
1001
|
let hookScriptPaths = [];
|
|
660
1002
|
if (manageHooks) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1003
|
+
if (options.action === "connect") hookScriptPaths = await installHookScripts(hooksDir);
|
|
1004
|
+
else if (removeSharedHookScripts) hookScriptPaths = await removeHookScripts(hooksDir);
|
|
1005
|
+
if (claudeCode) {
|
|
1006
|
+
const commandsDir = commandsDirFor({ env });
|
|
1007
|
+
const commandPaths = options.action === "connect"
|
|
1008
|
+
? await installCommandAssets(commandsDir)
|
|
1009
|
+
: await removeCommandAssets(commandsDir);
|
|
1010
|
+
hookScriptPaths = hookScriptPaths.concat(commandPaths);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
let proxyPaths = [];
|
|
1014
|
+
let proxyUpstream = null;
|
|
1015
|
+
if (manageProxy) {
|
|
1016
|
+
if (options.action === "connect") {
|
|
1017
|
+
// State first: the watchdog and a future disconnect must know the
|
|
1018
|
+
// user's original base URL before either could ever act on it. A
|
|
1019
|
+
// no-op re-connect keeps the existing record (it holds the original).
|
|
1020
|
+
let wiring = await readWiringState({ env });
|
|
1021
|
+
if (proxyPlan.status === "wire" || !wiring) {
|
|
1022
|
+
wiring = await writeWiringState({ env }, {
|
|
1023
|
+
upstream: proxyPlan.upstream,
|
|
1024
|
+
original: proxyPlan.original,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
proxyUpstream = wiring.upstream ?? null;
|
|
1028
|
+
const service = await installProxyServices({ env }, { upstream: proxyUpstream });
|
|
1029
|
+
proxyPaths = service.written;
|
|
1030
|
+
if (!service.installed) {
|
|
1031
|
+
stderr.write(
|
|
1032
|
+
`Warning: capture proxy service not fully registered — ${service.reason}\n`
|
|
1033
|
+
+ " If Claude Code cannot reach the API, run: forget-connect disconnect --client claude-code\n",
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
} else {
|
|
1037
|
+
proxyPaths = await removeProxyServices({ env });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (options.action === "disconnect" && !options.dryRun) {
|
|
1041
|
+
await reportCapture(options, env);
|
|
669
1042
|
}
|
|
670
1043
|
const verb = options.action === "connect" ? "Connected" : "Disconnected";
|
|
671
|
-
if (!result.changed.length && !hookScriptPaths.length) {
|
|
1044
|
+
if (!result.changed.length && !hookScriptPaths.length && !proxyPaths.length && !skillPaths.length) {
|
|
672
1045
|
stdout.write("No changes needed.\n");
|
|
673
1046
|
} else {
|
|
674
1047
|
stdout.write(`${verb} ${clients.map((client) => client.name).join(", ")}.\n`);
|
|
@@ -676,6 +1049,8 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
676
1049
|
for (const filePath of result.backups) stdout.write(` backup ${filePath}\n`);
|
|
677
1050
|
const hookVerb = options.action === "connect" ? "installed" : "removed";
|
|
678
1051
|
for (const filePath of hookScriptPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
|
|
1052
|
+
for (const filePath of proxyPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
|
|
1053
|
+
for (const filePath of skillPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
|
|
679
1054
|
}
|
|
680
1055
|
if (options.action === "connect") {
|
|
681
1056
|
if (options.scope) {
|
|
@@ -686,8 +1061,22 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
686
1061
|
);
|
|
687
1062
|
}
|
|
688
1063
|
if (manageHooks) {
|
|
689
|
-
stdout.write(" hooks:
|
|
690
|
-
|
|
1064
|
+
stdout.write(" hooks: shared capsule, per-turn recall, and session capture (needs python3 on PATH)\n");
|
|
1065
|
+
if (claudeCode) {
|
|
1066
|
+
stdout.write(" commands: /forget (the recall dial) · /forget-settings (status, doctor, cloud usage)\n");
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (manageProxy) {
|
|
1070
|
+
stdout.write(` proxy: capture proxy at ${PROXY_BASE_URL} (launchd ${PROXY_LABEL}, watchdog ${WATCHDOG_LABEL})\n`);
|
|
1071
|
+
stdout.write(" proxy RECORDS every Claude Code request and response (secrets scrubbed, owner-only 0600) in ~/.forget/proxy/stream/; kept 7 days, max 2 GB. Turn off: forget-connect disconnect (add --purge-capture to delete the files)\n");
|
|
1072
|
+
const legacyNote = legacyLine(await captureInfo({ env }));
|
|
1073
|
+
if (legacyNote) stdout.write(` ${legacyNote}\n`);
|
|
1074
|
+
if (proxyUpstream) {
|
|
1075
|
+
stdout.write(` proxy upstream: ${proxyUpstream} (your previous ANTHROPIC_BASE_URL, chained)\n`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (manageSkills) {
|
|
1079
|
+
stdout.write(" skill: shared Memory Agent quote, approval, receipt, and revoke contract\n");
|
|
691
1080
|
}
|
|
692
1081
|
if (options.hosted && !options.scope) {
|
|
693
1082
|
stdout.write(
|