codemem 0.22.3 → 0.23.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.
Files changed (61) hide show
  1. package/dist/index.js +1804 -531
  2. package/dist/index.js.map +1 -1
  3. package/package.json +10 -10
  4. package/dist/commands/claude-hook-ingest.d.ts +0 -51
  5. package/dist/commands/claude-hook-ingest.d.ts.map +0 -1
  6. package/dist/commands/claude-hook-ingest.test.d.ts +0 -2
  7. package/dist/commands/claude-hook-ingest.test.d.ts.map +0 -1
  8. package/dist/commands/db.d.ts +0 -3
  9. package/dist/commands/db.d.ts.map +0 -1
  10. package/dist/commands/db.test.d.ts +0 -2
  11. package/dist/commands/db.test.d.ts.map +0 -1
  12. package/dist/commands/embed.d.ts +0 -4
  13. package/dist/commands/embed.d.ts.map +0 -1
  14. package/dist/commands/embed.test.d.ts +0 -2
  15. package/dist/commands/embed.test.d.ts.map +0 -1
  16. package/dist/commands/enqueue-raw-event.d.ts +0 -3
  17. package/dist/commands/enqueue-raw-event.d.ts.map +0 -1
  18. package/dist/commands/export-memories.d.ts +0 -3
  19. package/dist/commands/export-memories.d.ts.map +0 -1
  20. package/dist/commands/import-memories.d.ts +0 -3
  21. package/dist/commands/import-memories.d.ts.map +0 -1
  22. package/dist/commands/mcp.d.ts +0 -3
  23. package/dist/commands/mcp.d.ts.map +0 -1
  24. package/dist/commands/memory.d.ts +0 -13
  25. package/dist/commands/memory.d.ts.map +0 -1
  26. package/dist/commands/memory.test.d.ts +0 -2
  27. package/dist/commands/memory.test.d.ts.map +0 -1
  28. package/dist/commands/pack.d.ts +0 -3
  29. package/dist/commands/pack.d.ts.map +0 -1
  30. package/dist/commands/recent.d.ts +0 -3
  31. package/dist/commands/recent.d.ts.map +0 -1
  32. package/dist/commands/search.d.ts +0 -3
  33. package/dist/commands/search.d.ts.map +0 -1
  34. package/dist/commands/serve-invocation.d.ts +0 -37
  35. package/dist/commands/serve-invocation.d.ts.map +0 -1
  36. package/dist/commands/serve.d.ts +0 -13
  37. package/dist/commands/serve.d.ts.map +0 -1
  38. package/dist/commands/serve.test.d.ts +0 -2
  39. package/dist/commands/serve.test.d.ts.map +0 -1
  40. package/dist/commands/setup-config.d.ts +0 -4
  41. package/dist/commands/setup-config.d.ts.map +0 -1
  42. package/dist/commands/setup-config.test.d.ts +0 -2
  43. package/dist/commands/setup-config.test.d.ts.map +0 -1
  44. package/dist/commands/setup.d.ts +0 -15
  45. package/dist/commands/setup.d.ts.map +0 -1
  46. package/dist/commands/stats.d.ts +0 -3
  47. package/dist/commands/stats.d.ts.map +0 -1
  48. package/dist/commands/sync-helpers.d.ts +0 -31
  49. package/dist/commands/sync-helpers.d.ts.map +0 -1
  50. package/dist/commands/sync.d.ts +0 -6
  51. package/dist/commands/sync.d.ts.map +0 -1
  52. package/dist/commands/sync.test.d.ts +0 -2
  53. package/dist/commands/sync.test.d.ts.map +0 -1
  54. package/dist/commands/version.d.ts +0 -3
  55. package/dist/commands/version.d.ts.map +0 -1
  56. package/dist/help-style.d.ts +0 -9
  57. package/dist/help-style.d.ts.map +0 -1
  58. package/dist/index.d.ts +0 -13
  59. package/dist/index.d.ts.map +0 -1
  60. package/dist/index.smoke.test.d.ts +0 -2
  61. package/dist/index.smoke.test.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_COORDINATOR_DB_PATH, MemoryStore, ObserverClient, RawEventSweeper, SyncRetentionRunner, VERSION, backfillTagsText, backfillVectors, buildRawEventEnvelopeFromHook, connect, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorImportInviteAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, ensureDeviceIdentity, exportMemories, fingerprintPublicKey, getRawEventStatus, importMemories, initDatabase, isEmbeddingDisabled, loadPublicKey, loadSqliteVec, planReplicationOpsAgePrune, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCoordinatorSyncConfig, readImportPayload, resolveDbPath, resolveProject, retryRawEventFailures, runSyncDaemon, runSyncPass, schema, setPeerProjectFilter, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncPassPreflight, updatePeerAddresses, vacuumDatabase, writeCodememConfigFile } from "@codemem/core";
3
- import { Command } from "commander";
2
+ import { DEFAULT_COORDINATOR_DB_PATH, MemoryStore, ObserverClient, RawEventSweeper, SyncRetentionRunner, VERSION, applyBootstrapSnapshot, backfillTagsText, backfillVectors, buildAuthHeaders, buildBaseUrl, buildRawEventEnvelopeFromHook, compareMemoryRoleReports, connect, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, ensureDeviceIdentity, exportMemories, fetchAllSnapshotPages, fingerprintPublicKey, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getWorkspaceCodememConfigPath, hasUnsyncedSharedMemoryChanges, importMemories, initDatabase, isEmbeddingDisabled, loadPublicKey, loadSqliteVec, planReplicationOpsAgePrune, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorSyncConfig, readImportPayload, requestJson, resolveCodememConfigPath, resolveDbPath, resolveHookProject, resolveProject, retryRawEventFailures, runSyncDaemon, runSyncPass, schema, setPeerProjectFilter, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncPassPreflight, updatePeerAddresses, vacuumDatabase, writeCodememConfigFile } from "@codemem/core";
3
+ import { Command, Option } from "commander";
4
4
  import omelette from "omelette";
5
5
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { styleText } from "node:util";
7
7
  import * as p from "@clack/prompts";
8
+ import { serve } from "@hono/node-server";
8
9
  import { homedir, networkInterfaces } from "node:os";
9
10
  import { dirname, join } from "node:path";
10
11
  import { spawn, spawnSync } from "node:child_process";
11
12
  import net from "node:net";
12
- import { serve } from "@hono/node-server";
13
13
  import { desc, eq } from "drizzle-orm";
14
14
  import { drizzle } from "drizzle-orm/better-sqlite3";
15
15
  //#region src/help-style.ts
@@ -30,6 +30,52 @@ var helpStyle = {
30
30
  styleArgumentText: (str) => styleText("yellow", str)
31
31
  };
32
32
  //#endregion
33
+ //#region src/shared-options.ts
34
+ /** Add -d/--db-path and hidden --db alias to a command. */
35
+ function addDbOption(cmd) {
36
+ cmd.addOption(new Option("-d, --db-path <path>", "database path (overrides $CODEMEM_DB)"));
37
+ cmd.addOption(new Option("--db <path>", "database path").hideHelp());
38
+ return cmd;
39
+ }
40
+ /** Resolve the db path from parsed opts that may have --db or --db-path. */
41
+ function resolveDbOpt(opts) {
42
+ return opts.dbPath ?? opts.db;
43
+ }
44
+ /** Add -c/--config to a command. */
45
+ function addConfigOption(cmd) {
46
+ cmd.addOption(new Option("-c, --config <path>", "config file path (overrides $CODEMEM_CONFIG)"));
47
+ return cmd;
48
+ }
49
+ /** Add -j/--json to a command. */
50
+ function addJsonOption(cmd) {
51
+ cmd.addOption(new Option("-j, --json", "output as JSON"));
52
+ return cmd;
53
+ }
54
+ /** Add --host and --port for the viewer/serve service. */
55
+ function addViewerHostOptions(cmd, defaults = {}) {
56
+ cmd.option("--host <host>", "viewer host", defaults.host ?? "127.0.0.1");
57
+ cmd.option("--port <port>", "viewer port", defaults.port ?? "38888");
58
+ return cmd;
59
+ }
60
+ /** Add hidden --user/--system Typer-era compatibility flags. */
61
+ function addLegacyServiceFlags(cmd) {
62
+ cmd.addOption(new Option("--user", "accepted for compatibility").default(true).hideHelp());
63
+ cmd.addOption(new Option("--system", "accepted for compatibility").hideHelp());
64
+ return cmd;
65
+ }
66
+ /** Emit a deprecation warning to stderr. */
67
+ function emitDeprecationWarning(oldForm, newForm) {
68
+ console.error(`Warning: '${oldForm}' is deprecated, use '${newForm}' instead.`);
69
+ }
70
+ /** Print a structured JSON error to stdout and set the exit code. */
71
+ function emitJsonError(errorCode, message, exitCode = 1) {
72
+ console.log(JSON.stringify({
73
+ error: errorCode,
74
+ message
75
+ }));
76
+ process.exitCode = exitCode;
77
+ }
78
+ //#endregion
33
79
  //#region src/commands/claude-hook-ingest.ts
34
80
  /**
35
81
  * codemem claude-hook-ingest — read a single Claude Code hook payload
@@ -43,6 +89,13 @@ var helpStyle = {
43
89
  * echo '{"hook_event_name":"Stop","session_id":"...","last_assistant_message":"..."}' \
44
90
  * | codemem claude-hook-ingest
45
91
  */
92
+ function emitStructuredError$1(errorCode, message) {
93
+ console.log(JSON.stringify({
94
+ error: errorCode,
95
+ message
96
+ }));
97
+ process.exitCode = 1;
98
+ }
46
99
  /** Try to POST the hook payload to the running viewer server. */
