forget-connect 0.1.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/src/cli.js ADDED
@@ -0,0 +1,484 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import readline from "node:readline/promises";
3
+ import { stdin, stdout } from "node:process";
4
+ import {
5
+ DEFAULT_MCP_URL,
6
+ ConfigError,
7
+ applyPlan,
8
+ buildPlan,
9
+ detectClients,
10
+ getClients,
11
+ inspectClients,
12
+ normalizeUrl,
13
+ redactUrlForDisplay,
14
+ scopedMcpUrl,
15
+ validateScopeId,
16
+ validateApiKey,
17
+ } from "./core.js";
18
+ import { doctorRemote } from "./doctor.js";
19
+
20
+ const CLIENT_IDS = new Set(["claude-code", "codex", "claude-desktop"]);
21
+
22
+ function help() {
23
+ return `forget-connect — connect your AI clients to Forget memory
24
+
25
+ Usage:
26
+ forget-connect [connect] [options]
27
+ forget-connect status [options]
28
+ forget-connect doctor [options]
29
+ forget-connect disconnect [options]
30
+
31
+ Options:
32
+ --client <ids> Comma-separated: claude-code,codex,claude-desktop,all
33
+ --url <url> MCP URL (default: ${DEFAULT_MCP_URL})
34
+ --user-id <id> Memory user scope (pair with --app-id)
35
+ --app-id <id> Project/app scope (pair with --user-id)
36
+ --no-auth Connect without a Bearer token (for a local server)
37
+ --no-rules Do not manage CLAUDE.md or AGENTS.md instruction blocks
38
+ --no-migrate-enacta Keep matching legacy config and rules blocks
39
+ --dry-run Show the files that would change without writing them
40
+ --timeout <seconds> Doctor network timeout (default: 10)
41
+ --json Print doctor results as JSON
42
+ -y, --yes Use detected clients (or all if none are detected)
43
+ -h, --help Show help
44
+ -v, --version Show version
45
+
46
+ Authentication:
47
+ Set FORGET_API_KEY, or run interactively and paste the key when prompted.
48
+ Keys are intentionally not accepted as command-line arguments.
49
+
50
+ Examples:
51
+ npx forget-connect
52
+ FORGET_API_KEY=... npx forget-connect --client claude-code,codex
53
+ FORGET_API_KEY=... npx forget-connect --user-id junghunkim --app-id Mem1
54
+ npx forget-connect --url http://localhost:8000/mcp --no-auth --client all
55
+ npx forget-connect status
56
+ npx forget-connect doctor --client codex
57
+ npx forget-connect disconnect --client codex
58
+ `;
59
+ }
60
+
61
+ function requireValue(argv, index, flag) {
62
+ const value = argv[index + 1];
63
+ if (!value || value.startsWith("-")) {
64
+ throw new ConfigError(`${flag} requires a value.`);
65
+ }
66
+ return value;
67
+ }
68
+
69
+ function isHostedBaseUrl(value) {
70
+ const candidate = new URL(value);
71
+ const hosted = new URL(DEFAULT_MCP_URL);
72
+ const candidateHost = candidate.hostname.toLowerCase().replace(/\.+$/, "");
73
+ const hostedHost = hosted.hostname.toLowerCase().replace(/\.+$/, "");
74
+ if (candidateHost !== hostedHost) return false;
75
+ if (candidate.protocol !== hosted.protocol || candidate.port !== hosted.port) {
76
+ throw new ConfigError(
77
+ "The hosted Forget service requires HTTPS on its standard port.",
78
+ );
79
+ }
80
+ // Treat every URL on the managed service origin as hosted. Matching only a
81
+ // pathname prefix is bypassable with percent-encoded route segments that an
82
+ // HTTP server later decodes (for example /%6Dcp or /mcp%2Fproject).
83
+ return true;
84
+ }
85
+
86
+ export function parseArgs(argv, env = process.env) {
87
+ const options = {
88
+ action: "connect",
89
+ clientValues: [],
90
+ url: env.FORGET_MCP_URL || DEFAULT_MCP_URL,
91
+ userId: env.FORGET_USER_ID?.trim() || "",
92
+ appId: env.FORGET_APP_ID?.trim() || "",
93
+ auth: true,
94
+ rules: true,
95
+ migrateLegacy: true,
96
+ dryRun: false,
97
+ yes: false,
98
+ help: false,
99
+ version: false,
100
+ timeoutMs: 10000,
101
+ json: false,
102
+ };
103
+
104
+ let actionSeen = false;
105
+ for (let index = 0; index < argv.length; index += 1) {
106
+ const arg = argv[index];
107
+ if (["connect", "disconnect", "status", "doctor"].includes(arg) && !actionSeen) {
108
+ options.action = arg;
109
+ actionSeen = true;
110
+ } else if (arg === "--client") {
111
+ options.clientValues.push(requireValue(argv, index, arg));
112
+ index += 1;
113
+ } else if (arg.startsWith("--client=")) {
114
+ options.clientValues.push(arg.slice("--client=".length));
115
+ } else if (arg === "--url") {
116
+ options.url = requireValue(argv, index, arg);
117
+ index += 1;
118
+ } else if (arg.startsWith("--url=")) {
119
+ options.url = arg.slice("--url=".length);
120
+ } else if (arg === "--user-id") {
121
+ options.userId = requireValue(argv, index, arg);
122
+ index += 1;
123
+ } else if (arg.startsWith("--user-id=")) {
124
+ options.userId = arg.slice("--user-id=".length);
125
+ } else if (arg === "--app-id") {
126
+ options.appId = requireValue(argv, index, arg);
127
+ index += 1;
128
+ } else if (arg.startsWith("--app-id=")) {
129
+ options.appId = arg.slice("--app-id=".length);
130
+ } else if (arg === "--no-auth") {
131
+ options.auth = false;
132
+ } else if (arg === "--no-rules") {
133
+ options.rules = false;
134
+ } else if (arg === "--no-migrate-enacta") {
135
+ options.migrateLegacy = false;
136
+ } else if (arg === "--dry-run") {
137
+ options.dryRun = true;
138
+ } else if (arg === "--timeout") {
139
+ const seconds = Number(requireValue(argv, index, arg));
140
+ if (!Number.isFinite(seconds) || seconds < 1 || seconds > 60) {
141
+ throw new ConfigError("--timeout must be between 1 and 60 seconds.");
142
+ }
143
+ options.timeoutMs = Math.round(seconds * 1000);
144
+ index += 1;
145
+ } else if (arg.startsWith("--timeout=")) {
146
+ const seconds = Number(arg.slice("--timeout=".length));
147
+ if (!Number.isFinite(seconds) || seconds < 1 || seconds > 60) {
148
+ throw new ConfigError("--timeout must be between 1 and 60 seconds.");
149
+ }
150
+ options.timeoutMs = Math.round(seconds * 1000);
151
+ } else if (arg === "--json") {
152
+ options.json = true;
153
+ } else if (arg === "--yes" || arg === "-y") {
154
+ options.yes = true;
155
+ } else if (arg === "--help" || arg === "-h") {
156
+ options.help = true;
157
+ } else if (arg === "--version" || arg === "-v") {
158
+ options.version = true;
159
+ } else {
160
+ throw new ConfigError(`Unknown argument: ${arg}`);
161
+ }
162
+ }
163
+ options.baseUrl = normalizeUrl(options.url);
164
+ options.hosted = isHostedBaseUrl(options.baseUrl);
165
+ if (Boolean(options.userId) !== Boolean(options.appId)) {
166
+ throw new ConfigError("--user-id and --app-id must be provided together.");
167
+ }
168
+ options.scope = options.userId
169
+ ? {
170
+ userId: validateScopeId(options.userId, "user_id"),
171
+ appId: validateScopeId(options.appId, "app_id"),
172
+ }
173
+ : null;
174
+ options.url = options.scope
175
+ ? scopedMcpUrl(options.baseUrl, options.scope)
176
+ : options.baseUrl;
177
+ return options;
178
+ }
179
+
180
+ function requestedClientIds(values) {
181
+ const ids = values
182
+ .flatMap((value) => value.split(","))
183
+ .map((value) => value.trim())
184
+ .filter(Boolean);
185
+ if (ids.includes("all")) return ["claude-code", "codex", "claude-desktop"];
186
+ for (const id of ids) {
187
+ if (!CLIENT_IDS.has(id)) {
188
+ throw new ConfigError(`Unknown client: ${id}`);
189
+ }
190
+ }
191
+ return [...new Set(ids)];
192
+ }
193
+
194
+ async function promptForClients(allClients, detected) {
195
+ const defaults = detected.length ? detected : allClients;
196
+ stdout.write("\nWhere should Forget be connected?\n");
197
+ allClients.forEach((client, index) => {
198
+ const detectedLabel = detected.includes(client) ? " (detected)" : "";
199
+ stdout.write(` ${index + 1}. ${client.name}${detectedLabel}\n`);
200
+ });
201
+ const defaultIndexes = defaults.map((client) => allClients.indexOf(client) + 1);
202
+ const rl = readline.createInterface({ input: stdin, output: stdout });
203
+ let answer;
204
+ try {
205
+ answer = await rl.question(
206
+ `Choose comma-separated numbers [${defaultIndexes.join(",")}]: `,
207
+ );
208
+ } finally {
209
+ rl.close();
210
+ }
211
+ const values = answer.trim() ? answer.split(",") : defaultIndexes.map(String);
212
+ const chosen = values.map((value) => {
213
+ const index = Number.parseInt(value.trim(), 10) - 1;
214
+ if (!Number.isInteger(index) || index < 0 || index >= allClients.length) {
215
+ throw new ConfigError(`Invalid client selection: ${value}`);
216
+ }
217
+ return allClients[index];
218
+ });
219
+ return [...new Set(chosen)];
220
+ }
221
+
222
+ async function selectClients(options, allClients) {
223
+ const explicitIds = requestedClientIds(options.clientValues);
224
+ if (explicitIds.length) {
225
+ return explicitIds.map((id) => allClients.find((client) => client.id === id));
226
+ }
227
+ if (options.action === "status" || options.action === "disconnect") return allClients;
228
+
229
+ const detected = await detectClients(allClients);
230
+ if (options.action === "doctor") {
231
+ if (detected.length) return detected;
232
+ throw new ConfigError("No supported client installation was detected. Pass --client <ids> to diagnose an explicit target.");
233
+ }
234
+ if (options.yes) return detected.length ? detected : allClients;
235
+ if (stdin.isTTY && stdout.isTTY) return promptForClients(allClients, detected);
236
+ throw new ConfigError(
237
+ "No client was selected in a non-interactive shell. Pass --client <ids> or --yes.",
238
+ );
239
+ }
240
+
241
+ async function promptHidden(question) {
242
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
243
+ throw new ConfigError("Set FORGET_API_KEY when running non-interactively.");
244
+ }
245
+ stdout.write(question);
246
+ const wasRaw = Boolean(stdin.isRaw);
247
+ stdin.setRawMode(true);
248
+ stdin.resume();
249
+ let value = "";
250
+ try {
251
+ return await new Promise((resolve, reject) => {
252
+ const onData = (chunk) => {
253
+ for (const byte of chunk) {
254
+ if (byte === 3) {
255
+ stdin.off("data", onData);
256
+ stdout.write("\n");
257
+ reject(new ConfigError("Cancelled."));
258
+ return;
259
+ }
260
+ if (byte === 10 || byte === 13) {
261
+ stdin.off("data", onData);
262
+ stdout.write("\n");
263
+ resolve(value);
264
+ return;
265
+ }
266
+ if (byte === 127 || byte === 8) {
267
+ value = value.slice(0, -1);
268
+ continue;
269
+ }
270
+ value += Buffer.from([byte]).toString("utf8");
271
+ }
272
+ };
273
+ stdin.on("data", onData);
274
+ });
275
+ } finally {
276
+ stdin.setRawMode(wasRaw);
277
+ stdin.pause();
278
+ }
279
+ }
280
+
281
+ async function apiKeyFor(options, env) {
282
+ if (!["connect", "doctor"].includes(options.action) || !options.auth || options.dryRun) return "";
283
+ const fromEnv = env.FORGET_API_KEY?.trim();
284
+ if (fromEnv) {
285
+ if (new URL(options.url).protocol !== "https:") {
286
+ throw new ConfigError(
287
+ "Bearer authentication requires HTTPS. Use --no-auth for a local HTTP server.",
288
+ );
289
+ }
290
+ return validateApiKey(fromEnv);
291
+ }
292
+ if (!options.hosted) return "";
293
+ const prompted = (await promptHidden("Paste your Forget API key: ")).trim();
294
+ if (!prompted) {
295
+ throw new ConfigError("An API key is required for the hosted Forget service.");
296
+ }
297
+ return validateApiKey(prompted);
298
+ }
299
+
300
+ function printStatus(statuses) {
301
+ for (const status of statuses) {
302
+ const config = status.config ? "connected" : "not connected";
303
+ const rules = status.rules === null
304
+ ? ""
305
+ : status.rules
306
+ ? ", rules installed"
307
+ : ", rules missing";
308
+ stdout.write(`${status.config ? "✓" : "○"} ${status.client.name}: ${config}${rules}\n`);
309
+ }
310
+ }
311
+
312
+ function localDoctorResults(statuses, { requireRules = true } = {}) {
313
+ return statuses.map((status) => ({
314
+ id: status.client.id,
315
+ name: status.client.name,
316
+ config: status.config,
317
+ rules: status.rules,
318
+ transport_valid: status.transportValid,
319
+ url_matches: status.urlMatches,
320
+ auth_matches: status.authMatches,
321
+ rules_current: status.rulesCurrent,
322
+ ok: Boolean(
323
+ status.config
324
+ && status.transportValid
325
+ && status.urlMatches
326
+ && status.authMatches
327
+ && (
328
+ status.rules === null
329
+ || !requireRules
330
+ || (status.rules && status.rulesCurrent)
331
+ )
332
+ ),
333
+ }));
334
+ }
335
+
336
+ function printDoctor(result) {
337
+ stdout.write("Forget connection doctor\n");
338
+ for (const client of result.clients) {
339
+ const details = [
340
+ client.config ? "config found" : "config missing",
341
+ client.transport_valid ? "transport valid" : "transport invalid",
342
+ client.url_matches ? "URL matches" : "URL mismatch",
343
+ client.auth_matches ? "auth matches" : "auth mismatch",
344
+ ];
345
+ if (client.rules !== null) {
346
+ details.push(
347
+ client.rules_current
348
+ ? "rules current"
349
+ : client.rules
350
+ ? "rules stale"
351
+ : "rules missing",
352
+ );
353
+ }
354
+ stdout.write(`${client.ok ? "✓" : "✗"} ${client.name}: ${details.join(", ")}\n`);
355
+ }
356
+ if (result.remote.skipped) {
357
+ stdout.write("○ MCP: not checked until local configuration is fixed\n");
358
+ return;
359
+ }
360
+ const scopeProbe = result.remote.scope_probe;
361
+ if (scopeProbe?.required || scopeProbe?.requested) {
362
+ stdout.write(
363
+ `${scopeProbe.ok ? "✓" : "✗"} Scope: ${scopeProbe.ok ? "verified" : "not verified"}\n`,
364
+ );
365
+ }
366
+ stdout.write(
367
+ `${result.remote.ok ? "✓" : "✗"} MCP: ${result.remote.server_name} · protocol ${result.remote.protocol_version} · ${result.remote.tool_count} tools\n`,
368
+ );
369
+ if (result.remote.missing_tools.length) {
370
+ stdout.write(` missing tools: ${result.remote.missing_tools.join(", ")}\n`);
371
+ }
372
+ }
373
+
374
+ async function version() {
375
+ const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
376
+ return JSON.parse(raw).version;
377
+ }
378
+
379
+ export async function run(argv = process.argv.slice(2), env = process.env) {
380
+ const options = parseArgs(argv, env);
381
+ if (options.help) {
382
+ stdout.write(help());
383
+ return;
384
+ }
385
+ if (options.version) {
386
+ stdout.write(`${await version()}\n`);
387
+ return;
388
+ }
389
+
390
+ const allClients = getClients({ env });
391
+ const clients = await selectClients(options, allClients);
392
+ if (options.action === "status") {
393
+ printStatus(await inspectClients(clients));
394
+ return;
395
+ }
396
+
397
+ const apiKey = await apiKeyFor(options, env);
398
+ if (options.action === "doctor") {
399
+ const local = localDoctorResults(
400
+ await inspectClients(clients, { url: options.url, apiKey }),
401
+ { requireRules: options.rules },
402
+ );
403
+ const remote = local.every((client) => client.ok)
404
+ ? await doctorRemote({
405
+ url: options.url,
406
+ apiKey,
407
+ timeoutMs: options.timeoutMs,
408
+ clientVersion: await version(),
409
+ expectedScope: options.scope,
410
+ requireScope: options.hosted,
411
+ })
412
+ : {
413
+ ok: false,
414
+ skipped: true,
415
+ reason: "local_configuration_mismatch",
416
+ server_name: "not checked",
417
+ server_version: "not checked",
418
+ protocol_version: "not checked",
419
+ session_negotiated: false,
420
+ tool_count: 0,
421
+ required_tools: [],
422
+ missing_tools: [],
423
+ scope_probe: {
424
+ requested: Boolean(options.scope),
425
+ required: options.hosted,
426
+ ok: false,
427
+ skipped: true,
428
+ },
429
+ };
430
+ const result = {
431
+ ok: local.every((client) => client.ok) && remote.ok,
432
+ url: redactUrlForDisplay(options.url),
433
+ scope: { configured: Boolean(options.scope) },
434
+ clients: local,
435
+ remote,
436
+ };
437
+ if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
438
+ else printDoctor(result);
439
+ if (!result.ok) throw new ConfigError("Doctor checks failed.");
440
+ return result;
441
+ }
442
+ const changes = await buildPlan(options.action, clients, {
443
+ url: options.url,
444
+ apiKey,
445
+ installInstructionRules: options.rules,
446
+ migrateLegacy: options.migrateLegacy,
447
+ legacyUrls: [...new Set([options.url, options.baseUrl])],
448
+ });
449
+
450
+ if (options.dryRun) {
451
+ if (!changes.length) {
452
+ stdout.write("No changes needed.\n");
453
+ return;
454
+ }
455
+ for (const change of changes) {
456
+ stdout.write(`Would update ${change.filePath} (${change.client.name} ${change.kind})\n`);
457
+ }
458
+ return;
459
+ }
460
+
461
+ const result = await applyPlan(changes);
462
+ const verb = options.action === "connect" ? "Connected" : "Disconnected";
463
+ if (!result.changed.length) {
464
+ stdout.write("No changes needed.\n");
465
+ } else {
466
+ stdout.write(`${verb} ${clients.map((client) => client.name).join(", ")}.\n`);
467
+ for (const filePath of result.changed) stdout.write(` updated ${filePath}\n`);
468
+ for (const filePath of result.backups) stdout.write(` backup ${filePath}\n`);
469
+ }
470
+ if (options.action === "connect") {
471
+ if (options.hosted && !options.scope) {
472
+ stdout.write(
473
+ "\nWarning: hosted continuity scope is not configured. Reconnect with --user-id and --app-id before relying on cross-client recall.\n",
474
+ );
475
+ }
476
+ const restarts = [...new Set(clients.map((client) => client.restart))];
477
+ stdout.write("\nNext:\n");
478
+ restarts.forEach((message) => stdout.write(` - ${message}\n`));
479
+ }
480
+ }
481
+
482
+ export async function main() {
483
+ await run();
484
+ }