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/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/<client>/http/<os-username>. This keeps each user's and client's
59
- memories isolated. Override with --user-id/--app-id, or opt out with
60
- --no-scope. An explicit --url is installed verbatim.
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-hooks Do not install Claude Code memory hooks (session capsule,
64
- per-turn recall, conflict alerts, session capture)
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: scope each client into its own memory pool at
230
- // /mcp/<client>/http/<os-username>. The unscoped /mcp endpoint pools every
231
- // client's memories into the server's fallback scope (cold-install audit
232
- // 2026-07-29), so plain /mcp is now the opt-in (--no-scope), not the
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
- if (options.scope) return options.url;
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
- return scopedMcpUrl(options.baseUrl, {
332
+ resolved = scopedMcpUrl(options.baseUrl, {
268
333
  userId: options.defaultScope.userId,
269
334
  appId: CANONICAL_APP_ID,
270
335
  });
271
336
  }
272
- return options.url;
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
- // A leftover hosted key must not block the local-first default flow, and
393
- // a loopback target never puts the secret on the wire anyway.
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 ? "✓" : "✗"} Hooks: ${hooksDetail}\n`);
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
- urlOverrides.set(client.id, scopedMcpUrl(installed.baseUrl, installed));
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
- await inspectClients(clients, { url: options.url, apiKey, urlFor }),
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 && (hooksStatus?.ok ?? true),
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 is the only client with a hook system today; the hooks are
603
- // what make memory arrive without being asked. Disconnect always cleans
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
- let manageHooks = Boolean(claudeCode)
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 settingsPath = settingsPathFor({ env });
617
- let settingsRaw = "";
618
- try {
619
- settingsRaw = await readFile(settingsPath, "utf8");
620
- } catch (error) {
621
- if (!error || error.code !== "ENOENT") throw error;
622
- }
623
- const settingsNext = options.action === "connect"
624
- ? connectHooksSettings(settingsRaw, { hooksDir, url: urlFor(claudeCode) })
625
- : disconnectHooksSettings(settingsRaw);
626
- if (settingsRaw !== settingsNext) {
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: claudeCode,
629
- filePath: settingsPath,
630
- before: settingsRaw,
631
- after: settingsNext,
632
- kind: "hooks",
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
- stdout.write(`Would install slash commands (/forget, /forget-settings) into ${commandsDirFor({ env })}\n`);
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
- stdout.write(`Would remove our slash commands from ${commandsDirFor({ env })} (user-edited files are kept)\n`);
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
- hookScriptPaths = options.action === "connect"
662
- ? await installHookScripts(hooksDir)
663
- : await removeHookScripts(hooksDir);
664
- const commandsDir = commandsDirFor({ env });
665
- const commandPaths = options.action === "connect"
666
- ? await installCommandAssets(commandsDir)
667
- : await removeCommandAssets(commandsDir);
668
- hookScriptPaths = hookScriptPaths.concat(commandPaths);
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: session capsule, per-turn recall, conflict alerts, session capture (needs python3 on PATH)\n");
690
- stdout.write(" commands: /forget (the recall dial) · /forget-settings (status, doctor, cloud usage)\n");
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(