47
100
  async function tryHttpIngest(payload, host, port) {
48
101
  const url = `http://${host}:${port}/api/claude-hooks`;
@@ -130,55 +183,823 @@ async function ingestClaudeHookPayload(payload, opts, deps = {}) {
130
183
  const httpIngest = deps.httpIngest ?? tryHttpIngest;
131
184
  const directIngest = deps.directIngest ?? directEnqueue;
132
185
  const resolveDb = deps.resolveDb ?? resolveDbPath;
133
- const httpResult = await httpIngest(payload, opts.host, opts.port);
186
+ const port = typeof opts.port === "number" ? opts.port : Number.parseInt(opts.port, 10);
187
+ const httpResult = await httpIngest(payload, opts.host, port);
134
188
  if (httpResult.ok) return {
135
189
  inserted: httpResult.inserted,
136
190
  skipped: httpResult.skipped,
137
191
  via: "http"
138
192
  };
139
193
  return {
140
- ...directIngest(payload, resolveDb(opts.db ?? opts.dbPath)),
194
+ ...directIngest(payload, resolveDb(resolveDbOpt(opts))),
141
195
  via: "direct"
142
196
  };
143
197
  }
144
- var claudeHookIngestCommand = new Command("claude-hook-ingest").configureHelp(helpStyle).description("Ingest Claude hook payload: HTTP first, direct DB fallback").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--host <host>", "viewer server host", "127.0.0.1").option("--port <port>", "viewer server port", "38888").action(async (opts) => {
198
+ var claudeHookCmd = new Command("claude-hook-ingest").configureHelp(helpStyle).description("Ingest Claude hook payload: HTTP first, direct DB fallback");
199
+ addDbOption(claudeHookCmd);
200
+ addViewerHostOptions(claudeHookCmd);
201
+ var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
145
202
  let raw;
146
203
  try {
147
204
  raw = readFileSync(0, "utf8").trim();
148
205
  } catch {
149
- process.exitCode = 1;
206
+ emitStructuredError$1("read_error", "failed to read stdin");
150
207
  return;
151
208
  }
152
209
  if (!raw) {
153
- process.exitCode = 1;
210
+ emitStructuredError$1("read_error", "empty stdin");
154
211
  return;
155
212
  }
156
213
  let payload;
157
214
  try {
158
215
  const parsed = JSON.parse(raw);
159
216
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
217
+ emitStructuredError$1("parse_error", "payload must be a JSON object");
218
+ return;
219
+ }
220
+ payload = parsed;
221
+ } catch {
222
+ emitStructuredError$1("parse_error", "invalid JSON");
223
+ return;
224
+ }
225
+ try {
226
+ const result = await ingestClaudeHookPayload(payload, opts);
227
+ console.log(JSON.stringify(result));
228
+ } catch (err) {
229
+ emitStructuredError$1("ingest_error", err instanceof Error ? err.message : String(err));
230
+ }
231
+ });
232
+ //#endregion
233
+ //#region src/commands/claude-hook-inject.ts
234
+ var DEFAULT_VIEWER_HOST = "127.0.0.1";
235
+ var DEFAULT_VIEWER_PORT = 38888;
236
+ var DEFAULT_MAX_CHARS = 16e3;
237
+ var DEFAULT_HTTP_MAX_TIME_S = 2;
238
+ function emitJson(value) {
239
+ console.log(JSON.stringify(value));
240
+ }
241
+ function envNotDisabled(value) {
242
+ const normalized = String(value ?? "").trim().toLowerCase();
243
+ return normalized !== "0" && normalized !== "false" && normalized !== "off";
244
+ }
245
+ function parsePositiveInt(value, fallback) {
246
+ const parsed = Number.parseInt(String(value ?? ""), 10);
247
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
248
+ return parsed;
249
+ }
250
+ function continueResult(additionalContext) {
251
+ if (!additionalContext) return { continue: true };
252
+ return {
253
+ continue: true,
254
+ hookSpecificOutput: { additionalContext }
255
+ };
256
+ }
257
+ function truncateAdditionalContext(text, maxChars) {
258
+ const normalized = text.trim();
259
+ if (!normalized) return "";
260
+ if (!Number.isFinite(maxChars) || maxChars <= 0 || normalized.length <= maxChars) return normalized;
261
+ return normalized.slice(0, maxChars);
262
+ }
263
+ function extractInjectContext(payload) {
264
+ return String(payload.prompt ?? "").trim() || null;
265
+ }
266
+ function resolveInjectProject(payload) {
267
+ return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
268
+ }
269
+ async function buildLocalPack(context, project, dbPath) {
270
+ const store = new MemoryStore(dbPath);
271
+ try {
272
+ const limit = parsePositiveInt(process.env.CODEMEM_INJECT_LIMIT, 8);
273
+ const budget = parsePositiveInt(process.env.CODEMEM_INJECT_TOKEN_BUDGET, 800);
274
+ const filters = {};
275
+ if (project) filters.project = project;
276
+ const pack = await store.buildMemoryPackAsync(context, limit, budget, filters);
277
+ return String(pack.pack_text ?? "").trim();
278
+ } finally {
279
+ store.close();
280
+ }
281
+ }
282
+ async function tryHttpPack(context, project, maxTimeMs = DEFAULT_HTTP_MAX_TIME_S * 1e3) {
283
+ const host = process.env.CODEMEM_VIEWER_HOST || DEFAULT_VIEWER_HOST;
284
+ const port = parsePositiveInt(process.env.CODEMEM_VIEWER_PORT, DEFAULT_VIEWER_PORT);
285
+ const url = new URL(`http://${host}:${port}/api/pack`);
286
+ url.searchParams.set("context", context);
287
+ url.searchParams.set("limit", String(parsePositiveInt(process.env.CODEMEM_INJECT_LIMIT, 8)));
288
+ url.searchParams.set("token_budget", String(parsePositiveInt(process.env.CODEMEM_INJECT_TOKEN_BUDGET, 800)));
289
+ if (project) url.searchParams.set("project", project);
290
+ const controller = new AbortController();
291
+ const timeout = setTimeout(() => controller.abort(), maxTimeMs);
292
+ try {
293
+ const res = await fetch(url, { signal: controller.signal });
294
+ if (!res.ok) return "";
295
+ const body = await res.json();
296
+ return String(body.pack_text ?? "").trim();
297
+ } catch {
298
+ return "";
299
+ } finally {
300
+ clearTimeout(timeout);
301
+ }
302
+ }
303
+ async function buildClaudeHookInjection(payload, opts, deps = {}) {
304
+ if (!envNotDisabled(process.env.CODEMEM_INJECT_CONTEXT || "1")) return continueResult();
305
+ const context = extractInjectContext(payload);
306
+ if (!context) return continueResult();
307
+ const buildPack = deps.buildLocalPack ?? buildLocalPack;
308
+ const httpPack = deps.httpPack ?? tryHttpPack;
309
+ const resolveDb = deps.resolveDb ?? resolveDbPath;
310
+ const project = resolveInjectProject(payload);
311
+ const maxChars = parsePositiveInt(process.env.CODEMEM_INJECT_MAX_CHARS, DEFAULT_MAX_CHARS);
312
+ const httpMaxTimeMs = parsePositiveInt(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S, DEFAULT_HTTP_MAX_TIME_S) * 1e3;
313
+ let additionalContext = "";
314
+ try {
315
+ additionalContext = await buildPack(context, project, resolveDb(resolveDbOpt(opts)));
316
+ } catch {
317
+ additionalContext = "";
318
+ }
319
+ if (!additionalContext && envNotDisabled(process.env.CODEMEM_INJECT_HTTP_FALLBACK || "1")) additionalContext = await httpPack(context, project, httpMaxTimeMs);
320
+ return continueResult(truncateAdditionalContext(additionalContext, maxChars));
321
+ }
322
+ var claudeHookInjectCmd = new Command("claude-hook-inject").configureHelp(helpStyle).description("Return Claude hook additionalContext from local pack generation");
323
+ addDbOption(claudeHookInjectCmd);
324
+ var claudeHookInjectCommand = claudeHookInjectCmd.action(async (opts) => {
325
+ let raw = "";
326
+ for await (const chunk of process.stdin) raw += String(chunk);
327
+ const trimmed = raw.trim();
328
+ if (!trimmed) {
329
+ emitJson(continueResult());
330
+ return;
331
+ }
332
+ let payload;
333
+ try {
334
+ const parsed = JSON.parse(trimmed);
335
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
336
+ emitJson({
337
+ error: "parse_error",
338
+ message: "payload must be a JSON object"
339
+ });
160
340
  process.exitCode = 1;
161
341
  return;
162
342
  }
163
343
  payload = parsed;
164
344
  } catch {
345
+ emitJson({
346
+ error: "parse_error",
347
+ message: "invalid JSON"
348
+ });
165
349
  process.exitCode = 1;
166
350
  return;
167
351
  }
168
- const port = Number.parseInt(opts.port, 10);
169
- const host = opts.host;
352
+ emitJson(await buildClaudeHookInjection(payload, opts));
353
+ });
354
+ //#endregion
355
+ //#region src/commands/config.ts
356
+ function parseIntegerOption(value, flagName) {
357
+ if (value == null) return void 0;
358
+ const trimmed = value.trim();
359
+ if (!/^\d+$/.test(trimmed)) throw new Error(`Invalid ${flagName}: ${value}`);
360
+ return Number.parseInt(trimmed, 10);
361
+ }
362
+ function buildWorkspaceConfigPatch(opts) {
363
+ const patch = {};
364
+ if (opts.syncEnabled === true) patch.sync_enabled = true;
365
+ if (opts.syncEnabled === false) patch.sync_enabled = false;
366
+ if (opts.syncHost?.trim()) patch.sync_host = opts.syncHost.trim();
367
+ const syncPort = parseIntegerOption(opts.syncPort, "--sync-port");
368
+ if (syncPort != null) patch.sync_port = syncPort;
369
+ const syncInterval = parseIntegerOption(opts.syncIntervalS, "--sync-interval-s");
370
+ if (syncInterval != null) patch.sync_interval_s = syncInterval;
371
+ if (opts.coordinatorUrl?.trim()) patch.sync_coordinator_url = opts.coordinatorUrl.trim();
372
+ if (opts.coordinatorGroup?.trim()) patch.sync_coordinator_group = opts.coordinatorGroup.trim();
373
+ return patch;
374
+ }
375
+ function mergeWorkspaceConfig(existingConfig, patch) {
376
+ return {
377
+ ...existingConfig,
378
+ ...patch
379
+ };
380
+ }
381
+ function runWorkspaceConfigCommand(opts) {
382
+ if (opts.enableSync && opts.disableSync) throw new Error("Use only one of --enable-sync or --disable-sync");
383
+ const workspaceId = opts.workspaceId;
384
+ if (!workspaceId) throw new Error("workspace-id is required");
385
+ const configPath = getWorkspaceCodememConfigPath(workspaceId);
386
+ const existingConfig = existsSync(configPath) ? readCodememConfigFileAtPath(configPath) : readCodememConfigFile();
387
+ const patch = buildWorkspaceConfigPatch({
388
+ workspaceId,
389
+ syncEnabled: opts.enableSync ? true : opts.disableSync ? false : void 0,
390
+ syncHost: opts.syncHost,
391
+ syncPort: opts.syncPort,
392
+ syncIntervalS: opts.syncIntervalS,
393
+ coordinatorUrl: opts.coordinatorUrl,
394
+ coordinatorGroup: opts.coordinatorGroup,
395
+ json: opts.json
396
+ });
397
+ if (Object.keys(patch).length === 0) throw new Error("Provide at least one config field to update");
398
+ const nextConfig = mergeWorkspaceConfig(existingConfig, patch);
399
+ return {
400
+ workspace_id: workspaceId,
401
+ config_path: writeCodememConfigFile(nextConfig, configPath),
402
+ updated_keys: Object.keys(patch).sort(),
403
+ config: nextConfig
404
+ };
405
+ }
406
+ var configCommand = new Command("config").configureHelp(helpStyle).description("Manage codemem configuration");
407
+ var workspaceCmd = new Command("workspace").configureHelp(helpStyle).description("Create or update workspace-scoped codemem config").argument("[workspace-id]", "workspace identifier").option("--enable-sync", "set sync_enabled=true").option("--disable-sync", "set sync_enabled=false").option("--sync-host <host>", "set sync_host").option("--sync-port <port>", "set sync_port").option("--sync-interval-s <seconds>", "set sync_interval_s").option("--coordinator-url <url>", "set sync_coordinator_url").option("--coordinator-group <group>", "set sync_coordinator_group");
408
+ workspaceCmd.addOption(new Option("--workspace-id <id>", "workspace identifier (use positional instead)").hideHelp());
409
+ addJsonOption(workspaceCmd);
410
+ workspaceCmd.action((workspaceIdArg, opts) => {
170
411
  try {
171
- const result = await ingestClaudeHookPayload(payload, {
172
- host,
173
- port,
174
- db: opts.db,
175
- dbPath: opts.dbPath
412
+ const workspaceId = workspaceIdArg || opts.workspaceId;
413
+ if (!workspaceId) {
414
+ console.error("Error: missing required argument 'workspace-id'");
415
+ process.exitCode = 2;
416
+ return;
417
+ }
418
+ const result = runWorkspaceConfigCommand({
419
+ ...opts,
420
+ workspaceId
176
421
  });
177
- console.log(JSON.stringify(result));
178
- } catch {
422
+ if (opts.json) {
423
+ console.log(JSON.stringify(result, null, 2));
424
+ return;
425
+ }
426
+ console.log(`Updated workspace config: ${result.config_path}`);
427
+ console.log(`Updated keys: ${result.updated_keys.join(", ")}`);
428
+ } catch (err) {
429
+ if (opts.json) console.log(JSON.stringify({
430
+ error: "config_error",
431
+ message: err instanceof Error ? err.message : String(err)
432
+ }));
433
+ else console.error(err instanceof Error ? err.message : String(err));
434
+ process.exitCode = 1;
435
+ }
436
+ });
437
+ configCommand.addCommand(workspaceCmd);
438
+ function formatWhereHuman(result) {
439
+ const lines = [];
440
+ const allEntries = [result.resolved, ...result.fallbackChain];
441
+ const sourceOrder = {
442
+ "cli-flag": 0,
443
+ "env-codemem-config": 1,
444
+ "env-runtime-root": 2,
445
+ "env-workspace-id": 3,
446
+ "legacy-global": 4
447
+ };
448
+ allEntries.sort((a, b) => (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99));
449
+ for (const entry of allEntries) {
450
+ const marker = entry === result.resolved ? ">>>" : " ";
451
+ const existsLabel = entry.exists ? "exists" : "missing";
452
+ lines.push(`${marker} [${entry.source}] ${entry.path}`);
453
+ lines.push(` ${entry.reason} (${existsLabel})`);
454
+ }
455
+ return lines.join("\n");
456
+ }
457
+ var whereCmd = new Command("where").configureHelp(helpStyle).description("show resolved config file path");
458
+ addConfigOption(whereCmd);
459
+ addJsonOption(whereCmd);
460
+ whereCmd.action((opts) => {
461
+ try {
462
+ const result = resolveCodememConfigPath(opts.config, "read");
463
+ if (opts.json) console.log(JSON.stringify(result, null, 2));
464
+ else console.log(formatWhereHuman(result));
465
+ } catch (err) {
466
+ if (opts.json) console.log(JSON.stringify({
467
+ error: "config_error",
468
+ message: err instanceof Error ? err.message : String(err)
469
+ }));
470
+ else console.error(err instanceof Error ? err.message : String(err));
179
471
  process.exitCode = 1;
180
472
  }
181
473
  });
474
+ configCommand.addCommand(whereCmd);
475
+ //#endregion
476
+ //#region src/commands/coordinator.ts
477
+ /**
478
+ * Coordinator CLI commands — manage coordinator invites, join requests, and relay server.
479
+ *
480
+ * Extracted from sync.ts to give coordinator admin its own top-level group
481
+ * per cli-design-conventions.md (operator/admin surfaces belong in their own group).
482
+ *
483
+ * buildCoordinatorCommand() is a factory that creates a fresh command tree.
484
+ * This allows both the canonical top-level `coordinator` and the deprecated
485
+ * `sync coordinator` alias to have independent Commander instances (Commander
486
+ * re-parents commands on addCommand, so sharing instances between two parents
487
+ * is not possible).
488
+ */
489
+ function readCoordinatorPublicKey(opts) {
490
+ const inline = String(opts.publicKey ?? "").trim();
491
+ const filePath = String(opts.publicKeyFile ?? "").trim();
492
+ if (inline && filePath) throw new Error("Use only one of --public-key or --public-key-file");
493
+ if (filePath) {
494
+ const text = readFileSync(filePath, "utf8").trim();
495
+ if (!text) throw new Error(`Public key file is empty: ${filePath}`);
496
+ return text;
497
+ }
498
+ if (!inline) throw new Error("Public key required via --public-key or --public-key-file");
499
+ return inline;
500
+ }
501
+ /**
502
+ * Build a fresh coordinator command tree. Each call returns independent
503
+ * Commander instances so the tree can be mounted under multiple parents.
504
+ */
505
+ function buildCoordinatorCommand() {
506
+ const cmd = new Command("coordinator").configureHelp(helpStyle).description("Manage coordinator invites, join requests, and relay server");
507
+ const groupCreateCmd = new Command("group-create").configureHelp(helpStyle).description("Create a coordinator group in the local store").argument("<group>", "group id").option("--name <name>", "display name override");
508
+ addDbOption(groupCreateCmd);
509
+ addJsonOption(groupCreateCmd);
510
+ groupCreateCmd.action(async (groupId, opts) => {
511
+ try {
512
+ const group = await coordinatorCreateGroupAction({
513
+ groupId,
514
+ displayName: opts.name?.trim() || null,
515
+ dbPath: resolveDbOpt(opts) ?? null
516
+ });
517
+ if (opts.json) {
518
+ console.log(JSON.stringify(group, null, 2));
519
+ return;
520
+ }
521
+ p.intro("codemem coordinator group-create");
522
+ p.log.success(`Group ready: ${groupId.trim()}`);
523
+ p.outro(String(group.display_name ?? group.group_id ?? groupId.trim()));
524
+ } catch (err) {
525
+ if (opts.json) {
526
+ emitJsonError("group_create_failed", err instanceof Error ? err.message : String(err));
527
+ return;
528
+ }
529
+ p.log.error(err instanceof Error ? err.message : String(err));
530
+ process.exitCode = 1;
531
+ }
532
+ });
533
+ cmd.addCommand(groupCreateCmd);
534
+ const listGroupsCmd = new Command("list-groups").configureHelp(helpStyle).description("List coordinator groups from the local store");
535
+ addDbOption(listGroupsCmd);
536
+ addJsonOption(listGroupsCmd);
537
+ listGroupsCmd.action(async (opts) => {
538
+ try {
539
+ const groups = await coordinatorListGroupsAction({ dbPath: resolveDbOpt(opts) ?? null });
540
+ if (opts.json) {
541
+ console.log(JSON.stringify(groups, null, 2));
542
+ return;
543
+ }
544
+ p.intro("codemem coordinator list-groups");
545
+ if (groups.length === 0) {
546
+ p.outro("No coordinator groups found");
547
+ return;
548
+ }
549
+ for (const group of groups) p.log.message(`- ${String(group.group_id ?? "")}${group.display_name ? ` (${String(group.display_name)})` : ""}`);
550
+ p.outro(`${groups.length} group(s)`);
551
+ } catch (err) {
552
+ if (opts.json) {
553
+ emitJsonError("list_groups_failed", err instanceof Error ? err.message : String(err));
554
+ return;
555
+ }
556
+ p.log.error(err instanceof Error ? err.message : String(err));
557
+ process.exitCode = 1;
558
+ }
559
+ });
560
+ cmd.addCommand(listGroupsCmd);
561
+ const enrollDeviceCmd = new Command("enroll-device").configureHelp(helpStyle).description("Enroll a device in a local coordinator group").argument("<group>", "group id").argument("<device-id>", "device id").option("--fingerprint <fingerprint>", "device fingerprint").option("--public-key <key>", "device public key").option("--public-key-file <path>", "path to device public key").option("--name <name>", "display name");
562
+ addDbOption(enrollDeviceCmd);
563
+ addJsonOption(enrollDeviceCmd);
564
+ enrollDeviceCmd.action(async (groupId, deviceId, opts) => {
565
+ try {
566
+ const publicKey = readCoordinatorPublicKey(opts);
567
+ const fingerprint = String(opts.fingerprint ?? "").trim();
568
+ if (!fingerprint) {
569
+ if (opts.json) {
570
+ emitJsonError("usage_error", "Fingerprint required via --fingerprint", 2);
571
+ return;
572
+ }
573
+ p.log.error("Fingerprint required via --fingerprint");
574
+ process.exitCode = 1;
575
+ return;
576
+ }
577
+ if (fingerprintPublicKey(publicKey) !== fingerprint) {
578
+ if (opts.json) {
579
+ emitJsonError("fingerprint_mismatch", "Fingerprint does not match the provided public key");
580
+ return;
581
+ }
582
+ p.log.error("Fingerprint does not match the provided public key");
583
+ process.exitCode = 1;
584
+ return;
585
+ }
586
+ const enrollment = await coordinatorEnrollDeviceAction({
587
+ groupId,
588
+ deviceId,
589
+ fingerprint,
590
+ publicKey,
591
+ displayName: opts.name?.trim() || null,
592
+ dbPath: resolveDbOpt(opts) ?? null
593
+ });
594
+ if (opts.json) {
595
+ console.log(JSON.stringify(enrollment, null, 2));
596
+ return;
597
+ }
598
+ p.intro("codemem coordinator enroll-device");
599
+ p.log.success(`Enrolled ${deviceId.trim()} in ${groupId.trim()}`);
600
+ p.outro(String(enrollment.display_name ?? enrollment.device_id ?? deviceId.trim()));
601
+ } catch (err) {
602
+ if (opts.json) {
603
+ emitJsonError("enroll_failed", err instanceof Error ? err.message : String(err));
604
+ return;
605
+ }
606
+ p.log.error(err instanceof Error ? err.message : String(err));
607
+ process.exitCode = 1;
608
+ }
609
+ });
610
+ cmd.addCommand(enrollDeviceCmd);
611
+ const listDevicesCmd = new Command("list-devices").configureHelp(helpStyle).description("List enrolled devices in a local coordinator group").argument("<group>", "group id").option("--include-disabled", "include disabled devices");
612
+ addDbOption(listDevicesCmd);
613
+ addJsonOption(listDevicesCmd);
614
+ listDevicesCmd.action(async (groupId, opts) => {
615
+ try {
616
+ const rows = await coordinatorListDevicesAction({
617
+ groupId,
618
+ includeDisabled: opts.includeDisabled === true,
619
+ dbPath: resolveDbOpt(opts) ?? null
620
+ });
621
+ if (opts.json) {
622
+ console.log(JSON.stringify(rows, null, 2));
623
+ return;
624
+ }
625
+ p.intro("codemem coordinator list-devices");
626
+ if (rows.length === 0) {
627
+ p.outro(`No enrolled devices for ${groupId.trim()}`);
628
+ return;
629
+ }
630
+ for (const row of rows) {
631
+ const label = String(row.display_name ?? row.device_id ?? "").trim() || String(row.device_id ?? "");
632
+ const enabled = Number(row.enabled ?? 1) === 1 ? "enabled" : "disabled";
633
+ p.log.message(`- ${label} (${String(row.device_id ?? "")}) ${enabled}`);
634
+ }
635
+ p.outro(`${rows.length} device(s)`);
636
+ } catch (err) {
637
+ if (opts.json) {
638
+ emitJsonError("list_devices_failed", err instanceof Error ? err.message : String(err));
639
+ return;
640
+ }
641
+ p.log.error(err instanceof Error ? err.message : String(err));
642
+ process.exitCode = 1;
643
+ }
644
+ });
645
+ cmd.addCommand(listDevicesCmd);
646
+ const renameDeviceCmd = new Command("rename-device").configureHelp(helpStyle).description("Rename an enrolled device in the local coordinator store").argument("<group>", "group id").argument("<device-id>", "device id").requiredOption("--name <name>", "display name");
647
+ addDbOption(renameDeviceCmd);
648
+ addJsonOption(renameDeviceCmd);
649
+ renameDeviceCmd.action(async (groupId, deviceId, opts) => {
650
+ try {
651
+ const result = await coordinatorRenameDeviceAction({
652
+ groupId,
653
+ deviceId,
654
+ displayName: opts.name.trim(),
655
+ dbPath: resolveDbOpt(opts) ?? null
656
+ });
657
+ if (!result) {
658
+ if (opts.json) {
659
+ emitJsonError("device_not_found", `Device not found: ${deviceId.trim()}`);
660
+ return;
661
+ }
662
+ p.log.error(`Device not found: ${deviceId.trim()}`);
663
+ process.exitCode = 1;
664
+ return;
665
+ }
666
+ if (opts.json) {
667
+ console.log(JSON.stringify(result, null, 2));
668
+ return;
669
+ }
670
+ p.intro("codemem coordinator rename-device");
671
+ p.log.success(`Renamed ${deviceId.trim()} in ${groupId.trim()}`);
672
+ p.outro(String(result.display_name ?? result.device_id ?? deviceId.trim()));
673
+ } catch (err) {
674
+ if (opts.json) {
675
+ emitJsonError("rename_failed", err instanceof Error ? err.message : String(err));
676
+ return;
677
+ }
678
+ p.log.error(err instanceof Error ? err.message : String(err));
679
+ process.exitCode = 1;
680
+ }
681
+ });
682
+ cmd.addCommand(renameDeviceCmd);
683
+ const disableDeviceCmd = new Command("disable-device").configureHelp(helpStyle).description("Disable an enrolled device in the local coordinator store").argument("<group>", "group id").argument("<device-id>", "device id");
684
+ addDbOption(disableDeviceCmd);
685
+ addJsonOption(disableDeviceCmd);
686
+ disableDeviceCmd.action(async (groupId, deviceId, opts) => {
687
+ try {
688
+ if (!await coordinatorDisableDeviceAction({
689
+ groupId,
690
+ deviceId,
691
+ dbPath: resolveDbOpt(opts) ?? null
692
+ })) {
693
+ if (opts.json) {
694
+ emitJsonError("device_not_found", `Device not found: ${deviceId.trim()}`);
695
+ return;
696
+ }
697
+ p.log.error(`Device not found: ${deviceId.trim()}`);
698
+ process.exitCode = 1;
699
+ return;
700
+ }
701
+ if (opts.json) {
702
+ console.log(JSON.stringify({
703
+ ok: true,
704
+ group_id: groupId.trim(),
705
+ device_id: deviceId.trim()
706
+ }, null, 2));
707
+ return;
708
+ }
709
+ p.intro("codemem coordinator disable-device");
710
+ p.log.success(`Disabled ${deviceId.trim()} in ${groupId.trim()}`);
711
+ p.outro("disabled");
712
+ } catch (err) {
713
+ if (opts.json) {
714
+ emitJsonError("disable_failed", err instanceof Error ? err.message : String(err));
715
+ return;
716
+ }
717
+ p.log.error(err instanceof Error ? err.message : String(err));
718
+ process.exitCode = 1;
719
+ }
720
+ });
721
+ cmd.addCommand(disableDeviceCmd);
722
+ const removeDeviceCmd = new Command("remove-device").configureHelp(helpStyle).description("Remove an enrolled device from the local coordinator store").argument("<group>", "group id").argument("<device-id>", "device id");
723
+ addDbOption(removeDeviceCmd);
724
+ addJsonOption(removeDeviceCmd);
725
+ removeDeviceCmd.action(async (groupId, deviceId, opts) => {
726
+ try {
727
+ if (!await coordinatorRemoveDeviceAction({
728
+ groupId,
729
+ deviceId,
730
+ dbPath: resolveDbOpt(opts) ?? null
731
+ })) {
732
+ if (opts.json) {
733
+ emitJsonError("device_not_found", `Device not found: ${deviceId.trim()}`);
734
+ return;
735
+ }
736
+ p.log.error(`Device not found: ${deviceId.trim()}`);
737
+ process.exitCode = 1;
738
+ return;
739
+ }
740
+ if (opts.json) {
741
+ console.log(JSON.stringify({
742
+ ok: true,
743
+ group_id: groupId.trim(),
744
+ device_id: deviceId.trim()
745
+ }, null, 2));
746
+ return;
747
+ }
748
+ p.intro("codemem coordinator remove-device");
749
+ p.log.success(`Removed ${deviceId.trim()} from ${groupId.trim()}`);
750
+ p.outro("removed");
751
+ } catch (err) {
752
+ if (opts.json) {
753
+ emitJsonError("remove_failed", err instanceof Error ? err.message : String(err));
754
+ return;
755
+ }
756
+ p.log.error(err instanceof Error ? err.message : String(err));
757
+ process.exitCode = 1;
758
+ }
759
+ });
760
+ cmd.addCommand(removeDeviceCmd);
761
+ const coordServeCmd = new Command("serve").configureHelp(helpStyle).description("Run the coordinator relay HTTP server").option("--coordinator-host <host>", "bind host").option("--coordinator-port <port>", "bind port");
762
+ coordServeCmd.addOption(new Option("-d, --db-path <path>", "coordinator database path"));
763
+ coordServeCmd.addOption(new Option("--db <path>", "coordinator database path").hideHelp());
764
+ coordServeCmd.addOption(new Option("--host <host>", "bind host").hideHelp());
765
+ coordServeCmd.addOption(new Option("--port <port>", "bind port").hideHelp());
766
+ coordServeCmd.action(async (opts) => {
767
+ const host = String(opts.coordinatorHost ?? opts.host ?? "127.0.0.1").trim() || "127.0.0.1";
768
+ const port = Number.parseInt(String(opts.coordinatorPort ?? opts.port ?? "7347"), 10);
769
+ const dbPath = resolveDbOpt(opts) ?? DEFAULT_COORDINATOR_DB_PATH;
770
+ const app = createBetterSqliteCoordinatorApp({ dbPath });
771
+ p.intro("codemem coordinator serve");
772
+ p.log.success(`Coordinator listening at http://${host}:${port}`);
773
+ p.log.info(`DB: ${dbPath}`);
774
+ serve({
775
+ fetch: app.fetch,
776
+ hostname: host,
777
+ port
778
+ });
779
+ });
780
+ cmd.addCommand(coordServeCmd);
781
+ const listBootstrapGrantsCmd = new Command("list-bootstrap-grants").configureHelp(helpStyle).description("List bootstrap grants for a coordinator group").argument("<group>", "group id").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override");
782
+ addDbOption(listBootstrapGrantsCmd);
783
+ addJsonOption(listBootstrapGrantsCmd);
784
+ listBootstrapGrantsCmd.action(async (groupId, opts) => {
785
+ try {
786
+ const rows = await coordinatorListBootstrapGrantsAction({
787
+ groupId,
788
+ dbPath: resolveDbOpt(opts) ?? null,
789
+ remoteUrl: opts.remoteUrl?.trim() || null,
790
+ adminSecret: opts.adminSecret?.trim() || null
791
+ });
792
+ if (opts.json) {
793
+ console.log(JSON.stringify(rows, null, 2));
794
+ return;
795
+ }
796
+ p.intro("codemem coordinator list-bootstrap-grants");
797
+ if (rows.length === 0) {
798
+ p.outro(`No bootstrap grants for ${groupId.trim()}`);
799
+ return;
800
+ }
801
+ for (const row of rows) p.log.message(`- ${row.grant_id} seed=${row.seed_device_id} worker=${row.worker_device_id} expires=${row.expires_at} revoked=${row.revoked_at ?? "no"}`);
802
+ p.outro(`${rows.length} bootstrap grant(s)`);
803
+ } catch (err) {
804
+ if (opts.json) {
805
+ emitJsonError("list_bootstrap_grants_failed", err instanceof Error ? err.message : String(err));
806
+ return;
807
+ }
808
+ p.log.error(err instanceof Error ? err.message : String(err));
809
+ process.exitCode = 1;
810
+ }
811
+ });
812
+ cmd.addCommand(listBootstrapGrantsCmd);
813
+ const revokeBootstrapGrantCmd = new Command("revoke-bootstrap-grant").configureHelp(helpStyle).description("Revoke a bootstrap grant").argument("<grant-id>", "bootstrap grant id").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override");
814
+ addDbOption(revokeBootstrapGrantCmd);
815
+ addJsonOption(revokeBootstrapGrantCmd);
816
+ revokeBootstrapGrantCmd.action(async (grantId, opts) => {
817
+ try {
818
+ if (!await coordinatorRevokeBootstrapGrantAction({
819
+ grantId,
820
+ dbPath: resolveDbOpt(opts) ?? null,
821
+ remoteUrl: opts.remoteUrl?.trim() || null,
822
+ adminSecret: opts.adminSecret?.trim() || null
823
+ })) {
824
+ if (opts.json) {
825
+ emitJsonError("grant_not_found", `Bootstrap grant not found: ${grantId.trim()}`);
826
+ return;
827
+ }
828
+ p.log.error(`Bootstrap grant not found: ${grantId.trim()}`);
829
+ process.exitCode = 1;
830
+ return;
831
+ }
832
+ if (opts.json) {
833
+ console.log(JSON.stringify({
834
+ ok: true,
835
+ grant_id: grantId.trim()
836
+ }, null, 2));
837
+ return;
838
+ }
839
+ p.intro("codemem coordinator revoke-bootstrap-grant");
840
+ p.log.success(`Revoked ${grantId.trim()}`);
841
+ p.outro("revoked");
842
+ } catch (err) {
843
+ if (opts.json) {
844
+ emitJsonError("revoke_failed", err instanceof Error ? err.message : String(err));
845
+ return;
846
+ }
847
+ p.log.error(err instanceof Error ? err.message : String(err));
848
+ process.exitCode = 1;
849
+ }
850
+ });
851
+ cmd.addCommand(revokeBootstrapGrantCmd);
852
+ const createInviteCmd = new Command("create-invite").configureHelp(helpStyle).description("Create a coordinator team invite").argument("[group]", "group id").option("--group <group>", "group id").option("--coordinator-url <url>", "coordinator URL override").option("--policy <policy>", "invite policy", "auto_admit").option("--ttl-hours <hours>", "invite TTL hours", "24").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override");
853
+ addDbOption(createInviteCmd);
854
+ addJsonOption(createInviteCmd);
855
+ createInviteCmd.action(async (groupArg, opts) => {
856
+ try {
857
+ const ttlHours = Number.parseInt(String(opts.ttlHours ?? "24"), 10);
858
+ const groupId = String(opts.group ?? "").trim() || String(groupArg ?? "").trim();
859
+ const result = await coordinatorCreateInviteAction({
860
+ groupId,
861
+ coordinatorUrl: opts.coordinatorUrl?.trim() || null,
862
+ policy: String(opts.policy ?? "auto_admit").trim(),
863
+ ttlHours,
864
+ createdBy: null,
865
+ dbPath: resolveDbOpt(opts) ?? null,
866
+ remoteUrl: opts.remoteUrl?.trim() || null,
867
+ adminSecret: opts.adminSecret?.trim() || null
868
+ });
869
+ if (opts.json) {
870
+ console.log(JSON.stringify(result, null, 2));
871
+ return;
872
+ }
873
+ p.intro("codemem coordinator create-invite");
874
+ p.log.success(`Invite created for ${groupId}`);
875
+ if (typeof result.link === "string") p.log.message(`- link: ${result.link}`);
876
+ if (typeof result.encoded === "string") p.log.message(`- invite: ${result.encoded}`);
877
+ for (const warning of Array.isArray(result.warnings) ? result.warnings : []) p.log.warn(String(warning));
878
+ p.outro("Invite ready");
879
+ } catch (err) {
880
+ if (opts.json) {
881
+ emitJsonError("create_invite_failed", err instanceof Error ? err.message : String(err));
882
+ return;
883
+ }
884
+ p.log.error(err instanceof Error ? err.message : String(err));
885
+ process.exitCode = 1;
886
+ }
887
+ });
888
+ cmd.addCommand(createInviteCmd);
889
+ const importInviteCmd = new Command("import-invite").configureHelp(helpStyle).description("Import a coordinator invite").argument("<invite>", "invite value or link").option("--keys-dir <path>", "keys directory");
890
+ addDbOption(importInviteCmd);
891
+ addConfigOption(importInviteCmd);
892
+ addJsonOption(importInviteCmd);
893
+ importInviteCmd.action(async (invite, opts) => {
894
+ try {
895
+ const result = await coordinatorImportInviteAction({
896
+ inviteValue: invite,
897
+ dbPath: resolveDbOpt(opts) ?? null,
898
+ keysDir: opts.keysDir ?? null,
899
+ configPath: opts.config ?? null
900
+ });
901
+ if (opts.json) {
902
+ console.log(JSON.stringify(result, null, 2));
903
+ return;
904
+ }
905
+ p.intro("codemem coordinator import-invite");
906
+ p.log.success(`Invite imported for ${result.group_id}`);
907
+ p.log.message(`- coordinator: ${result.coordinator_url}`);
908
+ p.log.message(`- status: ${result.status}`);
909
+ p.outro("Coordinator config updated");
910
+ } catch (err) {
911
+ if (opts.json) {
912
+ emitJsonError("import_invite_failed", err instanceof Error ? err.message : String(err));
913
+ return;
914
+ }
915
+ p.log.error(err instanceof Error ? err.message : String(err));
916
+ process.exitCode = 1;
917
+ }
918
+ });
919
+ cmd.addCommand(importInviteCmd);
920
+ const listJoinRequestsCmd = new Command("list-join-requests").configureHelp(helpStyle).description("List pending coordinator join requests").argument("[group]", "group id").option("--group <group>", "group id").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override");
921
+ addDbOption(listJoinRequestsCmd);
922
+ addJsonOption(listJoinRequestsCmd);
923
+ listJoinRequestsCmd.action(async (groupArg, opts) => {
924
+ try {
925
+ const groupId = String(opts.group ?? "").trim() || String(groupArg ?? "").trim();
926
+ const rows = await coordinatorListJoinRequestsAction({
927
+ groupId,
928
+ dbPath: resolveDbOpt(opts) ?? null,
929
+ remoteUrl: opts.remoteUrl?.trim() || null,
930
+ adminSecret: opts.adminSecret?.trim() || null
931
+ });
932
+ if (opts.json) {
933
+ console.log(JSON.stringify(rows, null, 2));
934
+ return;
935
+ }
936
+ p.intro("codemem coordinator list-join-requests");
937
+ if (rows.length === 0) {
938
+ p.outro(`No pending join requests for ${groupId}`);
939
+ return;
940
+ }
941
+ for (const row of rows) {
942
+ const displayName = row.display_name || row.device_id;
943
+ p.log.message(`- ${displayName} (${row.device_id}) request_id=${row.request_id}`);
944
+ }
945
+ p.outro(`${rows.length} pending join request(s)`);
946
+ } catch (err) {
947
+ if (opts.json) {
948
+ emitJsonError("list_join_requests_failed", err instanceof Error ? err.message : String(err));
949
+ return;
950
+ }
951
+ p.log.error(err instanceof Error ? err.message : String(err));
952
+ process.exitCode = 1;
953
+ }
954
+ });
955
+ cmd.addCommand(listJoinRequestsCmd);
956
+ function addReviewJoinRequestCommand(name, approve) {
957
+ const reviewCmd = new Command(name).configureHelp(helpStyle).description(`${approve ? "Approve" : "Deny"} a coordinator join request`).argument("<request-id>", "join request id").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override");
958
+ addDbOption(reviewCmd);
959
+ addJsonOption(reviewCmd);
960
+ reviewCmd.action(async (requestId, opts) => {
961
+ try {
962
+ const request = await coordinatorReviewJoinRequestAction({
963
+ requestId: requestId.trim(),
964
+ approve,
965
+ reviewedBy: null,
966
+ dbPath: resolveDbOpt(opts) ?? null,
967
+ remoteUrl: opts.remoteUrl?.trim() || null,
968
+ adminSecret: opts.adminSecret?.trim() || null
969
+ });
970
+ if (!request) {
971
+ if (opts.json) {
972
+ emitJsonError("join_request_not_found", `Join request not found: ${requestId.trim()}`);
973
+ return;
974
+ }
975
+ p.log.error(`Join request not found: ${requestId.trim()}`);
976
+ process.exitCode = 1;
977
+ return;
978
+ }
979
+ if (opts.json) {
980
+ console.log(JSON.stringify(request, null, 2));
981
+ return;
982
+ }
983
+ p.intro(`codemem coordinator ${name}`);
984
+ p.log.success(`${approve ? "Approved" : "Denied"} join request ${requestId.trim()}`);
985
+ p.outro(String(request.status ?? "updated"));
986
+ } catch (err) {
987
+ if (opts.json) {
988
+ emitJsonError("review_failed", err instanceof Error ? err.message : String(err));
989
+ return;
990
+ }
991
+ p.log.error(err instanceof Error ? err.message : String(err));
992
+ process.exitCode = 1;
993
+ }
994
+ });
995
+ cmd.addCommand(reviewCmd);
996
+ }
997
+ addReviewJoinRequestCommand("approve-join-request", true);
998
+ addReviewJoinRequestCommand("deny-join-request", false);
999
+ return cmd;
1000
+ }
1001
+ /** Canonical top-level coordinator command for registration in index.ts. */
1002
+ var coordinatorCommand = buildCoordinatorCommand();
182
1003
  //#endregion
183
1004
  //#region src/commands/db.ts
184
1005
  function formatBytes(bytes) {
@@ -210,18 +1031,28 @@ function estimateReplicationOpsBytes(db) {
210
1031
  }
211
1032
  }
212
1033
  var dbCommand = new Command("db").configureHelp(helpStyle).description("Database maintenance");
213
- dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("Create or verify the SQLite database and schema").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action((opts) => {
214
- const result = initDatabase(opts.db ?? opts.dbPath);
1034
+ var initCmd = new Command("init").configureHelp(helpStyle).description("Create or verify the SQLite database and schema");
1035
+ addDbOption(initCmd);
1036
+ initCmd.action((opts) => {
1037
+ const result = initDatabase(resolveDbOpt(opts));
215
1038
  p.intro("codemem db init");
216
1039
  p.log.success(`Database ready: ${result.path}`);
217
1040
  p.outro(`Size: ${result.sizeBytes.toLocaleString()} bytes`);
218
- })).addCommand(new Command("vacuum").configureHelp(helpStyle).description("Run VACUUM on the SQLite database").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action((opts) => {
219
- const result = vacuumDatabase(opts.db ?? opts.dbPath);
1041
+ });
1042
+ dbCommand.addCommand(initCmd);
1043
+ var vacuumCmd = new Command("vacuum").configureHelp(helpStyle).description("Run VACUUM on the SQLite database");
1044
+ addDbOption(vacuumCmd);
1045
+ vacuumCmd.action((opts) => {
1046
+ const result = vacuumDatabase(resolveDbOpt(opts));
220
1047
  p.intro("codemem db vacuum");
221
1048
  p.log.success(`Vacuumed: ${result.path}`);
222
1049
  p.outro(`Size: ${result.sizeBytes.toLocaleString()} bytes`);
223
- })).addCommand(new Command("prune-replication-ops").configureHelp(helpStyle).description("Prune replication op history with approximate oldest-first retention, dry-run, and progress reporting").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--dry-run", "show current size/targets without deleting").option("--max-age-days <days>", "retention age threshold in days", "30").option("--max-size-mb <mb>", "target replication log budget in MB", "512").option("--batch-ops <n>", "max ops deleted per batch", "5000").option("--batch-runtime-ms <ms>", "max runtime per batch in ms", "2000").option("--vacuum", "run VACUUM explicitly after prune completes").action((opts) => {
224
- const dbPath = resolveDbPath(opts.db ?? opts.dbPath);
1050
+ });
1051
+ dbCommand.addCommand(vacuumCmd);
1052
+ var pruneReplCmd = new Command("prune-replication-ops").configureHelp(helpStyle).description("Prune replication op history with approximate oldest-first retention, dry-run, and progress reporting").option("--dry-run", "show current size/targets without deleting").option("--max-age-days <days>", "retention age threshold in days", "30").option("--max-size-mb <mb>", "target replication log budget in MB", "512").option("--batch-ops <n>", "max ops deleted per batch", "5000").option("--batch-runtime-ms <ms>", "max runtime per batch in ms", "2000").option("--vacuum", "run VACUUM explicitly after prune completes");
1053
+ addDbOption(pruneReplCmd);
1054
+ pruneReplCmd.action((opts) => {
1055
+ const dbPath = resolveDbPath(resolveDbOpt(opts));
225
1056
  const db = connect(dbPath);
226
1057
  let dbOpen = true;
227
1058
  try {
@@ -267,8 +1098,13 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
267
1098
  } finally {
268
1099
  if (dbOpen) db.close();
269
1100
  }
270
- })).addCommand(new Command("raw-events-status").configureHelp(helpStyle).description("Show pending raw-event backlog by source stream").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max rows to show", "25").option("--json", "output as JSON").action((opts) => {
271
- const result = getRawEventStatus(opts.db ?? opts.dbPath, Number.parseInt(opts.limit, 10) || 25);
1101
+ });
1102
+ dbCommand.addCommand(pruneReplCmd);
1103
+ var rawEventsStatusCmd = new Command("raw-events-status").configureHelp(helpStyle).description("Show pending raw-event backlog by source stream").option("-n, --limit <n>", "max rows to show", "25");
1104
+ addDbOption(rawEventsStatusCmd);
1105
+ addJsonOption(rawEventsStatusCmd);
1106
+ rawEventsStatusCmd.action((opts) => {
1107
+ const result = getRawEventStatus(resolveDbOpt(opts), Number.parseInt(opts.limit, 10) || 25);
272
1108
  if (opts.json) {
273
1109
  console.log(JSON.stringify(result, null, 2));
274
1110
  return;
@@ -281,12 +1117,21 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
281
1117
  }
282
1118
  for (const item of result.items) p.log.message(`${item.source}:${item.stream_id} pending=${Math.max(0, item.last_received_event_seq - item.last_flushed_event_seq)} received=${item.last_received_event_seq} flushed=${item.last_flushed_event_seq} project=${item.project ?? ""}`);
283
1119
  p.outro("done");
284
- })).addCommand(new Command("raw-events-retry").configureHelp(helpStyle).description("Requeue failed raw-event flush batches").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max failed batches to requeue", "25").action((opts) => {
285
- const result = retryRawEventFailures(opts.db ?? opts.dbPath, Number.parseInt(opts.limit, 10) || 25);
1120
+ });
1121
+ dbCommand.addCommand(rawEventsStatusCmd);
1122
+ var rawEventsRetryCmd = new Command("raw-events-retry").configureHelp(helpStyle).description("Requeue failed raw-event flush batches").option("-n, --limit <n>", "max failed batches to requeue", "25");
1123
+ addDbOption(rawEventsRetryCmd);
1124
+ rawEventsRetryCmd.action((opts) => {
1125
+ const result = retryRawEventFailures(resolveDbOpt(opts), Number.parseInt(opts.limit, 10) || 25);
286
1126
  p.intro("codemem db raw-events-retry");
287
1127
  p.outro(`Requeued ${result.retried.toLocaleString()} failed batch(es)`);
288
- })).addCommand(new Command("raw-events-gate").configureHelp(helpStyle).description("Validate raw-event reliability thresholds (non-zero exit on failure)").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--min-flush-success-rate <rate>", "minimum flush success rate", "0.95").option("--max-dropped-event-rate <rate>", "maximum dropped event rate", "0.05").option("--min-session-boundary-accuracy <rate>", "minimum session boundary accuracy", "0.9").option("--window-hours <hours>", "lookback window in hours", "24").option("--json", "output as JSON").action((opts) => {
289
- const result = rawEventsGate(opts.db ?? opts.dbPath, {
1128
+ });
1129
+ dbCommand.addCommand(rawEventsRetryCmd);
1130
+ var rawEventsGateCmd = new Command("raw-events-gate").configureHelp(helpStyle).description("Validate raw-event reliability thresholds (non-zero exit on failure)").option("--min-flush-success-rate <rate>", "minimum flush success rate", "0.95").option("--max-dropped-event-rate <rate>", "maximum dropped event rate", "0.05").option("--min-session-boundary-accuracy <rate>", "minimum session boundary accuracy", "0.9").option("--window-hours <hours>", "lookback window in hours", "24");
1131
+ addDbOption(rawEventsGateCmd);
1132
+ addJsonOption(rawEventsGateCmd);
1133
+ rawEventsGateCmd.action((opts) => {
1134
+ const result = rawEventsGate(resolveDbOpt(opts), {
290
1135
  minFlushSuccessRate: Number.parseFloat(opts.minFlushSuccessRate),
291
1136
  maxDroppedEventRate: Number.parseFloat(opts.maxDroppedEventRate),
292
1137
  minSessionBoundaryAccuracy: Number.parseFloat(opts.minSessionBoundaryAccuracy),
@@ -310,8 +1155,12 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
310
1155
  p.outro("reliability gate FAILED");
311
1156
  process.exitCode = 1;
312
1157
  }
313
- })).addCommand(new Command("rename-project").configureHelp(helpStyle).description("Rename a project across sessions and related tables").argument("<old-name>", "current project name").argument("<new-name>", "new project name").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--apply", "apply changes (default is dry-run)").action((oldName, newName, opts) => {
314
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1158
+ });
1159
+ dbCommand.addCommand(rawEventsGateCmd);
1160
+ var renameProjectCmd = new Command("rename-project").configureHelp(helpStyle).description("Rename a project across sessions and related tables").argument("<old-name>", "current project name").argument("<new-name>", "new project name").option("--apply", "apply changes (default is dry-run)");
1161
+ addDbOption(renameProjectCmd);
1162
+ renameProjectCmd.action((oldName, newName, opts) => {
1163
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
315
1164
  try {
316
1165
  const dryRun = !opts.apply;
317
1166
  const suffixPattern = `%/${oldName.replace(/%/g, "\\%").replace(/_/g, "\\_")}`;
@@ -338,8 +1187,12 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
338
1187
  } finally {
339
1188
  store.close();
340
1189
  }
341
- })).addCommand(new Command("normalize-projects").configureHelp(helpStyle).description("Normalize path-like project identifiers to their basename").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--apply", "apply changes (default is dry-run)").action((opts) => {
342
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1190
+ });
1191
+ dbCommand.addCommand(renameProjectCmd);
1192
+ var normalizeProjectsCmd = new Command("normalize-projects").configureHelp(helpStyle).description("Normalize path-like project identifiers to their basename").option("--apply", "apply changes (default is dry-run)");
1193
+ addDbOption(normalizeProjectsCmd);
1194
+ normalizeProjectsCmd.action((opts) => {
1195
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
343
1196
  try {
344
1197
  const dryRun = !opts.apply;
345
1198
  const tables = ["sessions", "raw_event_sessions"];
@@ -379,8 +1232,13 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
379
1232
  } finally {
380
1233
  store.close();
381
1234
  }
382
- })).addCommand(new Command("size-report").configureHelp(helpStyle).description("Show SQLite file size and major storage consumers").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--limit <n>", "number of largest tables/indexes to show", "12").option("--json", "output as JSON").action((opts) => {
383
- const dbPath = resolveDbPath(opts.db ?? opts.dbPath);
1235
+ });
1236
+ dbCommand.addCommand(normalizeProjectsCmd);
1237
+ var sizeReportCmd = new Command("size-report").configureHelp(helpStyle).description("Show SQLite file size and major storage consumers").option("--limit <n>", "number of largest tables/indexes to show", "12");
1238
+ addDbOption(sizeReportCmd);
1239
+ addJsonOption(sizeReportCmd);
1240
+ sizeReportCmd.action((opts) => {
1241
+ const dbPath = resolveDbPath(resolveDbOpt(opts));
384
1242
  const db = connect(dbPath);
385
1243
  try {
386
1244
  const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 12);
@@ -389,10 +1247,10 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
389
1247
  const freePages = db.prepare("SELECT freelist_count FROM pragma_freelist_count").get();
390
1248
  const pageSize = db.prepare("PRAGMA page_size").get();
391
1249
  const tables = db.prepare(`SELECT name, SUM(pgsize) as size_bytes
392
- FROM dbstat
393
- GROUP BY name
394
- ORDER BY size_bytes DESC
395
- LIMIT ?`).all(limit);
1250
+ FROM dbstat
1251
+ GROUP BY name
1252
+ ORDER BY size_bytes DESC
1253
+ LIMIT ?`).all(limit);
396
1254
  if (opts.json) {
397
1255
  console.log(JSON.stringify({
398
1256
  file_size_bytes: fileSizeBytes,
@@ -419,8 +1277,13 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
419
1277
  } finally {
420
1278
  db.close();
421
1279
  }
422
- })).addCommand(new Command("backfill-tags").configureHelp(helpStyle).description("Populate tags_text for memories where tags are empty").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--limit <n>", "max memories to check").option("--since <iso>", "only memories created at/after this ISO timestamp").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "backfill across all projects").option("--inactive", "include inactive memories").option("--dry-run", "preview updates without writing").option("--json", "output as JSON").action((opts) => {
423
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1280
+ });
1281
+ dbCommand.addCommand(sizeReportCmd);
1282
+ var backfillTagsCmd = new Command("backfill-tags").configureHelp(helpStyle).description("Populate tags_text for memories where tags are empty").option("--limit <n>", "max memories to check").option("--since <iso>", "only memories created at/after this ISO timestamp").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "backfill across all projects").option("--inactive", "include inactive memories").option("--dry-run", "preview updates without writing");
1283
+ addDbOption(backfillTagsCmd);
1284
+ addJsonOption(backfillTagsCmd);
1285
+ backfillTagsCmd.action((opts) => {
1286
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
424
1287
  try {
425
1288
  const limit = parseOptionalPositiveInt$1(opts.limit);
426
1289
  const project = opts.allProjects === true ? null : opts.project?.trim() || process.env.CODEMEM_PROJECT?.trim() || resolveProject(process.cwd(), null);
@@ -445,8 +1308,13 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
445
1308
  } finally {
446
1309
  store.close();
447
1310
  }
448
- })).addCommand(new Command("prune-observations").configureHelp(helpStyle).description("Deactivate low-signal observations (does not delete rows)").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--limit <n>", "max observations to check").option("--dry-run", "preview deactivations without writing").option("--json", "output as JSON").action((opts) => {
449
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1311
+ });
1312
+ dbCommand.addCommand(backfillTagsCmd);
1313
+ var pruneObsCmd = new Command("prune-observations").configureHelp(helpStyle).description("Deactivate low-signal observations (does not delete rows)").option("--limit <n>", "max observations to check").option("--dry-run", "preview deactivations without writing");
1314
+ addDbOption(pruneObsCmd);
1315
+ addJsonOption(pruneObsCmd);
1316
+ pruneObsCmd.action((opts) => {
1317
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
450
1318
  try {
451
1319
  const limit = parseOptionalPositiveInt$1(opts.limit);
452
1320
  const result = deactivateLowSignalObservations(store.db, limit ?? null, opts.dryRun === true);
@@ -463,8 +1331,13 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
463
1331
  } finally {
464
1332
  store.close();
465
1333
  }
466
- })).addCommand(new Command("prune-memories").configureHelp(helpStyle).description("Deactivate low-signal memories across selected kinds").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--limit <n>", "max memories to check").option("--kinds <csv>", "comma-separated memory kinds (default set when omitted)").option("--dry-run", "preview deactivations without writing").option("--json", "output as JSON").action((opts) => {
467
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1334
+ });
1335
+ dbCommand.addCommand(pruneObsCmd);
1336
+ var pruneMemCmd = new Command("prune-memories").configureHelp(helpStyle).description("Deactivate low-signal memories across selected kinds").option("--limit <n>", "max memories to check").option("--kinds <csv>", "comma-separated memory kinds (default set when omitted)").option("--dry-run", "preview deactivations without writing");
1337
+ addDbOption(pruneMemCmd);
1338
+ addJsonOption(pruneMemCmd);
1339
+ pruneMemCmd.action((opts) => {
1340
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
468
1341
  try {
469
1342
  const limit = parseOptionalPositiveInt$1(opts.limit);
470
1343
  const kinds = parseKindsCsv(opts.kinds);
@@ -486,7 +1359,8 @@ dbCommand.addCommand(new Command("init").configureHelp(helpStyle).description("C
486
1359
  } finally {
487
1360
  store.close();
488
1361
  }
489
- }));
1362
+ });
1363
+ dbCommand.addCommand(pruneMemCmd);
490
1364
  //#endregion
491
1365
  //#region src/commands/embed.ts
492
1366
  function parseOptionalPositiveInt(value) {
@@ -503,8 +1377,11 @@ function resolveEmbedProjectScope(cwd, projectOpt, allProjects) {
503
1377
  if (envProject) return envProject;
504
1378
  return resolveProject(cwd, null);
505
1379
  }
506
- var embedCommand = new Command("embed").configureHelp(helpStyle).description("Backfill semantic embeddings").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--limit <n>", "max memories to check").option("--since <iso>", "only memories created at/after this ISO timestamp").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "embed across all projects").option("--inactive", "include inactive memories").option("--dry-run", "preview work without writing vectors").option("--json", "output as JSON").action(async (opts) => {
507
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1380
+ var embedCmd = new Command("embed").configureHelp(helpStyle).description("Backfill semantic embeddings").option("--limit <n>", "max memories to check").option("--since <iso>", "only memories created at/after this ISO timestamp").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "embed across all projects").option("--inactive", "include inactive memories").option("--dry-run", "preview work without writing vectors");
1381
+ addDbOption(embedCmd);
1382
+ addJsonOption(embedCmd);
1383
+ var embedCommand = embedCmd.action(async (opts) => {
1384
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
508
1385
  try {
509
1386
  const limit = parseOptionalPositiveInt(opts.limit);
510
1387
  const project = resolveEmbedProjectScope(process.cwd(), opts.project, opts.allProjects);
@@ -547,7 +1424,7 @@ function resolveSessionStreamId(payload) {
547
1424
  if (text) values.set(key, text);
548
1425
  }
549
1426
  if (values.size === 0) return null;
550
- if (new Set(values.values()).size > 1) throw new Error("conflicting session id fields");
1427
+ if (new Set(values.values()).size > 1) return null;
551
1428
  for (const key of SESSION_ID_KEYS) {
552
1429
  const value = values.get(key);
553
1430
  if (value) return value;
@@ -563,39 +1440,61 @@ async function readStdinJson() {
563
1440
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("payload must be an object");
564
1441
  return parsed;
565
1442
  }
566
- var enqueueRawEventCommand = new Command("enqueue-raw-event").configureHelp(helpStyle).description("Enqueue one raw event from stdin into the durable queue").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action(async (opts) => {
567
- const payload = await readStdinJson();
568
- const sessionId = resolveSessionStreamId(payload);
569
- if (!sessionId) throw new Error("session id required");
570
- if (sessionId.startsWith("msg_")) throw new Error("invalid session id");
571
- const eventType = typeof payload.event_type === "string" ? payload.event_type.trim() : "";
572
- if (!eventType) throw new Error("event_type required");
573
- const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
574
- const project = typeof payload.project === "string" ? payload.project : null;
575
- const startedAt = typeof payload.started_at === "string" ? payload.started_at : null;
576
- const tsWallMs = Number.isFinite(Number(payload.ts_wall_ms)) ? Math.floor(Number(payload.ts_wall_ms)) : null;
577
- const tsMonoMs = Number.isFinite(Number(payload.ts_mono_ms)) ? Number(payload.ts_mono_ms) : null;
578
- const eventId = typeof payload.event_id === "string" ? payload.event_id.trim() : "";
579
- const eventPayload = payload.payload && typeof payload.payload === "object" && !Array.isArray(payload.payload) ? stripPrivateObj(payload.payload) : {};
580
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1443
+ function emitStructuredError(errorCode, message) {
1444
+ console.log(JSON.stringify({
1445
+ error: errorCode,
1446
+ message
1447
+ }));
1448
+ process.exitCode = 1;
1449
+ }
1450
+ var enqueueCmd = new Command("enqueue-raw-event").configureHelp(helpStyle).description("Enqueue one raw event from stdin into the durable queue");
1451
+ addDbOption(enqueueCmd);
1452
+ var enqueueRawEventCommand = enqueueCmd.action(async (opts) => {
581
1453
  try {
582
- store.updateRawEventSessionMeta({
583
- opencodeSessionId: sessionId,
584
- source: "opencode",
585
- cwd,
586
- project,
587
- startedAt,
588
- lastSeenTsWallMs: tsWallMs
589
- });
590
- store.recordRawEventsBatch(sessionId, [{
591
- event_id: eventId,
592
- event_type: eventType,
593
- payload: eventPayload,
594
- ts_wall_ms: tsWallMs,
595
- ts_mono_ms: tsMonoMs
596
- }]);
597
- } finally {
598
- store.close();
1454
+ const payload = await readStdinJson();
1455
+ const sessionId = resolveSessionStreamId(payload);
1456
+ if (!sessionId) {
1457
+ emitStructuredError("validation_error", "session id required");
1458
+ return;
1459
+ }
1460
+ if (sessionId.startsWith("msg_")) {
1461
+ emitStructuredError("validation_error", "invalid session id");
1462
+ return;
1463
+ }
1464
+ const eventType = typeof payload.event_type === "string" ? payload.event_type.trim() : "";
1465
+ if (!eventType) {
1466
+ emitStructuredError("validation_error", "event_type required");
1467
+ return;
1468
+ }
1469
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
1470
+ const project = typeof payload.project === "string" ? payload.project : null;
1471
+ const startedAt = typeof payload.started_at === "string" ? payload.started_at : null;
1472
+ const tsWallMs = Number.isFinite(Number(payload.ts_wall_ms)) ? Math.floor(Number(payload.ts_wall_ms)) : null;
1473
+ const tsMonoMs = Number.isFinite(Number(payload.ts_mono_ms)) ? Number(payload.ts_mono_ms) : null;
1474
+ const eventId = typeof payload.event_id === "string" ? payload.event_id.trim() : "";
1475
+ const eventPayload = payload.payload && typeof payload.payload === "object" && !Array.isArray(payload.payload) ? stripPrivateObj(payload.payload) : {};
1476
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
1477
+ try {
1478
+ store.updateRawEventSessionMeta({
1479
+ opencodeSessionId: sessionId,
1480
+ source: "opencode",
1481
+ cwd,
1482
+ project,
1483
+ startedAt,
1484
+ lastSeenTsWallMs: tsWallMs
1485
+ });
1486
+ store.recordRawEventsBatch(sessionId, [{
1487
+ event_id: eventId,
1488
+ event_type: eventType,
1489
+ payload: eventPayload,
1490
+ ts_wall_ms: tsWallMs,
1491
+ ts_mono_ms: tsMonoMs
1492
+ }]);
1493
+ } finally {
1494
+ store.close();
1495
+ }
1496
+ } catch (err) {
1497
+ emitStructuredError("enqueue_error", err instanceof Error ? err.message : String(err));
599
1498
  }
600
1499
  });
601
1500
  //#endregion
@@ -603,9 +1502,11 @@ var enqueueRawEventCommand = new Command("enqueue-raw-event").configureHelp(help
603
1502
  function expandUserPath(value) {
604
1503
  return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
605
1504
  }
606
- var exportMemoriesCommand = new Command("export-memories").configureHelp(helpStyle).description("Export memories to a JSON file for sharing or backup").argument("<output>", "output file path (use '-' for stdout)").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--project <project>", "filter by project (defaults to git repo root)").option("--all-projects", "export all projects").option("--include-inactive", "include deactivated memories").option("--since <iso>", "only export memories created after this ISO timestamp").action((output, opts) => {
1505
+ var cmd$2 = new Command("export-memories").configureHelp(helpStyle).description("Export memories to a JSON file for sharing or backup").argument("<output>", "output file path (use '-' for stdout)").option("--project <project>", "filter by project (defaults to git repo root)").option("--all-projects", "export all projects").option("--include-inactive", "include deactivated memories").option("--since <iso>", "only export memories created after this ISO timestamp");
1506
+ addDbOption(cmd$2);
1507
+ cmd$2.action((output, opts) => {
607
1508
  const payload = exportMemories({
608
- dbPath: resolveDbPath(opts.db ?? opts.dbPath),
1509
+ dbPath: resolveDbPath(resolveDbOpt(opts)),
609
1510
  project: opts.project,
610
1511
  allProjects: opts.allProjects,
611
1512
  includeInactive: opts.includeInactive,
@@ -628,31 +1529,49 @@ var exportMemoriesCommand = new Command("export-memories").configureHelp(helpSty
628
1529
  ].join("\n"));
629
1530
  p.outro("done");
630
1531
  });
1532
+ var exportMemoriesCommand = cmd$2;
631
1533
  //#endregion
632
1534
  //#region src/commands/import-memories.ts
633
- var importMemoriesCommand = new Command("import-memories").configureHelp(helpStyle).description("Import memories from an exported JSON file").argument("<inputFile>", "input JSON file (use '-' for stdin)").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--remap-project <path>", "remap all projects to this path on import").option("--dry-run", "preview import without writing").action((inputFile, opts) => {
1535
+ var cmd$1 = new Command("import-memories").configureHelp(helpStyle).description("Import memories from an exported JSON file").argument("<inputFile>", "input JSON file (use '-' for stdin)").option("--remap-project <path>", "remap all projects to this path on import").option("--dry-run", "preview import without writing");
1536
+ addDbOption(cmd$1);
1537
+ addJsonOption(cmd$1);
1538
+ cmd$1.action((inputFile, opts) => {
634
1539
  let payload;
635
1540
  try {
636
1541
  payload = readImportPayload(inputFile);
637
1542
  } catch (error) {
638
- p.log.error(error instanceof Error ? error.message : "Invalid import file");
639
- process.exitCode = 1;
1543
+ const message = error instanceof Error ? error.message : "Invalid import file";
1544
+ if (opts.json) emitJsonError("invalid_input", message);
1545
+ else {
1546
+ p.log.error(message);
1547
+ process.exitCode = 1;
1548
+ }
640
1549
  return;
641
1550
  }
642
- p.intro("codemem import-memories");
643
- p.log.info([
644
- `Export version: ${payload.version}`,
645
- `Exported at: ${payload.exported_at}`,
646
- `Sessions: ${payload.sessions.length.toLocaleString()}`,
647
- `Memories: ${payload.memory_items.length.toLocaleString()}`,
648
- `Summaries: ${payload.session_summaries.length.toLocaleString()}`,
649
- `Prompts: ${payload.user_prompts.length.toLocaleString()}`
650
- ].join("\n"));
1551
+ if (!opts.json) {
1552
+ p.intro("codemem import-memories");
1553
+ p.log.info([
1554
+ `Export version: ${payload.version}`,
1555
+ `Exported at: ${payload.exported_at}`,
1556
+ `Sessions: ${payload.sessions.length.toLocaleString()}`,
1557
+ `Memories: ${payload.memory_items.length.toLocaleString()}`,
1558
+ `Summaries: ${payload.session_summaries.length.toLocaleString()}`,
1559
+ `Prompts: ${payload.user_prompts.length.toLocaleString()}`
1560
+ ].join("\n"));
1561
+ }
651
1562
  const result = importMemories(payload, {
652
- dbPath: resolveDbPath(opts.db ?? opts.dbPath),
1563
+ dbPath: resolveDbPath(resolveDbOpt(opts)),
653
1564
  remapProject: opts.remapProject,
654
1565
  dryRun: opts.dryRun
655
1566
  });
1567
+ if (opts.json) {
1568
+ console.log(JSON.stringify({
1569
+ sessions: result.sessions,
1570
+ memory_items: result.memory_items,
1571
+ skipped: result.dryRun
1572
+ }));
1573
+ return;
1574
+ }
656
1575
  if (result.dryRun) {
657
1576
  p.outro("dry run complete");
658
1577
  return;
@@ -665,25 +1584,53 @@ var importMemoriesCommand = new Command("import-memories").configureHelp(helpSty
665
1584
  ].join("\n"));
666
1585
  p.outro("done");
667
1586
  });
1587
+ var importMemoriesCommand = cmd$1;
668
1588
  //#endregion
669
1589
  //#region src/commands/mcp.ts
670
- var mcpCommand = new Command("mcp").configureHelp(helpStyle).description("Start the MCP stdio server").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").action(async (opts) => {
671
- const dbPath = opts.db ?? opts.dbPath;
1590
+ var mcpCmd = new Command("mcp").configureHelp(helpStyle).description("Start the MCP stdio server");
1591
+ addDbOption(mcpCmd);
1592
+ var mcpCommand = mcpCmd.action(async (opts) => {
1593
+ const dbPath = resolveDbOpt(opts);
672
1594
  if (dbPath) process.env.CODEMEM_DB = dbPath;
673
- await import("@codemem/mcp");
1595
+ try {
1596
+ await import("@codemem/mcp");
1597
+ } catch (err) {
1598
+ console.error(`Failed to start MCP server: ${err instanceof Error ? err.message : String(err)}`);
1599
+ process.exitCode = 1;
1600
+ }
674
1601
  });
675
1602
  //#endregion
1603
+ //#region src/commands/pack-shared.ts
1604
+ function collectWorkingSetFile(value, previous) {
1605
+ return [...previous, value];
1606
+ }
1607
+ function buildPackRequestOptions(opts, ctx = {}) {
1608
+ const limit = Number.parseInt(opts.limit ?? "10", 10) || 10;
1609
+ const budgetRaw = opts.tokenBudget ?? opts.budget;
1610
+ const budget = budgetRaw ? Number.parseInt(budgetRaw, 10) : void 0;
1611
+ const filters = {};
1612
+ if (!opts.allProjects) {
1613
+ const defaultProject = ctx.envProject?.trim() || null;
1614
+ const resolveProjectFn = ctx.resolveProjectFn ?? resolveProject;
1615
+ const cwd = ctx.cwd ?? process.cwd();
1616
+ const project = defaultProject || resolveProjectFn(cwd, opts.project ?? null);
1617
+ if (project) filters.project = project;
1618
+ }
1619
+ if ((opts.workingSetFile?.length ?? 0) > 0) filters.working_set_paths = opts.workingSetFile;
1620
+ return {
1621
+ limit,
1622
+ budget,
1623
+ filters
1624
+ };
1625
+ }
1626
+ //#endregion
676
1627
  //#region src/commands/memory.ts
677
1628
  /**
678
- * Memory management CLI commands — show, forget, remember.
1629
+ * Memory management CLI commands — show, forget, remember, inject.
679
1630
  *
680
1631
  * Ports codemem/commands/memory_cmds.py (show_cmd, forget_cmd, remember_cmd).
681
- * Compact and inject are deferred compact requires the summarizer pipeline,
682
- * and inject is just pack_text output which the existing pack command covers.
1632
+ * Inject is deprecated in favor of `codemem pack`.
683
1633
  */
684
- function collectWorkingSetFile$1(value, previous) {
685
- return [...previous, value];
686
- }
687
1634
  /** Parse a strict positive integer, rejecting prefixes like "12abc". */
688
1635
  function parseStrictPositiveId(value) {
689
1636
  if (!/^\d+$/.test(value.trim())) return null;
@@ -693,19 +1640,35 @@ function parseStrictPositiveId(value) {
693
1640
  function showMemoryAction(idStr, opts) {
694
1641
  const memoryId = parseStrictPositiveId(idStr);
695
1642
  if (memoryId === null) {
696
- p.log.error(`Invalid memory ID: ${idStr}`);
697
- process.exitCode = 1;
1643
+ if (opts.json) emitJsonError("invalid_id", `Invalid memory ID: ${idStr}`);
1644
+ else {
1645
+ p.log.error(`Invalid memory ID: ${idStr}`);
1646
+ process.exitCode = 1;
1647
+ }
698
1648
  return;
699
1649
  }
700
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1650
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
701
1651
  try {
702
1652
  const item = store.get(memoryId);
703
1653
  if (!item) {
704
- p.log.error(`Memory ${memoryId} not found`);
705
- process.exitCode = 1;
1654
+ if (opts.json) emitJsonError("not_found", `Memory ${memoryId} not found`);
1655
+ else {
1656
+ p.log.error(`Memory ${memoryId} not found`);
1657
+ process.exitCode = 1;
1658
+ }
706
1659
  return;
707
1660
  }
708
- console.log(JSON.stringify(item, null, 2));
1661
+ if (opts.json) console.log(JSON.stringify(item, null, 2));
1662
+ else {
1663
+ console.log(`#${item.id} [${item.kind}] ${item.title}`);
1664
+ if (item.subtitle) console.log(` ${item.subtitle}`);
1665
+ console.log(` created: ${item.created_at} confidence: ${item.confidence}`);
1666
+ if (item.tags_text) console.log(` tags: ${item.tags_text}`);
1667
+ if (item.body_text) {
1668
+ const preview = item.body_text.length > 300 ? `${item.body_text.slice(0, 300)}…` : item.body_text;
1669
+ console.log(`\n${preview}`);
1670
+ }
1671
+ }
709
1672
  } finally {
710
1673
  store.close();
711
1674
  }
@@ -713,20 +1676,27 @@ function showMemoryAction(idStr, opts) {
713
1676
  function forgetMemoryAction(idStr, opts) {
714
1677
  const memoryId = parseStrictPositiveId(idStr);
715
1678
  if (memoryId === null) {
716
- p.log.error(`Invalid memory ID: ${idStr}`);
717
- process.exitCode = 1;
1679
+ if (opts.json) emitJsonError("invalid_id", `Invalid memory ID: ${idStr}`);
1680
+ else {
1681
+ p.log.error(`Invalid memory ID: ${idStr}`);
1682
+ process.exitCode = 1;
1683
+ }
718
1684
  return;
719
1685
  }
720
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1686
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
721
1687
  try {
722
1688
  store.forget(memoryId);
723
- p.log.success(`Memory ${memoryId} marked inactive`);
1689
+ if (opts.json) console.log(JSON.stringify({
1690
+ id: memoryId,
1691
+ status: "forgotten"
1692
+ }));
1693
+ else p.log.success(`Memory ${memoryId} marked inactive`);
724
1694
  } finally {
725
1695
  store.close();
726
1696
  }
727
1697
  }
728
1698
  async function rememberMemoryAction(opts) {
729
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1699
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
730
1700
  let sessionId = null;
731
1701
  try {
732
1702
  const project = resolveProject(process.cwd(), opts.project ?? null);
@@ -740,7 +1710,8 @@ async function rememberMemoryAction(opts) {
740
1710
  const memId = store.remember(sessionId, opts.kind, opts.title, opts.body, .5, opts.tags);
741
1711
  await store.flushPendingVectorWrites();
742
1712
  store.endSession(sessionId, { manual: true });
743
- p.log.success(`Stored memory ${memId}`);
1713
+ if (opts.json) console.log(JSON.stringify({ id: memId }));
1714
+ else p.log.success(`Stored memory ${memId}`);
744
1715
  } catch (err) {
745
1716
  if (sessionId !== null) try {
746
1717
  store.endSession(sessionId, {
@@ -748,46 +1719,211 @@ async function rememberMemoryAction(opts) {
748
1719
  error: true
749
1720
  });
750
1721
  } catch {}
751
- throw err;
1722
+ const message = err instanceof Error ? err.message : String(err);
1723
+ if (opts.json) emitJsonError("remember_failed", message);
1724
+ else {
1725
+ p.log.error(`Failed to store memory: ${message}`);
1726
+ process.exitCode = 1;
1727
+ }
752
1728
  } finally {
753
1729
  store.close();
754
1730
  }
755
1731
  }
756
1732
  function createShowMemoryCommand() {
757
- return new Command("show").configureHelp(helpStyle).description("Print a memory item as JSON").argument("<id>", "memory ID").option("--db <path>", "database path").option("--db-path <path>", "database path").action(showMemoryAction);
1733
+ const cmd = new Command("show").configureHelp(helpStyle).description("Show a memory item").argument("<id>", "memory ID");
1734
+ addDbOption(cmd);
1735
+ addJsonOption(cmd);
1736
+ cmd.action(showMemoryAction);
1737
+ return cmd;
758
1738
  }
759
1739
  function createForgetMemoryCommand() {
760
- return new Command("forget").configureHelp(helpStyle).description("Deactivate a memory item").argument("<id>", "memory ID").option("--db <path>", "database path").option("--db-path <path>", "database path").action(forgetMemoryAction);
1740
+ const cmd = new Command("forget").configureHelp(helpStyle).description("Deactivate a memory item").argument("<id>", "memory ID");
1741
+ addDbOption(cmd);
1742
+ addJsonOption(cmd);
1743
+ cmd.action(forgetMemoryAction);
1744
+ return cmd;
761
1745
  }
762
1746
  function createRememberMemoryCommand() {
763
- return new Command("remember").configureHelp(helpStyle).description("Manually add a memory item").requiredOption("-k, --kind <kind>", "memory kind (discovery, decision, feature, bugfix, etc.)").requiredOption("-t, --title <title>", "memory title").requiredOption("-b, --body <body>", "memory body text").option("--tags <tags...>", "tags (space-separated)").option("--project <project>", "project name (defaults to git repo root)").option("--db <path>", "database path").option("--db-path <path>", "database path").action(rememberMemoryAction);
1747
+ const cmd = new Command("remember").configureHelp(helpStyle).description("Manually add a memory item").requiredOption("-k, --kind <kind>", "memory kind (discovery, decision, feature, bugfix, etc.)").requiredOption("-t, --title <title>", "memory title").requiredOption("-b, --body <body>", "memory body text").option("--tags <tags...>", "tags (space-separated)").option("--project <project>", "project name (defaults to git repo root)");
1748
+ addDbOption(cmd);
1749
+ addJsonOption(cmd);
1750
+ cmd.action(rememberMemoryAction);
1751
+ return cmd;
764
1752
  }
765
1753
  function createInjectMemoryCommand() {
766
- return new Command("inject").configureHelp(helpStyle).description("Build raw memory context text for manual prompt injection").argument("<context>", "context string to search for").option("--db <path>", "database path").option("--db-path <path>", "database path").option("-n, --limit <n>", "max items", "10").option("--budget <tokens>", "token budget").option("--token-budget <tokens>", "token budget").option("--working-set-file <path>", "recently modified file path used as ranking hint", collectWorkingSetFile$1, []).option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").allowUnknownOption(true).allowExcessArguments(true).action(async (context, opts) => {
767
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1754
+ const cmd = new Command("inject").configureHelp(helpStyle).description("Build raw memory context text for manual prompt injection").argument("<context>", "context string to search for").option("-n, --limit <n>", "max items", "10").option("--budget <tokens>", "token budget").option("--token-budget <tokens>", "token budget").option("--working-set-file <path>", "recently modified file path used as ranking hint", collectWorkingSetFile, []).option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").allowUnknownOption(true).allowExcessArguments(true);
1755
+ addDbOption(cmd);
1756
+ cmd.action(async (context, opts) => {
1757
+ emitDeprecationWarning("codemem memory inject", "codemem pack");
1758
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
768
1759
  try {
769
- const limit = Number.parseInt(opts.limit ?? "10", 10) || 10;
770
- const budgetRaw = opts.tokenBudget ?? opts.budget;
771
- const budget = budgetRaw ? Number.parseInt(budgetRaw, 10) : void 0;
772
- const filters = {};
773
- if (!opts.allProjects) {
774
- const project = process.env.CODEMEM_PROJECT?.trim() || resolveProject(process.cwd(), opts.project ?? null);
775
- if (project) filters.project = project;
776
- }
777
- if ((opts.workingSetFile?.length ?? 0) > 0) filters.working_set_paths = opts.workingSetFile;
1760
+ const { limit, budget, filters } = buildPackRequestOptions(opts, { envProject: process.env.CODEMEM_PROJECT });
778
1761
  const pack = await store.buildMemoryPackAsync(context, limit, budget, filters);
779
1762
  console.log(pack.pack_text ?? "");
780
1763
  } finally {
781
1764
  store.close();
782
1765
  }
783
1766
  });
1767
+ return cmd;
1768
+ }
1769
+ function createMemoryRoleReportCommand() {
1770
+ const cmd = new Command("role-report").configureHelp(helpStyle).description("Analyze inferred memory roles in a DB snapshot").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "analyze across all projects").option("--probe <query>", "run a retrieval probe query against the snapshot", (value, prev) => [...prev, value], []).option("--inactive", "include inactive memories");
1771
+ addDbOption(cmd);
1772
+ addJsonOption(cmd);
1773
+ cmd.action((opts) => {
1774
+ const project = opts.allProjects === true ? null : opts.project?.trim() || process.env.CODEMEM_PROJECT?.trim() || resolveProject(process.cwd(), null);
1775
+ const result = getMemoryRoleReport(resolveDbOpt(opts), {
1776
+ project,
1777
+ allProjects: opts.allProjects === true,
1778
+ includeInactive: opts.inactive === true,
1779
+ probes: opts.probe
1780
+ });
1781
+ if (opts.json) {
1782
+ console.log(JSON.stringify(result, null, 2));
1783
+ return;
1784
+ }
1785
+ p.intro("codemem memory role-report");
1786
+ p.log.info([
1787
+ `Memories: ${result.totals.memories}`,
1788
+ `Active: ${result.totals.active}`,
1789
+ `Sessions: ${result.totals.sessions}`
1790
+ ].join("\n"));
1791
+ p.log.info("Counts by role:");
1792
+ for (const [role, count] of Object.entries(result.counts_by_role)) p.log.message(` ${role.padEnd(10)} ${String(count)}`);
1793
+ p.log.info("Counts by mapping:");
1794
+ p.log.message(` mapped ${result.counts_by_mapping.mapped}`);
1795
+ p.log.message(` unmapped ${result.counts_by_mapping.unmapped}`);
1796
+ p.log.info("Summary lineages:");
1797
+ p.log.message(` session_summary ${result.summary_lineages.session_summary}`);
1798
+ p.log.message(` legacy_metadata_summary ${result.summary_lineages.legacy_metadata_summary}`);
1799
+ p.log.message(` summary_mapped ${result.summary_mapping.mapped}`);
1800
+ p.log.message(` summary_unmapped ${result.summary_mapping.unmapped}`);
1801
+ p.log.info("Project quality:");
1802
+ for (const [bucket, count] of Object.entries(result.project_quality)) p.log.message(` ${bucket.padEnd(12)} ${String(count)}`);
1803
+ if (result.probe_results.length > 0) {
1804
+ p.log.info("Probe results:");
1805
+ for (const probe of result.probe_results) {
1806
+ p.log.message(` query: ${probe.query}`);
1807
+ p.log.message(` mode: ${probe.mode}`);
1808
+ p.log.message(` top roles: durable=${probe.top_role_counts.durable} recap=${probe.top_role_counts.recap} ephemeral=${probe.top_role_counts.ephemeral} general=${probe.top_role_counts.general}`);
1809
+ p.log.message(` top mapping: mapped=${probe.top_mapping_counts.mapped} unmapped=${probe.top_mapping_counts.unmapped}`);
1810
+ p.log.message(` burden: recap_share=${probe.top_burden.recap_share.toFixed(2)} unmapped_share=${probe.top_burden.unmapped_share.toFixed(2)} recap_unmapped_share=${probe.top_burden.recap_unmapped_share.toFixed(2)}`);
1811
+ if (probe.simulated_demoted_unmapped_recap) p.log.message(` simulated demote-unmapped-recap burden: recap_share=${probe.simulated_demoted_unmapped_recap.top_burden.recap_share.toFixed(2)} unmapped_share=${probe.simulated_demoted_unmapped_recap.top_burden.unmapped_share.toFixed(2)} recap_unmapped_share=${probe.simulated_demoted_unmapped_recap.top_burden.recap_unmapped_share.toFixed(2)}`);
1812
+ if (probe.simulated_demoted_unmapped_recap_and_ephemeral) p.log.message(` simulated demote-unmapped-recap+ephemeral burden: recap_share=${probe.simulated_demoted_unmapped_recap_and_ephemeral.top_burden.recap_share.toFixed(2)} unmapped_share=${probe.simulated_demoted_unmapped_recap_and_ephemeral.top_burden.unmapped_share.toFixed(2)} recap_unmapped_share=${probe.simulated_demoted_unmapped_recap_and_ephemeral.top_burden.recap_unmapped_share.toFixed(2)}`);
1813
+ for (const item of probe.items.slice(0, 5)) p.log.message(` [${item.id}] (${item.kind}/${item.role}/${item.mapping}) ${item.title} — ${item.role_reason}`);
1814
+ }
1815
+ }
1816
+ p.outro("done");
1817
+ });
1818
+ return cmd;
1819
+ }
1820
+ function createMemoryRoleCompareCommand() {
1821
+ const cmd = new Command("role-compare").configureHelp(helpStyle).description("Compare inferred memory-role and probe metrics across two DB snapshots").argument("<baseline_db>", "baseline sqlite database path").argument("<candidate_db>", "candidate sqlite database path").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "analyze across all projects").option("--probe <query>", "run a retrieval probe query against both snapshots", (value, prev) => [...prev, value], []).option("--inactive", "include inactive memories");
1822
+ addJsonOption(cmd);
1823
+ cmd.action((baselineDb, candidateDb, opts) => {
1824
+ const result = compareMemoryRoleReports(baselineDb, candidateDb, {
1825
+ project: opts.allProjects === true ? null : opts.project?.trim() || process.env.CODEMEM_PROJECT?.trim() || resolveProject(process.cwd(), null),
1826
+ allProjects: opts.allProjects === true,
1827
+ includeInactive: opts.inactive === true,
1828
+ probes: opts.probe
1829
+ });
1830
+ if (opts.json) {
1831
+ console.log(JSON.stringify(result, null, 2));
1832
+ return;
1833
+ }
1834
+ p.intro("codemem memory role-compare");
1835
+ p.log.info([
1836
+ `Baseline sessions: ${result.baseline.totals.sessions}`,
1837
+ `Candidate sessions: ${result.candidate.totals.sessions}`,
1838
+ `Delta sessions: ${result.delta.totals.sessions}`,
1839
+ `Mapped delta: ${result.delta.counts_by_mapping.mapped}`,
1840
+ `Unmapped delta: ${result.delta.counts_by_mapping.unmapped}`,
1841
+ `Summary mapped delta: ${result.delta.summary_mapping.mapped}`,
1842
+ `Summary unmapped delta: ${result.delta.summary_mapping.unmapped}`
1843
+ ].join("\n"));
1844
+ p.log.info("Role deltas:");
1845
+ for (const [role, count] of Object.entries(result.delta.counts_by_role)) p.log.message(` ${role.padEnd(10)} ${String(count)}`);
1846
+ if (result.probe_comparisons.length > 0) {
1847
+ p.log.info("Probe comparisons:");
1848
+ for (const probe of result.probe_comparisons) {
1849
+ p.log.message(` query: ${probe.query}`);
1850
+ p.log.message(` modes: baseline=${probe.baseline_mode ?? "-"} candidate=${probe.candidate_mode ?? "-"}`);
1851
+ p.log.message(` overlap: shared_top_keys=${probe.shared_item_keys.length} baseline_top=${probe.baseline_item_ids.slice(0, 5).join(",") || "-"} candidate_top=${probe.candidate_item_ids.slice(0, 5).join(",") || "-"}`);
1852
+ if (probe.delta_top_burden) p.log.message(` burden delta: recap_share=${probe.delta_top_burden.recap_share.toFixed(2)} unmapped_share=${probe.delta_top_burden.unmapped_share.toFixed(2)} recap_unmapped_share=${probe.delta_top_burden.recap_unmapped_share.toFixed(2)}`);
1853
+ if (probe.delta_top_mapping_counts) p.log.message(` mapping delta: mapped=${probe.delta_top_mapping_counts.mapped} unmapped=${probe.delta_top_mapping_counts.unmapped}`);
1854
+ }
1855
+ }
1856
+ p.outro("done");
1857
+ });
1858
+ return cmd;
1859
+ }
1860
+ function createMemoryRelinkReportCommand() {
1861
+ const cmd = new Command("relink-report").configureHelp(helpStyle).description("Analyze dry-run raw-event session relinking and compaction opportunities").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "analyze across all projects").option("--limit <n>", "max groups to print", "25");
1862
+ addDbOption(cmd);
1863
+ addJsonOption(cmd);
1864
+ cmd.action((opts) => {
1865
+ const project = opts.allProjects === true ? null : opts.project?.trim() || process.env.CODEMEM_PROJECT?.trim() || resolveProject(process.cwd(), null);
1866
+ const limit = Number.parseInt(opts.limit ?? "25", 10) || 25;
1867
+ const result = getRawEventRelinkReport(resolveDbOpt(opts), {
1868
+ project,
1869
+ allProjects: opts.allProjects === true,
1870
+ limit
1871
+ });
1872
+ if (opts.json) {
1873
+ console.log(JSON.stringify(result, null, 2));
1874
+ return;
1875
+ }
1876
+ p.intro("codemem memory relink-report");
1877
+ p.log.info([
1878
+ `Recoverable sessions: ${result.totals.recoverable_sessions}`,
1879
+ `Distinct stable ids: ${result.totals.distinct_stable_ids}`,
1880
+ `Groups with multiple sessions: ${result.totals.groups_with_multiple_sessions}`,
1881
+ `Groups with mapped session: ${result.totals.groups_with_mapped_session}`,
1882
+ `Groups without mapped session: ${result.totals.groups_without_mapped_session}`,
1883
+ `Active memories in groups: ${result.totals.active_memories}`,
1884
+ `Repointable active memories: ${result.totals.repointable_active_memories}`
1885
+ ].join("\n"));
1886
+ p.log.info("Top relink groups:");
1887
+ for (const group of result.groups) p.log.message(` ${group.stable_id} -> canonical ${group.canonical_session_id} | local=${group.local_sessions} mapped=${group.mapped_sessions} unmapped=${group.unmapped_sessions} active=${group.active_memories} repointable=${group.repointable_active_memories}`);
1888
+ p.outro("done");
1889
+ });
1890
+ return cmd;
784
1891
  }
785
- function createCompactMemoryCommand() {
786
- return new Command("compact").configureHelp(helpStyle).description("Deferred command guidance for memory compaction").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--session-id <id>", "session ID").option("--limit <n>", "max sessions to compact", "10").allowUnknownOption(true).allowExcessArguments(true).action(() => {
787
- p.log.warn("`codemem memory compact` is not implemented in the TypeScript CLI yet.");
788
- p.log.info("Current workaround: rely on automatic ingestion; for manual context use `codemem memory inject <context>`.");
789
- process.exitCode = 2;
1892
+ function createMemoryRelinkPlanCommand() {
1893
+ const cmd = new Command("relink-plan").configureHelp(helpStyle).description("Emit dry-run raw-event relink remediation actions").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "analyze across all projects").option("--limit <n>", "max groups to include", "25");
1894
+ addDbOption(cmd);
1895
+ addJsonOption(cmd);
1896
+ cmd.action((opts) => {
1897
+ const project = opts.allProjects === true ? null : opts.project?.trim() || process.env.CODEMEM_PROJECT?.trim() || resolveProject(process.cwd(), null);
1898
+ const limit = Number.parseInt(opts.limit ?? "25", 10) || 25;
1899
+ const result = getRawEventRelinkPlan(resolveDbOpt(opts), {
1900
+ project,
1901
+ allProjects: opts.allProjects === true,
1902
+ limit
1903
+ });
1904
+ if (opts.json) {
1905
+ console.log(JSON.stringify(result, null, 2));
1906
+ return;
1907
+ }
1908
+ p.intro("codemem memory relink-plan");
1909
+ p.log.info([
1910
+ `Groups: ${result.totals.groups}`,
1911
+ `Eligible groups: ${result.totals.eligible_groups}`,
1912
+ `Skipped groups: ${result.totals.skipped_groups}`,
1913
+ `Actions: ${result.totals.actions}`,
1914
+ `Bridge creations: ${result.totals.bridge_creations}`,
1915
+ `Memory repoints: ${result.totals.memory_repoints}`,
1916
+ `Session compactions: ${result.totals.session_compactions}`
1917
+ ].join("\n"));
1918
+ p.log.info("Planned actions:");
1919
+ for (const action of result.actions.slice(0, 15)) p.log.message(` ${action.action} ${action.stable_id} -> canonical ${action.canonical_session_id} | sessions=${action.session_ids.join(",") || "-"} memories=${action.memory_count} reason=${action.reason}`);
1920
+ if (result.skipped_groups.length > 0) {
1921
+ p.log.info("Skipped groups:");
1922
+ for (const group of result.skipped_groups.slice(0, 10)) p.log.message(` ${group.stable_id} | blockers=${group.blockers.join(",")}`);
1923
+ }
1924
+ p.outro("done");
790
1925
  });
1926
+ return cmd;
791
1927
  }
792
1928
  var showMemoryCommand = createShowMemoryCommand();
793
1929
  var forgetMemoryCommand = createForgetMemoryCommand();
@@ -797,24 +1933,19 @@ memoryCommand.addCommand(createShowMemoryCommand());
797
1933
  memoryCommand.addCommand(createForgetMemoryCommand());
798
1934
  memoryCommand.addCommand(createRememberMemoryCommand());
799
1935
  memoryCommand.addCommand(createInjectMemoryCommand());
800
- memoryCommand.addCommand(createCompactMemoryCommand());
1936
+ memoryCommand.addCommand(createMemoryRoleReportCommand());
1937
+ memoryCommand.addCommand(createMemoryRoleCompareCommand());
1938
+ memoryCommand.addCommand(createMemoryRelinkReportCommand());
1939
+ memoryCommand.addCommand(createMemoryRelinkPlanCommand());
801
1940
  //#endregion
802
1941
  //#region src/commands/pack.ts
803
- function collectWorkingSetFile(value, previous) {
804
- return [...previous, value];
805
- }
806
- var packCommand = new Command("pack").configureHelp(helpStyle).description("Build a context-aware memory pack").argument("<context>", "context string to search for").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max items", "10").option("--budget <tokens>", "token budget").option("--token-budget <tokens>", "token budget").option("--working-set-file <path>", "recently modified file path used as ranking hint", collectWorkingSetFile, []).option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").option("--json", "output as JSON").action(async (context, opts) => {
807
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1942
+ var packCmd = new Command("pack").configureHelp(helpStyle).description("Build a context-aware memory pack").argument("<context>", "context string to search for").option("-n, --limit <n>", "max items", "10").option("--budget <tokens>", "token budget").option("--token-budget <tokens>", "token budget").option("--working-set-file <path>", "recently modified file path used as ranking hint", collectWorkingSetFile, []).option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects");
1943
+ addDbOption(packCmd);
1944
+ addJsonOption(packCmd);
1945
+ var packCommand = packCmd.action(async (context, opts) => {
1946
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
808
1947
  try {
809
- const limit = Number.parseInt(opts.limit, 10) || 10;
810
- const budgetRaw = opts.tokenBudget ?? opts.budget;
811
- const budget = budgetRaw ? Number.parseInt(budgetRaw, 10) : void 0;
812
- const filters = {};
813
- if (!opts.allProjects) {
814
- const project = process.env.CODEMEM_PROJECT?.trim() || resolveProject(process.cwd(), opts.project ?? null);
815
- if (project) filters.project = project;
816
- }
817
- if ((opts.workingSetFile?.length ?? 0) > 0) filters.working_set_paths = opts.workingSetFile;
1948
+ const { limit, budget, filters } = buildPackRequestOptions(opts, { envProject: process.env.CODEMEM_PROJECT });
818
1949
  const result = await store.buildMemoryPackAsync(context, limit, budget, filters);
819
1950
  if (opts.json) {
820
1951
  console.log(JSON.stringify(result, null, 2));
@@ -837,8 +1968,11 @@ var packCommand = new Command("pack").configureHelp(helpStyle).description("Buil
837
1968
  });
838
1969
  //#endregion
839
1970
  //#region src/commands/recent.ts
840
- var recentCommand = new Command("recent").configureHelp(helpStyle).description("Show recent memories").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--limit <n>", "max results", "5").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").option("--kind <kind>", "filter by memory kind").action((opts) => {
841
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1971
+ var cmd = new Command("recent").configureHelp(helpStyle).description("Show recent memories").option("--limit <n>", "max results", "5").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").option("--kind <kind>", "filter by memory kind");
1972
+ addDbOption(cmd);
1973
+ addJsonOption(cmd);
1974
+ cmd.action((opts) => {
1975
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
842
1976
  try {
843
1977
  const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 5);
844
1978
  const filters = {};
@@ -848,15 +1982,20 @@ var recentCommand = new Command("recent").configureHelp(helpStyle).description("
848
1982
  if (project) filters.project = project;
849
1983
  }
850
1984
  const items = store.recent(limit, filters);
851
- for (const item of items) console.log(`#${item.id} [${item.kind}] ${item.title}`);
1985
+ if (opts.json) console.log(JSON.stringify(items));
1986
+ else for (const item of items) console.log(`#${item.id} [${item.kind}] ${item.title}`);
852
1987
  } finally {
853
1988
  store.close();
854
1989
  }
855
1990
  });
1991
+ var recentCommand = cmd;
856
1992
  //#endregion
857
1993
  //#region src/commands/search.ts
858
- var searchCommand = new Command("search").configureHelp(helpStyle).description("Search memories by query").argument("<query>", "search query").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("-n, --limit <n>", "max results", "5").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").option("--kind <kind>", "filter by memory kind").option("--json", "output as JSON").action((query, opts) => {
859
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
1994
+ var searchCmd = new Command("search").configureHelp(helpStyle).description("Search memories by query").argument("<query>", "search query").option("-n, --limit <n>", "max results", "5").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").option("--kind <kind>", "filter by memory kind");
1995
+ addDbOption(searchCmd);
1996
+ addJsonOption(searchCmd);
1997
+ var searchCommand = searchCmd.action((query, opts) => {
1998
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
860
1999
  try {
861
2000
  const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 5);
862
2001
  const filters = {};
@@ -900,6 +2039,10 @@ function timeSince(isoDate) {
900
2039
  }
901
2040
  //#endregion
902
2041
  //#region src/commands/serve-invocation.ts
2042
+ /**
2043
+ * Parse and validate a port string. Throws a user-friendly message (no stack trace)
2044
+ * that callers in serve.ts catch at the action boundary.
2045
+ */
903
2046
  function parsePort(rawPort) {
904
2047
  const port = Number.parseInt(rawPort, 10);
905
2048
  if (!Number.isFinite(port) || port < 1 || port > 65535) throw new Error(`Invalid port: ${rawPort}`);
@@ -908,10 +2051,11 @@ function parsePort(rawPort) {
908
2051
  function resolveLegacyServeInvocation(opts) {
909
2052
  if (opts.stop && opts.restart) throw new Error("Use only one of --stop or --restart");
910
2053
  if (opts.foreground && opts.background) throw new Error("Use only one of --background or --foreground");
911
- const dbPath = opts.db ?? opts.dbPath ?? null;
2054
+ const dbPath = resolveDbOpt(opts) ?? null;
912
2055
  return {
913
2056
  mode: opts.stop ? "stop" : opts.restart ? "restart" : "start",
914
2057
  dbPath,
2058
+ configPath: opts.config ?? null,
915
2059
  host: opts.host,
916
2060
  port: parsePort(opts.port),
917
2061
  background: opts.restart ? true : opts.background ? true : false
@@ -926,7 +2070,8 @@ function resolveServeInvocation(action, opts) {
926
2070
  function resolveStartServeInvocation(opts) {
927
2071
  return {
928
2072
  mode: "start",
929
- dbPath: opts.db ?? opts.dbPath ?? null,
2073
+ dbPath: resolveDbOpt(opts) ?? null,
2074
+ configPath: opts.config ?? null,
930
2075
  host: opts.host,
931
2076
  port: parsePort(opts.port),
932
2077
  background: !opts.foreground
@@ -935,7 +2080,8 @@ function resolveStartServeInvocation(opts) {
935
2080
  function resolveStopRestartInvocation(mode, opts) {
936
2081
  return {
937
2082
  mode,
938
- dbPath: opts.db ?? opts.dbPath ?? null,
2083
+ dbPath: resolveDbOpt(opts) ?? null,
2084
+ configPath: opts.config ?? null,
939
2085
  host: opts.host,
940
2086
  port: parsePort(opts.port),
941
2087
  background: mode === "restart"
@@ -1182,14 +2328,19 @@ async function startBackgroundViewer(invocation) {
1182
2328
  return;
1183
2329
  }
1184
2330
  const scriptPath = process.argv[1];
1185
- if (!scriptPath) throw new Error("Unable to resolve CLI entrypoint for background launch");
2331
+ if (!scriptPath) {
2332
+ p.log.error("Unable to resolve CLI entrypoint for background launch");
2333
+ process.exitCode = 1;
2334
+ return;
2335
+ }
1186
2336
  const child = spawn(process.execPath, buildForegroundRunnerArgs(scriptPath, invocation), {
1187
2337
  cwd: process.cwd(),
1188
2338
  detached: true,
1189
2339
  stdio: "ignore",
1190
2340
  env: {
1191
2341
  ...process.env,
1192
- ...invocation.dbPath ? { CODEMEM_DB: invocation.dbPath } : {}
2342
+ ...invocation.dbPath ? { CODEMEM_DB: invocation.dbPath } : {},
2343
+ ...invocation.configPath ? { CODEMEM_CONFIG: invocation.configPath } : {}
1193
2344
  }
1194
2345
  });
1195
2346
  child.unref();
@@ -1205,6 +2356,7 @@ async function startForegroundViewer(invocation) {
1205
2356
  const { createApp, createSyncApp, closeStore, getStore } = await import("@codemem/server");
1206
2357
  const { serve } = await import("@hono/node-server");
1207
2358
  if (invocation.dbPath) process.env.CODEMEM_DB = invocation.dbPath;
2359
+ if (invocation.configPath) process.env.CODEMEM_CONFIG = invocation.configPath;
1208
2360
  warnIfViewerExposed(invocation.host, invocation.port);
1209
2361
  if (await isPortOpen(invocation.host, invocation.port)) {
1210
2362
  p.log.warn(`Viewer already running at http://${invocation.host}:${invocation.port}`);
@@ -1229,7 +2381,7 @@ async function startForegroundViewer(invocation) {
1229
2381
  sweeper.start();
1230
2382
  const syncAbort = new AbortController();
1231
2383
  const retentionAbort = new AbortController();
1232
- const syncConfig = readCoordinatorSyncConfig(readCodememConfigFile());
2384
+ const syncConfig = readCoordinatorSyncConfig(invocation.configPath ? readCodememConfigFileAtPath(invocation.configPath) : readCodememConfigFile());
1233
2385
  const syncEnabled = syncConfig.syncEnabled;
1234
2386
  const retentionRunner = new SyncRetentionRunner({
1235
2387
  dbPath: resolveDbPath(invocation.dbPath ?? void 0),
@@ -1408,17 +2560,31 @@ async function runServeInvocation(invocation) {
1408
2560
  });
1409
2561
  }
1410
2562
  }
1411
- function addSharedServeOptions(command) {
1412
- return command.option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--host <host>", "bind host", "127.0.0.1").option("--port <port>", "bind port", "38888");
1413
- }
1414
- var serveCommand = addSharedServeOptions(new Command("serve").configureHelp(helpStyle).description("Run or manage the viewer").argument("[action]", "lifecycle action (start|stop|restart)")).option("--background", "run viewer in background").option("--foreground", "run viewer in foreground").option("--stop", "stop background viewer").option("--restart", "restart background viewer").action(async (action, opts) => {
1415
- const normalizedAction = action === void 0 ? void 0 : action === "start" || action === "stop" || action === "restart" ? action : null;
1416
- if (normalizedAction === null) {
1417
- p.log.error(`Unknown serve action: ${action}`);
2563
+ var serveCmd = new Command("serve").configureHelp(helpStyle).description("Run or manage the viewer").argument("[action]", "lifecycle action (start|stop|restart)");
2564
+ addDbOption(serveCmd);
2565
+ addConfigOption(serveCmd);
2566
+ addViewerHostOptions(serveCmd);
2567
+ serveCmd.addOption(new Option("--background", "run viewer in background").hideHelp());
2568
+ serveCmd.addOption(new Option("--foreground", "run viewer in foreground").hideHelp());
2569
+ serveCmd.addOption(new Option("--stop", "stop background viewer").hideHelp());
2570
+ serveCmd.addOption(new Option("--restart", "restart background viewer").hideHelp());
2571
+ var serveCommand = serveCmd.action(async (action, opts) => {
2572
+ try {
2573
+ if (opts.stop) emitDeprecationWarning("--stop", "codemem serve stop");
2574
+ if (opts.restart) emitDeprecationWarning("--restart", "codemem serve restart");
2575
+ if (opts.background) emitDeprecationWarning("--background", "codemem serve start");
2576
+ if (opts.foreground) emitDeprecationWarning("--foreground", "codemem serve start --foreground");
2577
+ const normalizedAction = action === void 0 ? void 0 : action === "start" || action === "stop" || action === "restart" ? action : null;
2578
+ if (normalizedAction === null) {
2579
+ p.log.error(`Unknown serve action: ${action}`);
2580
+ process.exitCode = 1;
2581
+ return;
2582
+ }
2583
+ await runServeInvocation(resolveServeInvocation(normalizedAction, opts));
2584
+ } catch (err) {
2585
+ p.log.error(err instanceof Error ? err.message : String(err));
1418
2586
  process.exitCode = 1;
1419
- return;
1420
2587
  }
1421
- await runServeInvocation(resolveServeInvocation(normalizedAction, opts));
1422
2588
  });
1423
2589
  //#endregion
1424
2590
  //#region src/commands/setup-config.ts
@@ -1673,8 +2839,11 @@ function fmtTokens(n) {
1673
2839
  if (n >= 1e3) return `~${(n / 1e3).toFixed(0)}K`;
1674
2840
  return `${n}`;
1675
2841
  }
1676
- var statsCommand = new Command("stats").configureHelp(helpStyle).description("Show database statistics").option("--db <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--db-path <path>", "database path (default: $CODEMEM_DB or ~/.codemem/mem.sqlite)").option("--json", "output as JSON").action((opts) => {
1677
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
2842
+ var statsCmd = new Command("stats").configureHelp(helpStyle).description("Show database statistics");
2843
+ addDbOption(statsCmd);
2844
+ addJsonOption(statsCmd);
2845
+ var statsCommand = statsCmd.action((opts) => {
2846
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
1678
2847
  try {
1679
2848
  const result = store.stats();
1680
2849
  if (opts.json) {
@@ -1728,7 +2897,9 @@ function buildServeLifecycleArgs(action, opts, scriptPath, execArgv = []) {
1728
2897
  if (action === "start") args.push("--restart");
1729
2898
  else if (action === "stop") args.push("--stop");
1730
2899
  else args.push("--restart");
1731
- if (opts.db ?? opts.dbPath) args.push("--db-path", opts.db ?? opts.dbPath ?? "");
2900
+ const dbResolved = resolveDbOpt(opts);
2901
+ if (dbResolved) args.push("--db-path", dbResolved);
2902
+ if (opts.config) args.push("--config", opts.config);
1732
2903
  if (opts.host) args.push("--host", opts.host);
1733
2904
  if (opts.port) args.push("--port", opts.port);
1734
2905
  return args;
@@ -1752,10 +2923,22 @@ function collectAdvertiseAddresses(explicitAddress, configuredHost, port, interf
1752
2923
  /**
1753
2924
  * Sync CLI commands — enable/disable/status/peers/connect.
1754
2925
  */
2926
+ function readCliConfig(configPath) {
2927
+ return configPath ? readCodememConfigFileAtPath(configPath) : readCodememConfigFile();
2928
+ }
2929
+ function writeCliConfig(config, configPath) {
2930
+ return writeCodememConfigFile(config, configPath || void 0);
2931
+ }
1755
2932
  function parseAttemptsLimit(value) {
1756
2933
  if (!/^\d+$/.test(value.trim())) throw new Error(`Invalid --limit: ${value}`);
1757
2934
  return Number.parseInt(value, 10);
1758
2935
  }
2936
+ function parsePositiveIntegerOption(value, flagName) {
2937
+ if (value == null) return void 0;
2938
+ const trimmed = value.trim();
2939
+ if (!/^\d+$/.test(trimmed)) throw new Error(`Invalid ${flagName}: ${value}`);
2940
+ return Number.parseInt(trimmed, 10);
2941
+ }
1759
2942
  function resolvePeerMatch(db, peerRef) {
1760
2943
  const trimmed = peerRef.trim();
1761
2944
  if (!trimmed) return null;
@@ -1771,18 +2954,6 @@ function resolvePeerMatch(db, peerRef) {
1771
2954
  if (byName.length > 1) return "ambiguous";
1772
2955
  return byName[0] ?? null;
1773
2956
  }
1774
- function readCoordinatorPublicKey(opts) {
1775
- const inline = String(opts.publicKey ?? "").trim();
1776
- const filePath = String(opts.publicKeyFile ?? "").trim();
1777
- if (inline && filePath) throw new Error("Use only one of --public-key or --public-key-file");
1778
- if (filePath) {
1779
- const text = readFileSync(filePath, "utf8").trim();
1780
- if (!text) throw new Error(`Public key file is empty: ${filePath}`);
1781
- return text;
1782
- }
1783
- if (!inline) throw new Error("Public key required via --public-key or --public-key-file");
1784
- return inline;
1785
- }
1786
2957
  async function portOpen(host, port) {
1787
2958
  return new Promise((resolve) => {
1788
2959
  const socket = net.createConnection({
@@ -1828,12 +2999,13 @@ function parseStoredAddressEndpoint(value) {
1828
2999
  async function runServeLifecycle(action, opts) {
1829
3000
  if (opts.user === false || opts.system === true) p.log.warn("TS sync lifecycle currently manages the local viewer process, not separate user/system services.");
1830
3001
  if (action === "start") {
1831
- if (readCodememConfigFile().sync_enabled !== true) {
3002
+ if (readCoordinatorSyncConfig(readCliConfig(opts.config)).syncEnabled !== true) {
1832
3003
  p.log.error("Sync is disabled. Run `codemem sync enable` first.");
1833
3004
  process.exitCode = 1;
1834
3005
  return;
1835
3006
  }
1836
3007
  }
3008
+ const dbResolved = resolveDbOpt(opts);
1837
3009
  const args = buildServeLifecycleArgs(action, opts, process.argv[1] ?? "", process.execArgv);
1838
3010
  await new Promise((resolve, reject) => {
1839
3011
  const child = spawn(process.execPath, args, {
@@ -1841,7 +3013,8 @@ async function runServeLifecycle(action, opts) {
1841
3013
  stdio: "inherit",
1842
3014
  env: {
1843
3015
  ...process.env,
1844
- ...opts.db ?? opts.dbPath ? { CODEMEM_DB: opts.db ?? opts.dbPath } : {}
3016
+ ...dbResolved ? { CODEMEM_DB: dbResolved } : {},
3017
+ ...opts.config ? { CODEMEM_CONFIG: opts.config } : {}
1845
3018
  }
1846
3019
  });
1847
3020
  child.once("error", reject);
@@ -1852,8 +3025,11 @@ async function runServeLifecycle(action, opts) {
1852
3025
  });
1853
3026
  }
1854
3027
  var syncCommand = new Command("sync").configureHelp(helpStyle).description("Sync configuration and peer management");
1855
- syncCommand.addCommand(new Command("attempts").configureHelp(helpStyle).description("Show recent sync attempts").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--limit <n>", "max attempts", "10").option("--json", "output as JSON").action((opts) => {
1856
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
3028
+ var attemptsCmd = new Command("attempts").configureHelp(helpStyle).description("Show recent sync attempts").option("--limit <n>", "max attempts", "10");
3029
+ addDbOption(attemptsCmd);
3030
+ addJsonOption(attemptsCmd);
3031
+ attemptsCmd.action((opts) => {
3032
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
1857
3033
  try {
1858
3034
  const d = drizzle(store.db, { schema });
1859
3035
  const limit = parseAttemptsLimit(opts.limit);
@@ -1873,19 +3049,30 @@ syncCommand.addCommand(new Command("attempts").configureHelp(helpStyle).descript
1873
3049
  } finally {
1874
3050
  store.close();
1875
3051
  }
1876
- }));
1877
- syncCommand.addCommand(new Command("start").configureHelp(helpStyle).description("Start sync daemon").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--host <host>", "viewer host").option("--port <port>", "viewer port").option("--user", "accepted for compatibility", true).option("--system", "accepted for compatibility").action(async (opts) => {
1878
- await runServeLifecycle("start", opts);
1879
- }));
1880
- syncCommand.addCommand(new Command("stop").configureHelp(helpStyle).description("Stop sync daemon").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--host <host>", "viewer host").option("--port <port>", "viewer port").option("--user", "accepted for compatibility", true).option("--system", "accepted for compatibility").action(async (opts) => {
1881
- await runServeLifecycle("stop", opts);
1882
- }));
1883
- syncCommand.addCommand(new Command("restart").configureHelp(helpStyle).description("Restart sync daemon").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--host <host>", "viewer host").option("--port <port>", "viewer port").option("--user", "accepted for compatibility", true).option("--system", "accepted for compatibility").action(async (opts) => {
1884
- await runServeLifecycle("restart", opts);
1885
- }));
1886
- syncCommand.addCommand(new Command("once").configureHelp(helpStyle).description("Run a single sync pass").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--peer <peer>", "peer device id or name").action(async (opts) => {
1887
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
3052
+ });
3053
+ syncCommand.addCommand(attemptsCmd);
3054
+ function addDeprecatedLifecycleCommand(action) {
3055
+ const cmd = new Command(action).configureHelp(helpStyle).description(`[deprecated] ${action} sync daemon — use 'codemem serve ${action}'`);
3056
+ addDbOption(cmd);
3057
+ addConfigOption(cmd);
3058
+ addViewerHostOptions(cmd);
3059
+ addLegacyServiceFlags(cmd);
3060
+ cmd.action(async (opts) => {
3061
+ emitDeprecationWarning(`codemem sync ${action}`, `codemem serve ${action}`);
3062
+ await runServeLifecycle(action, opts);
3063
+ });
3064
+ syncCommand.addCommand(cmd, { hidden: true });
3065
+ }
3066
+ addDeprecatedLifecycleCommand("start");
3067
+ addDeprecatedLifecycleCommand("stop");
3068
+ addDeprecatedLifecycleCommand("restart");
3069
+ var onceCmd = new Command("once").configureHelp(helpStyle).description("Run a single sync pass").option("--peer <peer>", "peer device id or name");
3070
+ addDbOption(onceCmd);
3071
+ addJsonOption(onceCmd);
3072
+ onceCmd.action(async (opts) => {
3073
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
1888
3074
  try {
3075
+ const keysDir = process.env.CODEMEM_KEYS_DIR?.trim() || void 0;
1889
3076
  syncPassPreflight(store.db);
1890
3077
  const d = drizzle(store.db, { schema });
1891
3078
  const rows = opts.peer ? (() => {
@@ -1893,6 +3080,10 @@ syncCommand.addCommand(new Command("once").configureHelp(helpStyle).description(
1893
3080
  if (deviceMatches.length > 0) return deviceMatches;
1894
3081
  const nameMatches = d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).where(eq(schema.syncPeers.name, opts.peer)).all();
1895
3082
  if (nameMatches.length > 1) {
3083
+ if (opts.json) {
3084
+ emitJsonError("ambiguous_peer", `Peer name is ambiguous: ${opts.peer}`);
3085
+ return [];
3086
+ }
1896
3087
  p.log.error(`Peer name is ambiguous: ${opts.peer}`);
1897
3088
  process.exitCode = 1;
1898
3089
  return [];
@@ -1900,31 +3091,59 @@ syncCommand.addCommand(new Command("once").configureHelp(helpStyle).description(
1900
3091
  return nameMatches;
1901
3092
  })() : d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).all();
1902
3093
  if (rows.length === 0) {
3094
+ if (process.exitCode) return;
3095
+ if (opts.json) {
3096
+ emitJsonError("no_peers", "No peers available for sync");
3097
+ return;
3098
+ }
1903
3099
  p.log.warn("No peers available for sync");
1904
3100
  process.exitCode = 1;
1905
3101
  return;
1906
3102
  }
1907
3103
  let hadFailure = false;
3104
+ const results = [];
1908
3105
  for (const row of rows) {
1909
- const result = await runSyncPass(store.db, row.peer_device_id);
3106
+ const result = await runSyncPass(store.db, row.peer_device_id, { keysDir });
1910
3107
  if (!result.ok) hadFailure = true;
1911
- console.log(formatSyncOnceResult(row.peer_device_id, result));
3108
+ results.push({
3109
+ peer_device_id: row.peer_device_id,
3110
+ ok: result.ok,
3111
+ ...result.error ? { error: result.error } : {}
3112
+ });
3113
+ if (!opts.json) console.log(formatSyncOnceResult(row.peer_device_id, result));
1912
3114
  }
3115
+ if (opts.json) console.log(JSON.stringify({
3116
+ ok: !hadFailure,
3117
+ results
3118
+ }, null, 2));
1913
3119
  if (hadFailure) process.exitCode = 1;
1914
3120
  } finally {
1915
3121
  store.close();
1916
3122
  }
1917
- }));
1918
- syncCommand.addCommand(new Command("pair").configureHelp(helpStyle).description("Print pairing payload or accept a peer payload").option("--accept <json>", "accept pairing payload JSON from another device").option("--accept-file <path>", "accept pairing payload from file path, or '-' for stdin").option("--payload-only", "when generating pairing payload, print JSON only").option("--name <name>", "label for the peer").option("--address <host:port>", "override peer address (host:port)").option("--include <projects>", "outbound-only allowlist for accepted peer").option("--exclude <projects>", "outbound-only blocklist for accepted peer").option("--all", "with --accept, push all projects to that peer").option("--default", "with --accept, use default/global push filters").option("--db-path <path>", "database path").action(async (opts) => {
1919
- const store = new MemoryStore(resolveDbPath(opts.dbPath));
3123
+ });
3124
+ syncCommand.addCommand(onceCmd);
3125
+ var pairCmd = new Command("pair").configureHelp(helpStyle).description("Print pairing payload or accept a peer payload").option("--accept <json>", "accept pairing payload JSON from another device").option("--accept-file <path>", "accept pairing payload from file path, or '-' for stdin").option("--payload-only", "when generating pairing payload, print JSON only").option("--name <name>", "label for the peer").option("--address <host:port>", "override peer address (host:port)").option("--include <projects>", "outbound-only allowlist for accepted peer").option("--exclude <projects>", "outbound-only blocklist for accepted peer").option("--all", "with --accept, push all projects to that peer").option("--default", "with --accept, use default/global push filters");
3126
+ addDbOption(pairCmd);
3127
+ addConfigOption(pairCmd);
3128
+ addJsonOption(pairCmd);
3129
+ pairCmd.action(async (opts) => {
3130
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
1920
3131
  try {
1921
3132
  const acceptModeRequested = opts.accept != null || opts.acceptFile != null;
1922
3133
  if (opts.payloadOnly && acceptModeRequested) {
3134
+ if (opts.json) {
3135
+ emitJsonError("usage_error", "--payload-only cannot be combined with --accept or --accept-file", 2);
3136
+ return;
3137
+ }
1923
3138
  p.log.error("--payload-only cannot be combined with --accept or --accept-file");
1924
3139
  process.exitCode = 1;
1925
3140
  return;
1926
3141
  }
1927
3142
  if (opts.accept && opts.acceptFile) {
3143
+ if (opts.json) {
3144
+ emitJsonError("usage_error", "Use only one of --accept or --accept-file", 2);
3145
+ return;
3146
+ }
1928
3147
  p.log.error("Use only one of --accept or --accept-file");
1929
3148
  process.exitCode = 1;
1930
3149
  return;
@@ -1941,27 +3160,48 @@ syncCommand.addCommand(new Command("pair").configureHelp(helpStyle).description(
1941
3160
  process.stdin.on("error", reject);
1942
3161
  }) : readFileSync(opts.acceptFile, "utf8");
1943
3162
  } catch (error) {
1944
- p.log.error(error instanceof Error ? `Failed to read pairing payload from ${opts.acceptFile}: ${error.message}` : `Failed to read pairing payload from ${opts.acceptFile}`);
3163
+ const msg = error instanceof Error ? `Failed to read pairing payload from ${opts.acceptFile}: ${error.message}` : `Failed to read pairing payload from ${opts.acceptFile}`;
3164
+ if (opts.json) {
3165
+ emitJsonError("read_error", msg);
3166
+ return;
3167
+ }
3168
+ p.log.error(msg);
1945
3169
  process.exitCode = 1;
1946
3170
  return;
1947
3171
  }
1948
3172
  if (acceptModeRequested && !(acceptText ?? "").trim()) {
3173
+ if (opts.json) {
3174
+ emitJsonError("usage_error", "Empty pairing payload; provide JSON via --accept or --accept-file", 2);
3175
+ return;
3176
+ }
1949
3177
  p.log.error("Empty pairing payload; provide JSON via --accept or --accept-file");
1950
3178
  process.exitCode = 1;
1951
3179
  return;
1952
3180
  }
1953
3181
  if (!acceptText && (opts.include || opts.exclude || opts.all || opts.default)) {
3182
+ if (opts.json) {
3183
+ emitJsonError("usage_error", "Project filters are outbound-only and must be set on the device running --accept", 2);
3184
+ return;
3185
+ }
1954
3186
  p.log.error("Project filters are outbound-only and must be set on the device running --accept");
1955
3187
  process.exitCode = 1;
1956
3188
  return;
1957
3189
  }
1958
3190
  if (acceptText?.trim()) {
1959
3191
  if (opts.all && opts.default) {
3192
+ if (opts.json) {
3193
+ emitJsonError("usage_error", "Use only one of --all or --default", 2);
3194
+ return;
3195
+ }
1960
3196
  p.log.error("Use only one of --all or --default");
1961
3197
  process.exitCode = 1;
1962
3198
  return;
1963
3199
  }
1964
3200
  if ((opts.all || opts.default) && (opts.include || opts.exclude)) {
3201
+ if (opts.json) {
3202
+ emitJsonError("usage_error", "--include/--exclude cannot be combined with --all/--default", 2);
3203
+ return;
3204
+ }
1965
3205
  p.log.error("--include/--exclude cannot be combined with --all/--default");
1966
3206
  process.exitCode = 1;
1967
3207
  return;
@@ -1970,7 +3210,12 @@ syncCommand.addCommand(new Command("pair").configureHelp(helpStyle).description(
1970
3210
  try {
1971
3211
  payload = JSON.parse(acceptText);
1972
3212
  } catch (error) {
1973
- p.log.error(error instanceof Error ? `Invalid pairing payload: ${error.message}` : "Invalid pairing payload");
3213
+ const msg = error instanceof Error ? `Invalid pairing payload: ${error.message}` : "Invalid pairing payload";
3214
+ if (opts.json) {
3215
+ emitJsonError("invalid_payload", msg);
3216
+ return;
3217
+ }
3218
+ p.log.error(msg);
1974
3219
  process.exitCode = 1;
1975
3220
  return;
1976
3221
  }
@@ -1979,11 +3224,20 @@ syncCommand.addCommand(new Command("pair").configureHelp(helpStyle).description(
1979
3224
  const publicKey = String(payload.public_key || "").trim();
1980
3225
  const resolvedAddresses = opts.address?.trim() ? [opts.address.trim()] : Array.isArray(payload.addresses) ? payload.addresses.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()) : [];
1981
3226
  if (!deviceId || !fingerprint || !publicKey || resolvedAddresses.length === 0) {
1982
- p.log.error("Pairing payload missing device_id, fingerprint, public_key, or addresses");
3227
+ const msg = "Pairing payload missing device_id, fingerprint, public_key, or addresses";
3228
+ if (opts.json) {
3229
+ emitJsonError("invalid_payload", msg);
3230
+ return;
3231
+ }
3232
+ p.log.error(msg);
1983
3233
  process.exitCode = 1;
1984
3234
  return;
1985
3235
  }
1986
3236
  if (fingerprintPublicKey(publicKey) !== fingerprint) {
3237
+ if (opts.json) {
3238
+ emitJsonError("fingerprint_mismatch", "Pairing payload fingerprint mismatch");
3239
+ return;
3240
+ }
1987
3241
  p.log.error("Pairing payload fingerprint mismatch");
1988
3242
  process.exitCode = 1;
1989
3243
  return;
@@ -2001,6 +3255,13 @@ syncCommand.addCommand(new Command("pair").configureHelp(helpStyle).description(
2001
3255
  include: opts.all ? [] : parseProjectList(opts.include),
2002
3256
  exclude: opts.all ? [] : parseProjectList(opts.exclude)
2003
3257
  });
3258
+ if (opts.json) {
3259
+ console.log(JSON.stringify({
3260
+ ok: true,
3261
+ peer_device_id: deviceId
3262
+ }));
3263
+ return;
3264
+ }
2004
3265
  p.log.success(`Paired with ${deviceId}`);
2005
3266
  return;
2006
3267
  }
@@ -2008,24 +3269,28 @@ syncCommand.addCommand(new Command("pair").configureHelp(helpStyle).description(
2008
3269
  const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir });
2009
3270
  const publicKey = loadPublicKey(keysDir);
2010
3271
  if (!publicKey) {
3272
+ if (opts.json) {
3273
+ emitJsonError("missing_key", "Public key missing");
3274
+ return;
3275
+ }
2011
3276
  p.log.error("Public key missing");
2012
3277
  process.exitCode = 1;
2013
3278
  return;
2014
3279
  }
2015
- const config = readCodememConfigFile();
3280
+ const config = readCliConfig(opts.config);
2016
3281
  const explicitAddress = opts.address?.trim();
2017
3282
  const configuredHost = typeof config.sync_host === "string" ? config.sync_host : null;
2018
3283
  const configuredPort = typeof config.sync_port === "number" ? config.sync_port : 7337;
2019
3284
  const addresses = collectAdvertiseAddresses(explicitAddress ?? null, configuredHost, configuredPort, networkInterfaces());
2020
- const payload = {
3285
+ const payloadObj = {
2021
3286
  device_id: deviceId,
2022
3287
  fingerprint,
2023
3288
  public_key: publicKey,
2024
3289
  address: addresses[0] ?? "",
2025
3290
  addresses
2026
3291
  };
2027
- const payloadText = JSON.stringify(payload);
2028
- if (opts.payloadOnly) {
3292
+ const payloadText = JSON.stringify(payloadObj);
3293
+ if (opts.payloadOnly || opts.json) {
2029
3294
  process.stdout.write(`${payloadText}\n`);
2030
3295
  return;
2031
3296
  }
@@ -2042,10 +3307,15 @@ syncCommand.addCommand(new Command("pair").configureHelp(helpStyle).description(
2042
3307
  } finally {
2043
3308
  store.close();
2044
3309
  }
2045
- }));
2046
- syncCommand.addCommand(new Command("doctor").configureHelp(helpStyle).description("Diagnose common sync setup and connectivity issues").option("--db <path>", "database path").option("--db-path <path>", "database path").action(async (opts) => {
2047
- const config = readCodememConfigFile();
2048
- const dbPath = resolveDbPath(opts.db ?? opts.dbPath);
3310
+ });
3311
+ syncCommand.addCommand(pairCmd);
3312
+ var doctorCmd = new Command("doctor").configureHelp(helpStyle).description("Diagnose common sync setup and connectivity issues");
3313
+ addDbOption(doctorCmd);
3314
+ addConfigOption(doctorCmd);
3315
+ addJsonOption(doctorCmd);
3316
+ doctorCmd.action(async (opts) => {
3317
+ const config = readCliConfig(opts.config);
3318
+ const dbPath = resolveDbPath(resolveDbOpt(opts));
2049
3319
  const store = new MemoryStore(dbPath);
2050
3320
  try {
2051
3321
  const d = drizzle(store.db, { schema });
@@ -2061,47 +3331,70 @@ syncCommand.addCommand(new Command("doctor").configureHelp(helpStyle).descriptio
2061
3331
  const syncHost = typeof config.sync_host === "string" ? config.sync_host : "0.0.0.0";
2062
3332
  const syncPort = typeof config.sync_port === "number" ? config.sync_port : 7337;
2063
3333
  const viewerBinding = readViewerBinding(dbPath);
3334
+ const reachable = viewerBinding ? await portOpen(viewerBinding.host, viewerBinding.port) : false;
3335
+ if (!reachable) issues.push("daemon not running");
3336
+ if (!device) issues.push("identity missing");
3337
+ if (daemonState?.last_error && (!daemonState.last_ok_at || daemonState.last_ok_at < (daemonState.last_error_at ?? ""))) issues.push("daemon error");
3338
+ if (peers.length === 0) issues.push("no peers");
3339
+ if (!config.sync_enabled) issues.push("sync is disabled");
3340
+ const peerDetails = [];
3341
+ for (const peer of peers) {
3342
+ const addresses = peer.addresses_json ? JSON.parse(peer.addresses_json) : [];
3343
+ const endpoint = parseStoredAddressEndpoint(addresses[0] ?? "");
3344
+ const reach = endpoint ? await portOpen(endpoint.host, endpoint.port) ? "ok" : "unreachable" : "unknown";
3345
+ const pinned = Boolean(peer.pinned_fingerprint);
3346
+ const hasKey = Boolean(peer.public_key);
3347
+ peerDetails.push({
3348
+ peer_device_id: peer.peer_device_id,
3349
+ addresses: addresses.length,
3350
+ reachable: reach,
3351
+ pinned,
3352
+ has_public_key: hasKey
3353
+ });
3354
+ if (reach !== "ok") issues.push(`peer ${peer.peer_device_id} unreachable`);
3355
+ if (!pinned || !hasKey) issues.push(`peer ${peer.peer_device_id} not pinned`);
3356
+ }
3357
+ if (opts.json) {
3358
+ console.log(JSON.stringify({
3359
+ enabled: config.sync_enabled === true,
3360
+ listen: `${syncHost}:${syncPort}`,
3361
+ mdns: process.env.CODEMEM_SYNC_MDNS ? "env-configured" : "default/off",
3362
+ daemon: reachable ? "running" : "not running",
3363
+ identity: device?.device_id ?? null,
3364
+ daemon_error: daemonState?.last_error ?? null,
3365
+ peers: peerDetails,
3366
+ issues: [...new Set(issues)],
3367
+ ok: issues.length === 0
3368
+ }));
3369
+ return;
3370
+ }
2064
3371
  console.log("Sync doctor");
2065
3372
  console.log(`- Enabled: ${config.sync_enabled === true}`);
2066
3373
  console.log(`- Listen: ${syncHost}:${syncPort}`);
2067
3374
  console.log(`- mDNS: ${process.env.CODEMEM_SYNC_MDNS ? "env-configured" : "default/off"}`);
2068
- const reachable = viewerBinding ? await portOpen(viewerBinding.host, viewerBinding.port) : false;
2069
3375
  console.log(`- Daemon: ${reachable ? "running" : "not running"}`);
2070
- if (!reachable) issues.push("daemon not running");
2071
- if (!device) {
2072
- console.log("- Identity: missing (run `codemem sync enable`)");
2073
- issues.push("identity missing");
2074
- } else console.log(`- Identity: ${device.device_id}`);
2075
- if (daemonState?.last_error && (!daemonState.last_ok_at || daemonState.last_ok_at < (daemonState.last_error_at ?? ""))) {
2076
- console.log(`- Daemon error: ${daemonState.last_error} (at ${daemonState.last_error_at ?? "unknown"})`);
2077
- issues.push("daemon error");
2078
- }
2079
- if (peers.length === 0) {
2080
- console.log("- Peers: none (pair a device first)");
2081
- issues.push("no peers");
2082
- } else {
3376
+ if (!device) console.log("- Identity: missing (run `codemem sync enable`)");
3377
+ else console.log(`- Identity: ${device.device_id}`);
3378
+ if (daemonState?.last_error && (!daemonState.last_ok_at || daemonState.last_ok_at < (daemonState.last_error_at ?? ""))) console.log(`- Daemon error: ${daemonState.last_error} (at ${daemonState.last_error_at ?? "unknown"})`);
3379
+ if (peers.length === 0) console.log("- Peers: none (pair a device first)");
3380
+ else {
2083
3381
  console.log(`- Peers: ${peers.length}`);
2084
- for (const peer of peers) {
2085
- const addresses = peer.addresses_json ? JSON.parse(peer.addresses_json) : [];
2086
- const endpoint = parseStoredAddressEndpoint(addresses[0] ?? "");
2087
- const reach = endpoint ? await portOpen(endpoint.host, endpoint.port) ? "ok" : "unreachable" : "unknown";
2088
- const pinned = Boolean(peer.pinned_fingerprint);
2089
- const hasKey = Boolean(peer.public_key);
2090
- console.log(` - ${peer.peer_device_id}: addresses=${addresses.length} reach=${reach} pinned=${pinned} public_key=${hasKey}`);
2091
- if (reach !== "ok") issues.push(`peer ${peer.peer_device_id} unreachable`);
2092
- if (!pinned || !hasKey) issues.push(`peer ${peer.peer_device_id} not pinned`);
2093
- }
3382
+ for (const detail of peerDetails) console.log(` - ${detail.peer_device_id}: addresses=${detail.addresses} reach=${detail.reachable} pinned=${detail.pinned} public_key=${detail.has_public_key}`);
2094
3383
  }
2095
- if (!config.sync_enabled) issues.push("sync is disabled");
2096
3384
  if (issues.length > 0) console.log(`WARN: ${[...new Set(issues)].slice(0, 3).join(", ")}`);
2097
3385
  else console.log("OK: sync looks healthy");
2098
3386
  } finally {
2099
3387
  store.close();
2100
3388
  }
2101
- }));
2102
- syncCommand.addCommand(new Command("status").configureHelp(helpStyle).description("Show sync configuration and peer summary").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--json", "output as JSON").action((opts) => {
2103
- const config = readCodememConfigFile();
2104
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
3389
+ });
3390
+ syncCommand.addCommand(doctorCmd);
3391
+ var statusCmd = new Command("status").configureHelp(helpStyle).description("Show sync configuration and peer summary");
3392
+ addDbOption(statusCmd);
3393
+ addConfigOption(statusCmd);
3394
+ addJsonOption(statusCmd);
3395
+ statusCmd.action((opts) => {
3396
+ const config = readCliConfig(opts.config);
3397
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
2105
3398
  try {
2106
3399
  const d = drizzle(store.db, { schema });
2107
3400
  const deviceRow = d.select({
@@ -2151,17 +3444,41 @@ syncCommand.addCommand(new Command("status").configureHelp(helpStyle).descriptio
2151
3444
  } finally {
2152
3445
  store.close();
2153
3446
  }
2154
- }));
2155
- syncCommand.addCommand(new Command("enable").configureHelp(helpStyle).description("Enable sync and initialize device identity").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--host <host>", "sync listen host").option("--port <port>", "sync listen port").option("--interval <seconds>", "sync interval in seconds").action((opts) => {
2156
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
3447
+ });
3448
+ syncCommand.addCommand(statusCmd);
3449
+ var enableCmd = new Command("enable").configureHelp(helpStyle).description("Enable sync and initialize device identity").option("--sync-host <host>", "sync listen host").option("--sync-port <port>", "sync listen port").option("--interval <seconds>", "sync interval in seconds");
3450
+ addDbOption(enableCmd);
3451
+ addConfigOption(enableCmd);
3452
+ addJsonOption(enableCmd);
3453
+ enableCmd.addOption(new Option("--host <host>", "sync listen host").hideHelp());
3454
+ enableCmd.addOption(new Option("--port <port>", "sync listen port").hideHelp());
3455
+ enableCmd.action((opts) => {
3456
+ if (opts.host && !opts.syncHost) emitDeprecationWarning("--host on sync enable", "--sync-host");
3457
+ if (opts.port && !opts.syncPort) emitDeprecationWarning("--port on sync enable", "--sync-port");
3458
+ const effectiveHost = opts.syncHost ?? opts.host;
3459
+ const effectivePort = opts.syncPort ?? opts.port;
3460
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
2157
3461
  try {
2158
3462
  const [deviceId, fingerprint] = ensureDeviceIdentity(store.db);
2159
- const config = readCodememConfigFile();
3463
+ const config = readCliConfig(opts.config);
2160
3464
  config.sync_enabled = true;
2161
- if (opts.host) config.sync_host = opts.host;
2162
- if (opts.port) config.sync_port = Number.parseInt(opts.port, 10);
2163
- if (opts.interval) config.sync_interval_s = Number.parseInt(opts.interval, 10);
2164
- writeCodememConfigFile(config);
3465
+ if (effectiveHost) config.sync_host = effectiveHost;
3466
+ const syncPort = parsePositiveIntegerOption(effectivePort, "--sync-port");
3467
+ const syncInterval = parsePositiveIntegerOption(opts.interval, "--interval");
3468
+ if (syncPort != null) config.sync_port = syncPort;
3469
+ if (syncInterval != null) config.sync_interval_s = syncInterval;
3470
+ writeCliConfig(config, opts.config);
3471
+ if (opts.json) {
3472
+ console.log(JSON.stringify({
3473
+ ok: true,
3474
+ device_id: deviceId,
3475
+ fingerprint,
3476
+ host: config.sync_host ?? "0.0.0.0",
3477
+ port: config.sync_port ?? 7337,
3478
+ interval_s: config.sync_interval_s ?? 120
3479
+ }));
3480
+ return;
3481
+ }
2165
3482
  p.intro("codemem sync enable");
2166
3483
  p.log.success([
2167
3484
  `Device ID: ${deviceId}`,
@@ -2174,16 +3491,23 @@ syncCommand.addCommand(new Command("enable").configureHelp(helpStyle).descriptio
2174
3491
  } finally {
2175
3492
  store.close();
2176
3493
  }
2177
- }));
2178
- syncCommand.addCommand(new Command("disable").configureHelp(helpStyle).description("Disable sync without deleting keys or peers").action(() => {
2179
- const config = readCodememConfigFile();
3494
+ });
3495
+ syncCommand.addCommand(enableCmd);
3496
+ var disableCmd = new Command("disable").configureHelp(helpStyle).description("Disable sync without deleting keys or peers");
3497
+ addConfigOption(disableCmd);
3498
+ disableCmd.action((opts) => {
3499
+ const config = readCliConfig(opts.config);
2180
3500
  config.sync_enabled = false;
2181
- writeCodememConfigFile(config);
3501
+ writeCliConfig(config, opts.config);
2182
3502
  p.intro("codemem sync disable");
2183
3503
  p.outro("Sync disabled — restart `codemem serve` to take effect");
2184
- }));
2185
- var peersCommand = new Command("peers").configureHelp(helpStyle).description("List known sync peers").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--json", "output as JSON").action((opts) => {
2186
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
3504
+ });
3505
+ syncCommand.addCommand(disableCmd);
3506
+ var peersCommand = new Command("peers").configureHelp(helpStyle).description("List known sync peers");
3507
+ addDbOption(peersCommand);
3508
+ addJsonOption(peersCommand);
3509
+ peersCommand.action((opts) => {
3510
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
2187
3511
  try {
2188
3512
  const peers = drizzle(store.db, { schema }).select({
2189
3513
  peer_device_id: schema.syncPeers.peer_device_id,
@@ -2211,17 +3535,28 @@ var peersCommand = new Command("peers").configureHelp(helpStyle).description("Li
2211
3535
  store.close();
2212
3536
  }
2213
3537
  });
2214
- peersCommand.addCommand(new Command("remove").configureHelp(helpStyle).description("Remove a sync peer by device id or exact name").argument("<peer>", "peer device id or exact name").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--json", "output as JSON").action((peerRef, opts) => {
2215
- const store = new MemoryStore(resolveDbPath(opts.db ?? opts.dbPath));
3538
+ var peersRemoveCmd = new Command("remove").configureHelp(helpStyle).description("Remove a sync peer by device id or exact name").argument("<peer>", "peer device id or exact name");
3539
+ addDbOption(peersRemoveCmd);
3540
+ addJsonOption(peersRemoveCmd);
3541
+ peersRemoveCmd.action((peerRef, opts) => {
3542
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
2216
3543
  try {
2217
3544
  const d = drizzle(store.db, { schema });
2218
3545
  const match = resolvePeerMatch(d, peerRef);
2219
3546
  if (match === "ambiguous") {
3547
+ if (opts.json) {
3548
+ emitJsonError("ambiguous_peer", `Peer name is ambiguous: ${peerRef.trim()}`);
3549
+ return;
3550
+ }
2220
3551
  p.log.error(`Peer name is ambiguous: ${peerRef.trim()}`);
2221
3552
  process.exitCode = 1;
2222
3553
  return;
2223
3554
  }
2224
3555
  if (!match) {
3556
+ if (opts.json) {
3557
+ emitJsonError("peer_not_found", `Peer not found: ${peerRef.trim()}`);
3558
+ return;
3559
+ }
2225
3560
  p.log.error(`Peer not found: ${peerRef.trim()}`);
2226
3561
  process.exitCode = 1;
2227
3562
  return;
@@ -2243,267 +3578,180 @@ peersCommand.addCommand(new Command("remove").configureHelp(helpStyle).descripti
2243
3578
  } finally {
2244
3579
  store.close();
2245
3580
  }
2246
- }));
3581
+ });
3582
+ peersCommand.addCommand(peersRemoveCmd);
2247
3583
  syncCommand.addCommand(peersCommand);
2248
- syncCommand.addCommand(new Command("connect").configureHelp(helpStyle).description("Configure coordinator URL for cloud sync").argument("<url>", "coordinator URL (e.g. https://coordinator.example.com)").option("--group <group>", "sync group ID").action((url, opts) => {
2249
- const config = readCodememConfigFile();
2250
- config.sync_coordinator_url = url.trim();
2251
- if (opts.group) config.sync_coordinator_group = opts.group.trim();
2252
- writeCodememConfigFile(config);
2253
- p.intro("codemem sync connect");
2254
- p.log.success(`Coordinator: ${url.trim()}`);
2255
- if (opts.group) p.log.info(`Group: ${opts.group.trim()}`);
2256
- p.outro("Restart `codemem serve` to activate coordinator sync");
2257
- }));
2258
- var coordinatorCommand = new Command("coordinator").configureHelp(helpStyle).description("Manage coordinator invites, join requests, and relay server");
2259
- coordinatorCommand.addCommand(new Command("group-create").configureHelp(helpStyle).description("Create a coordinator group in the local store").argument("<group>", "group id").option("--name <name>", "display name override").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--json", "output as JSON").action(async (groupId, opts) => {
2260
- const group = await coordinatorCreateGroupAction({
2261
- groupId,
2262
- displayName: opts.name?.trim() || null,
2263
- dbPath: opts.db ?? opts.dbPath ?? null
2264
- });
2265
- if (opts.json) {
2266
- console.log(JSON.stringify(group, null, 2));
2267
- return;
2268
- }
2269
- p.intro("codemem sync coordinator group-create");
2270
- p.log.success(`Group ready: ${groupId.trim()}`);
2271
- p.outro(String(group.display_name ?? group.group_id ?? groupId.trim()));
2272
- }));
2273
- coordinatorCommand.addCommand(new Command("list-groups").configureHelp(helpStyle).description("List coordinator groups from the local store").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--json", "output as JSON").action(async (opts) => {
2274
- const groups = await coordinatorListGroupsAction({ dbPath: opts.db ?? opts.dbPath ?? null });
2275
- if (opts.json) {
2276
- console.log(JSON.stringify(groups, null, 2));
2277
- return;
2278
- }
2279
- p.intro("codemem sync coordinator list-groups");
2280
- if (groups.length === 0) {
2281
- p.outro("No coordinator groups found");
2282
- return;
2283
- }
2284
- for (const group of groups) p.log.message(`- ${String(group.group_id ?? "")}${group.display_name ? ` (${String(group.display_name)})` : ""}`);
2285
- p.outro(`${groups.length} group(s)`);
2286
- }));
2287
- coordinatorCommand.addCommand(new Command("enroll-device").configureHelp(helpStyle).description("Enroll a device in a local coordinator group").argument("<group>", "group id").argument("<device-id>", "device id").option("--fingerprint <fingerprint>", "device fingerprint").option("--public-key <key>", "device public key").option("--public-key-file <path>", "path to device public key").option("--name <name>", "display name").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--json", "output as JSON").action(async (groupId, deviceId, opts) => {
2288
- const publicKey = readCoordinatorPublicKey(opts);
2289
- const fingerprint = String(opts.fingerprint ?? "").trim();
2290
- if (!fingerprint) {
2291
- p.log.error("Fingerprint required via --fingerprint");
2292
- process.exitCode = 1;
2293
- return;
2294
- }
2295
- if (fingerprintPublicKey(publicKey) !== fingerprint) {
2296
- p.log.error("Fingerprint does not match the provided public key");
2297
- process.exitCode = 1;
2298
- return;
2299
- }
2300
- const enrollment = await coordinatorEnrollDeviceAction({
2301
- groupId,
2302
- deviceId,
2303
- fingerprint,
2304
- publicKey,
2305
- displayName: opts.name?.trim() || null,
2306
- dbPath: opts.db ?? opts.dbPath ?? null
2307
- });
2308
- if (opts.json) {
2309
- console.log(JSON.stringify(enrollment, null, 2));
2310
- return;
2311
- }
2312
- p.intro("codemem sync coordinator enroll-device");
2313
- p.log.success(`Enrolled ${deviceId.trim()} in ${groupId.trim()}`);
2314
- p.outro(String(enrollment.display_name ?? enrollment.device_id ?? deviceId.trim()));
2315
- }));
2316
- coordinatorCommand.addCommand(new Command("list-devices").configureHelp(helpStyle).description("List enrolled devices in a local coordinator group").argument("<group>", "group id").option("--include-disabled", "include disabled devices").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--json", "output as JSON").action(async (groupId, opts) => {
2317
- const rows = await coordinatorListDevicesAction({
2318
- groupId,
2319
- includeDisabled: opts.includeDisabled === true,
2320
- dbPath: opts.db ?? opts.dbPath ?? null
2321
- });
2322
- if (opts.json) {
2323
- console.log(JSON.stringify(rows, null, 2));
2324
- return;
2325
- }
2326
- p.intro("codemem sync coordinator list-devices");
2327
- if (rows.length === 0) {
2328
- p.outro(`No enrolled devices for ${groupId.trim()}`);
2329
- return;
2330
- }
2331
- for (const row of rows) {
2332
- const label = String(row.display_name ?? row.device_id ?? "").trim() || String(row.device_id ?? "");
2333
- const enabled = Number(row.enabled ?? 1) === 1 ? "enabled" : "disabled";
2334
- p.log.message(`- ${label} (${String(row.device_id ?? "")}) ${enabled}`);
2335
- }
2336
- p.outro(`${rows.length} device(s)`);
2337
- }));
2338
- coordinatorCommand.addCommand(new Command("rename-device").configureHelp(helpStyle).description("Rename an enrolled device in the local coordinator store").argument("<group>", "group id").argument("<device-id>", "device id").requiredOption("--name <name>", "display name").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--json", "output as JSON").action(async (groupId, deviceId, opts) => {
2339
- const result = await coordinatorRenameDeviceAction({
2340
- groupId,
2341
- deviceId,
2342
- displayName: opts.name.trim(),
2343
- dbPath: opts.db ?? opts.dbPath ?? null
2344
- });
2345
- if (!result) {
2346
- p.log.error(`Device not found: ${deviceId.trim()}`);
2347
- process.exitCode = 1;
2348
- return;
2349
- }
2350
- if (opts.json) {
2351
- console.log(JSON.stringify(result, null, 2));
2352
- return;
2353
- }
2354
- p.intro("codemem sync coordinator rename-device");
2355
- p.log.success(`Renamed ${deviceId.trim()} in ${groupId.trim()}`);
2356
- p.outro(String(result.display_name ?? result.device_id ?? deviceId.trim()));
2357
- }));
2358
- coordinatorCommand.addCommand(new Command("disable-device").configureHelp(helpStyle).description("Disable an enrolled device in the local coordinator store").argument("<group>", "group id").argument("<device-id>", "device id").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--json", "output as JSON").action(async (groupId, deviceId, opts) => {
2359
- if (!await coordinatorDisableDeviceAction({
2360
- groupId,
2361
- deviceId,
2362
- dbPath: opts.db ?? opts.dbPath ?? null
2363
- })) {
2364
- p.log.error(`Device not found: ${deviceId.trim()}`);
2365
- process.exitCode = 1;
2366
- return;
2367
- }
2368
- if (opts.json) {
2369
- console.log(JSON.stringify({
2370
- ok: true,
2371
- group_id: groupId.trim(),
2372
- device_id: deviceId.trim()
2373
- }, null, 2));
2374
- return;
2375
- }
2376
- p.intro("codemem sync coordinator disable-device");
2377
- p.log.success(`Disabled ${deviceId.trim()} in ${groupId.trim()}`);
2378
- p.outro("disabled");
2379
- }));
2380
- coordinatorCommand.addCommand(new Command("remove-device").configureHelp(helpStyle).description("Remove an enrolled device from the local coordinator store").argument("<group>", "group id").argument("<device-id>", "device id").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--json", "output as JSON").action(async (groupId, deviceId, opts) => {
2381
- if (!await coordinatorRemoveDeviceAction({
2382
- groupId,
2383
- deviceId,
2384
- dbPath: opts.db ?? opts.dbPath ?? null
2385
- })) {
2386
- p.log.error(`Device not found: ${deviceId.trim()}`);
2387
- process.exitCode = 1;
2388
- return;
2389
- }
2390
- if (opts.json) {
2391
- console.log(JSON.stringify({
2392
- ok: true,
2393
- group_id: groupId.trim(),
2394
- device_id: deviceId.trim()
2395
- }, null, 2));
2396
- return;
2397
- }
2398
- p.intro("codemem sync coordinator remove-device");
2399
- p.log.success(`Removed ${deviceId.trim()} from ${groupId.trim()}`);
2400
- p.outro("removed");
2401
- }));
2402
- coordinatorCommand.addCommand(new Command("serve").configureHelp(helpStyle).description("Run the coordinator relay HTTP server").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--host <host>", "bind host", "127.0.0.1").option("--port <port>", "bind port", "7347").action(async (opts) => {
2403
- const host = String(opts.host ?? "127.0.0.1").trim() || "127.0.0.1";
2404
- const port = Number.parseInt(String(opts.port ?? "7347"), 10);
2405
- const dbPath = opts.db ?? opts.dbPath ?? DEFAULT_COORDINATOR_DB_PATH;
2406
- const app = createBetterSqliteCoordinatorApp({ dbPath });
2407
- p.intro("codemem sync coordinator serve");
2408
- p.log.success(`Coordinator listening at http://${host}:${port}`);
2409
- p.log.info(`DB: ${dbPath}`);
2410
- serve({
2411
- fetch: app.fetch,
2412
- hostname: host,
2413
- port
2414
- });
2415
- }));
2416
- coordinatorCommand.addCommand(new Command("create-invite").configureHelp(helpStyle).description("Create a coordinator team invite").argument("[group]", "group id").option("--group <group>", "group id").option("--coordinator-url <url>", "coordinator URL override").option("--policy <policy>", "invite policy", "auto_admit").option("--ttl-hours <hours>", "invite TTL hours", "24").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override").option("--json", "output as JSON").action(async (groupArg, opts) => {
2417
- const ttlHours = Number.parseInt(String(opts.ttlHours ?? "24"), 10);
2418
- const groupId = String(opts.group ?? "").trim() || String(groupArg ?? "").trim();
2419
- const result = await coordinatorCreateInviteAction({
2420
- groupId,
2421
- coordinatorUrl: opts.coordinatorUrl?.trim() || null,
2422
- policy: String(opts.policy ?? "auto_admit").trim(),
2423
- ttlHours,
2424
- createdBy: null,
2425
- dbPath: opts.db ?? opts.dbPath ?? null,
2426
- remoteUrl: opts.remoteUrl?.trim() || null,
2427
- adminSecret: opts.adminSecret?.trim() || null
2428
- });
2429
- if (opts.json) {
2430
- console.log(JSON.stringify(result, null, 2));
2431
- return;
2432
- }
2433
- p.intro("codemem sync coordinator create-invite");
2434
- p.log.success(`Invite created for ${groupId}`);
2435
- if (typeof result.link === "string") p.log.message(`- link: ${result.link}`);
2436
- if (typeof result.encoded === "string") p.log.message(`- invite: ${result.encoded}`);
2437
- for (const warning of Array.isArray(result.warnings) ? result.warnings : []) p.log.warn(String(warning));
2438
- p.outro("Invite ready");
2439
- }));
2440
- coordinatorCommand.addCommand(new Command("import-invite").configureHelp(helpStyle).description("Import a coordinator invite").argument("<invite>", "invite value or link").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--keys-dir <path>", "keys directory").option("--config <path>", "config path").option("--json", "output as JSON").action(async (invite, opts) => {
2441
- const result = await coordinatorImportInviteAction({
2442
- inviteValue: invite,
2443
- dbPath: opts.db ?? opts.dbPath ?? null,
2444
- keysDir: opts.keysDir ?? null,
2445
- configPath: opts.config ?? null
2446
- });
2447
- if (opts.json) {
2448
- console.log(JSON.stringify(result, null, 2));
2449
- return;
2450
- }
2451
- p.intro("codemem sync coordinator import-invite");
2452
- p.log.success(`Invite imported for ${result.group_id}`);
2453
- p.log.message(`- coordinator: ${result.coordinator_url}`);
2454
- p.log.message(`- status: ${result.status}`);
2455
- p.outro("Coordinator config updated");
2456
- }));
2457
- coordinatorCommand.addCommand(new Command("list-join-requests").configureHelp(helpStyle).description("List pending coordinator join requests").argument("[group]", "group id").option("--group <group>", "group id").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override").option("--json", "output as JSON").action(async (groupArg, opts) => {
2458
- const groupId = String(opts.group ?? "").trim() || String(groupArg ?? "").trim();
2459
- const rows = await coordinatorListJoinRequestsAction({
2460
- groupId,
2461
- dbPath: opts.db ?? opts.dbPath ?? null,
2462
- remoteUrl: opts.remoteUrl?.trim() || null,
2463
- adminSecret: opts.adminSecret?.trim() || null
2464
- });
2465
- if (opts.json) {
2466
- console.log(JSON.stringify(rows, null, 2));
2467
- return;
2468
- }
2469
- p.intro("codemem sync coordinator list-join-requests");
2470
- if (rows.length === 0) {
2471
- p.outro(`No pending join requests for ${groupId}`);
3584
+ var bootstrapCmd = new Command("bootstrap").configureHelp(helpStyle).description("Fast-bootstrap memories from a peer (full snapshot transfer)").argument("[peer-device-id]", "peer device ID to bootstrap from").option("--bootstrap-grant <grant-id>", "bootstrap grant id for seed-authorized bootstrap").option("--page-size <n>", "items per snapshot page (default: 2000)", "2000").option("--keys-dir <path>", "keys directory").option("--force", "skip dirty-local-state safety check");
3585
+ addDbOption(bootstrapCmd);
3586
+ addJsonOption(bootstrapCmd);
3587
+ bootstrapCmd.addOption(new Option("--peer <device-id>", "peer device ID").hideHelp());
3588
+ bootstrapCmd.action(async (peerArg, opts) => {
3589
+ const peerDeviceId = (peerArg || opts.peer || "").trim();
3590
+ if (!peerDeviceId) {
3591
+ if (opts.json) {
3592
+ emitJsonError("usage_error", "missing required argument: <peer-device-id>", 2);
3593
+ return;
3594
+ }
3595
+ p.log.error("missing required argument: <peer-device-id>");
3596
+ process.exitCode = 2;
2472
3597
  return;
2473
3598
  }
2474
- for (const row of rows) {
2475
- const displayName = row.display_name || row.device_id;
2476
- p.log.message(`- ${displayName} (${row.device_id}) request_id=${row.request_id}`);
2477
- }
2478
- p.outro(`${rows.length} pending join request(s)`);
2479
- }));
2480
- function addReviewJoinRequestCommand(name, approve) {
2481
- coordinatorCommand.addCommand(new Command(name).configureHelp(helpStyle).description(`${approve ? "Approve" : "Deny"} a coordinator join request`).argument("<request-id>", "join request id").option("--db <path>", "coordinator database path").option("--db-path <path>", "coordinator database path").option("--remote-url <url>", "remote coordinator URL override").option("--admin-secret <secret>", "remote coordinator admin secret override").option("--json", "output as JSON").action(async (requestId, opts) => {
2482
- const request = await coordinatorReviewJoinRequestAction({
2483
- requestId: requestId.trim(),
2484
- approve,
2485
- reviewedBy: null,
2486
- dbPath: opts.db ?? opts.dbPath ?? null,
2487
- remoteUrl: opts.remoteUrl?.trim() || null,
2488
- adminSecret: opts.adminSecret?.trim() || null
2489
- });
2490
- if (!request) {
2491
- p.log.error(`Join request not found: ${requestId.trim()}`);
3599
+ const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
3600
+ try {
3601
+ const pageSize = Math.max(1, Number.parseInt(opts.pageSize, 10) || 2e3);
3602
+ const keysDir = opts.keysDir ?? void 0;
3603
+ const peer = drizzle(store.db, { schema }).select().from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
3604
+ if (!peer) {
3605
+ if (opts.json) emitJsonError("peer_not_found", `Peer ${peerDeviceId} not found in sync_peers.`);
3606
+ else p.log.error(`Peer ${peerDeviceId} not found in sync_peers.`);
2492
3607
  process.exitCode = 1;
2493
3608
  return;
2494
3609
  }
2495
- if (opts.json) {
2496
- console.log(JSON.stringify(request, null, 2));
3610
+ if (!peer.pinned_fingerprint) {
3611
+ if (opts.json) emitJsonError("peer_not_pinned", `Peer ${peerDeviceId} has no pinned fingerprint. Accept it first.`);
3612
+ else p.log.error(`Peer ${peerDeviceId} has no pinned fingerprint. Accept it first.`);
3613
+ process.exitCode = 1;
2497
3614
  return;
2498
3615
  }
2499
- p.intro(`codemem sync coordinator ${name}`);
2500
- p.log.success(`${approve ? "Approved" : "Denied"} join request ${requestId.trim()}`);
2501
- p.outro(String(request.status ?? "updated"));
2502
- }));
2503
- }
2504
- addReviewJoinRequestCommand("approve-join-request", true);
2505
- addReviewJoinRequestCommand("deny-join-request", false);
2506
- syncCommand.addCommand(coordinatorCommand);
3616
+ if (!opts.force) {
3617
+ const dirty = hasUnsyncedSharedMemoryChanges(store.db);
3618
+ if (dirty.dirty) {
3619
+ if (opts.json) emitJsonError("local_unsynced_changes", `${dirty.count} unsynced shared memory change(s) would be lost. Use --force to override.`);
3620
+ else p.log.error(`${dirty.count} unsynced shared memory change(s) would be lost. Use --force to override.`);
3621
+ process.exitCode = 1;
3622
+ return;
3623
+ }
3624
+ }
3625
+ const [deviceId] = ensureDeviceIdentity(store.db, { keysDir });
3626
+ const addresses = JSON.parse(String(peer.addresses_json ?? "[]"));
3627
+ if (!addresses.length) {
3628
+ if (opts.json) emitJsonError("no_peer_addresses", "Peer has no known addresses. Run a sync first or add addresses.");
3629
+ else p.log.error("Peer has no known addresses. Run a sync first or add addresses.");
3630
+ process.exitCode = 1;
3631
+ return;
3632
+ }
3633
+ let boundary = null;
3634
+ let baseUrl = "";
3635
+ const addressResults = [];
3636
+ for (const address of addresses) {
3637
+ const candidate = buildBaseUrl(address);
3638
+ if (!candidate) continue;
3639
+ const statusUrl = `${candidate}/v1/status`;
3640
+ const headers = buildAuthHeaders({
3641
+ deviceId,
3642
+ method: "GET",
3643
+ url: statusUrl,
3644
+ bodyBytes: Buffer.alloc(0),
3645
+ bootstrapGrantId: opts.bootstrapGrant,
3646
+ keysDir
3647
+ });
3648
+ try {
3649
+ const [code, payload] = await requestJson("GET", statusUrl, { headers });
3650
+ if (code !== 200 || !payload) {
3651
+ addressResults.push({
3652
+ address: candidate,
3653
+ result: `status ${code}`
3654
+ });
3655
+ continue;
3656
+ }
3657
+ if (payload.fingerprint !== peer.pinned_fingerprint) {
3658
+ addressResults.push({
3659
+ address: candidate,
3660
+ result: "fingerprint mismatch"
3661
+ });
3662
+ continue;
3663
+ }
3664
+ const reset = payload.sync_reset;
3665
+ if (reset && typeof reset.generation === "number" && typeof reset.snapshot_id === "string") {
3666
+ boundary = {
3667
+ generation: reset.generation,
3668
+ snapshot_id: reset.snapshot_id,
3669
+ baseline_cursor: typeof reset.baseline_cursor === "string" ? reset.baseline_cursor : null
3670
+ };
3671
+ baseUrl = candidate;
3672
+ addressResults.push({
3673
+ address: candidate,
3674
+ result: "ok"
3675
+ });
3676
+ break;
3677
+ }
3678
+ addressResults.push({
3679
+ address: candidate,
3680
+ result: "missing sync_reset boundary"
3681
+ });
3682
+ } catch (err) {
3683
+ addressResults.push({
3684
+ address: candidate,
3685
+ result: err instanceof Error ? err.message : String(err)
3686
+ });
3687
+ }
3688
+ }
3689
+ if (!boundary || !baseUrl) {
3690
+ const summary = addressResults.map((r) => `${r.address}: ${r.result}`).join("; ");
3691
+ const detail = summary ? `no reachable peer with valid reset boundary. Tried: ${summary}` : "peer unreachable or missing reset boundary";
3692
+ if (opts.json) console.log(JSON.stringify({
3693
+ ok: false,
3694
+ error: detail,
3695
+ addresses: addressResults
3696
+ }));
3697
+ else p.log.error(detail);
3698
+ process.exitCode = 1;
3699
+ return;
3700
+ }
3701
+ if (!opts.json) {
3702
+ p.intro("codemem sync bootstrap");
3703
+ p.log.step(`Bootstrapping from ${peer.name || peerDeviceId}...`);
3704
+ }
3705
+ const resetInfo = {
3706
+ generation: boundary.generation,
3707
+ snapshot_id: boundary.snapshot_id,
3708
+ baseline_cursor: boundary.baseline_cursor,
3709
+ retained_floor_cursor: null,
3710
+ reset_required: true,
3711
+ reason: "initial_bootstrap"
3712
+ };
3713
+ const { items } = await fetchAllSnapshotPages(baseUrl, resetInfo, deviceId, {
3714
+ keysDir,
3715
+ bootstrapGrantId: opts.bootstrapGrant,
3716
+ pageSize
3717
+ });
3718
+ const result = applyBootstrapSnapshot(store.db, peerDeviceId, items, resetInfo);
3719
+ if (opts.json) console.log(JSON.stringify({
3720
+ ok: result.ok,
3721
+ applied: result.applied,
3722
+ deleted: result.deleted,
3723
+ error: result.error ?? null
3724
+ }));
3725
+ else {
3726
+ if (result.ok) p.log.success(`Applied ${result.applied} memories (removed ${result.deleted} stale).`);
3727
+ else p.log.error(result.error || "Bootstrap apply failed.");
3728
+ p.outro(result.ok ? "Bootstrap complete" : "Bootstrap failed");
3729
+ }
3730
+ if (!result.ok) process.exitCode = 1;
3731
+ } finally {
3732
+ store.close();
3733
+ }
3734
+ });
3735
+ syncCommand.addCommand(bootstrapCmd);
3736
+ var connectCmd = new Command("connect").configureHelp(helpStyle).description("Configure coordinator URL for cloud sync").argument("<url>", "coordinator URL (e.g. https://coordinator.example.com)").option("--group <group>", "sync group ID");
3737
+ addConfigOption(connectCmd);
3738
+ connectCmd.action((url, opts) => {
3739
+ const config = readCliConfig(opts.config);
3740
+ config.sync_coordinator_url = url.trim();
3741
+ if (opts.group) config.sync_coordinator_group = opts.group.trim();
3742
+ writeCliConfig(config, opts.config);
3743
+ p.intro("codemem sync connect");
3744
+ p.log.success(`Coordinator: ${url.trim()}`);
3745
+ if (opts.group) p.log.info(`Group: ${opts.group.trim()}`);
3746
+ p.outro("Restart `codemem serve` to activate coordinator sync");
3747
+ });
3748
+ syncCommand.addCommand(connectCmd);
3749
+ var syncCoordinatorAlias = buildCoordinatorCommand();
3750
+ syncCoordinatorAlias.hook("preAction", (_thisCmd, actionCmd) => {
3751
+ const subName = actionCmd.name();
3752
+ emitDeprecationWarning(`codemem sync coordinator ${subName}`, `codemem coordinator ${subName}`);
3753
+ });
3754
+ syncCommand.addCommand(syncCoordinatorAlias);
2507
3755
  //#endregion
2508
3756
  //#region src/commands/version.ts
2509
3757
  var versionCommand = new Command("version").configureHelp(helpStyle).description("Print codemem version").action(() => {
@@ -2524,24 +3772,24 @@ var versionCommand = new Command("version").configureHelp(helpStyle).description
2524
3772
  var completion = omelette("codemem <command>");
2525
3773
  completion.on("command", ({ reply }) => {
2526
3774
  reply([
3775
+ "claude-hook-inject",
2527
3776
  "claude-hook-ingest",
3777
+ "config",
3778
+ "coordinator",
2528
3779
  "db",
2529
3780
  "embed",
3781
+ "enqueue-raw-event",
2530
3782
  "export-memories",
2531
- "forget",
2532
- "memory",
2533
3783
  "import-memories",
2534
- "setup",
2535
- "show",
2536
- "sync",
2537
- "stats",
3784
+ "mcp",
3785
+ "memory",
3786
+ "pack",
2538
3787
  "recent",
2539
- "remember",
2540
3788
  "search",
2541
- "pack",
2542
3789
  "serve",
2543
- "mcp",
2544
- "enqueue-raw-event",
3790
+ "setup",
3791
+ "stats",
3792
+ "sync",
2545
3793
  "version",
2546
3794
  "help",
2547
3795
  "--help",
@@ -2575,8 +3823,33 @@ if (hasRootFlag("--cleanup-completion")) {
2575
3823
  completion.cleanupShellInitFile();
2576
3824
  process.exit(0);
2577
3825
  }
3826
+ {
3827
+ const memExport = new Command("export").description("Export memories to a JSON file for sharing or backup").argument("<output>", "output file path (use '-' for stdout)").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--project <project>", "filter by project").option("--all-projects", "export all projects").option("--include-inactive", "include deactivated memories").option("--since <iso>", "only export memories created after this ISO timestamp").configureHelp(helpStyle).allowUnknownOption(true).allowExcessArguments(true).action(async () => {
3828
+ const idx = process.argv.indexOf("export");
3829
+ const tail = idx >= 0 ? process.argv.slice(idx + 1) : [];
3830
+ await exportMemoriesCommand.parseAsync([
3831
+ "node",
3832
+ "export-memories",
3833
+ ...tail
3834
+ ]);
3835
+ });
3836
+ const memImport = new Command("import").description("Import memories from an exported JSON file").argument("<inputFile>", "input JSON file (use '-' for stdin)").option("--db <path>", "database path").option("--db-path <path>", "database path").option("--remap-project <path>", "remap all projects to this path on import").option("--dry-run", "preview import without writing").configureHelp(helpStyle).allowUnknownOption(true).allowExcessArguments(true).action(async () => {
3837
+ const idx = process.argv.indexOf("import");
3838
+ const tail = idx >= 0 ? process.argv.slice(idx + 1) : [];
3839
+ await importMemoriesCommand.parseAsync([
3840
+ "node",
3841
+ "import-memories",
3842
+ ...tail
3843
+ ]);
3844
+ });
3845
+ memoryCommand.addCommand(memExport);
3846
+ memoryCommand.addCommand(memImport);
3847
+ }
2578
3848
  program.addCommand(serveCommand);
3849
+ program.addCommand(configCommand);
3850
+ program.addCommand(coordinatorCommand);
2579
3851
  program.addCommand(mcpCommand);
3852
+ program.addCommand(claudeHookInjectCommand);
2580
3853
  program.addCommand(claudeHookIngestCommand);
2581
3854
  program.addCommand(dbCommand);
2582
3855
  program.addCommand(exportMemoriesCommand);
@@ -2586,9 +3859,9 @@ program.addCommand(embedCommand);
2586
3859
  program.addCommand(recentCommand);
2587
3860
  program.addCommand(searchCommand);
2588
3861
  program.addCommand(packCommand);
2589
- program.addCommand(showMemoryCommand);
2590
- program.addCommand(forgetMemoryCommand);
2591
- program.addCommand(rememberMemoryCommand);
3862
+ program.addCommand(showMemoryCommand, { hidden: true });
3863
+ program.addCommand(forgetMemoryCommand, { hidden: true });
3864
+ program.addCommand(rememberMemoryCommand, { hidden: true });
2592
3865
  program.addCommand(memoryCommand);
2593
3866
  program.addCommand(syncCommand);
2594
3867
  program.addCommand(setupCommand);