synomem 0.3.0 → 0.5.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 (91) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +54 -14
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backend.js +8 -2
  5. package/dist/backend.js.map +1 -1
  6. package/dist/cli.d.ts +9 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +451 -24
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client.d.ts +34 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +162 -11
  13. package/dist/client.js.map +1 -1
  14. package/dist/cloud.d.ts +16 -0
  15. package/dist/cloud.d.ts.map +1 -0
  16. package/dist/cloud.js +19 -0
  17. package/dist/cloud.js.map +1 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +8 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/configure.d.ts +55 -0
  22. package/dist/configure.d.ts.map +1 -0
  23. package/dist/configure.js +193 -0
  24. package/dist/configure.js.map +1 -0
  25. package/dist/credentials.d.ts +15 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js +9 -1
  28. package/dist/credentials.js.map +1 -1
  29. package/dist/discover.d.ts +48 -0
  30. package/dist/discover.d.ts.map +1 -0
  31. package/dist/discover.js +106 -0
  32. package/dist/discover.js.map +1 -0
  33. package/dist/import.d.ts +58 -43
  34. package/dist/import.d.ts.map +1 -1
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +15 -6
  41. package/dist/mcp/index.js.map +1 -1
  42. package/dist/oauth.d.ts.map +1 -1
  43. package/dist/oauth.js +8 -1
  44. package/dist/oauth.js.map +1 -1
  45. package/dist/projections.d.ts.map +1 -1
  46. package/dist/projections.js +19 -11
  47. package/dist/projections.js.map +1 -1
  48. package/dist/prompt.d.ts +28 -0
  49. package/dist/prompt.d.ts.map +1 -0
  50. package/dist/prompt.js +72 -0
  51. package/dist/prompt.js.map +1 -0
  52. package/dist/remote.d.ts +4 -0
  53. package/dist/remote.d.ts.map +1 -1
  54. package/dist/remote.js +4 -0
  55. package/dist/remote.js.map +1 -1
  56. package/dist/schemas.d.ts +101 -60
  57. package/dist/schemas.d.ts.map +1 -1
  58. package/dist/schemas.js +57 -5
  59. package/dist/schemas.js.map +1 -1
  60. package/dist/service.d.ts +6 -1
  61. package/dist/service.d.ts.map +1 -1
  62. package/dist/storage.d.ts +19 -1
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +61 -14
  65. package/dist/storage.js.map +1 -1
  66. package/dist/types.d.ts +50 -2
  67. package/dist/types.d.ts.map +1 -1
  68. package/docs/cli.md +68 -4
  69. package/docs/examples.md +1 -1
  70. package/docs/mcp.md +1 -1
  71. package/docs/skill.md +1 -1
  72. package/docs/storage-format.md +1 -1
  73. package/package.json +8 -8
  74. package/src/backend.ts +8 -2
  75. package/src/cli.ts +597 -31
  76. package/src/client.ts +176 -12
  77. package/src/cloud.ts +19 -0
  78. package/src/config.ts +8 -1
  79. package/src/configure.ts +249 -0
  80. package/src/credentials.ts +26 -3
  81. package/src/discover.ts +155 -0
  82. package/src/index.ts +9 -1
  83. package/src/mcp/index.ts +19 -5
  84. package/src/oauth.ts +8 -1
  85. package/src/projections.ts +21 -11
  86. package/src/prompt.ts +88 -0
  87. package/src/remote.ts +24 -0
  88. package/src/schemas.ts +60 -5
  89. package/src/service.ts +12 -0
  90. package/src/storage.ts +79 -13
  91. package/src/types.ts +45 -2
package/dist/cli.js CHANGED
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { Command, CommanderError, Option } from 'commander';
7
7
  import { configuredServiceFactory, readSynomemConfig, writeSynomemBackend } from './backend.js';
8
+ import { cloudApiUrl } from './cloud.js';
9
+ import { resolveHome } from './config.js';
10
+ import { assertInteractive, confirmPlan, credentialFingerprint, credentialStoreChoices, environmentInstructions, readAccessToken, runConfigWizard, writeCredentialFile, } from './configure.js';
11
+ import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
12
+ import { defaultPromptIo } from './prompt.js';
8
13
  import { credentialReference, OsCredentialStore } from './credentials.js';
9
14
  import { asSynomemError, SynomemError } from './errors.js';
10
15
  import { atomicWriteFile } from './fs-utils.js';
@@ -194,6 +199,8 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
194
199
  const env = dependencies.env ?? process.env;
195
200
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
196
201
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
202
+ const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
203
+ const promptIo = dependencies.promptIo ?? defaultPromptIo();
197
204
  const verifyRemoteCredential = dependencies.verifyRemoteCredential ??
198
205
  (async (options) => {
199
206
  const remote = new RemoteSynomemService({
@@ -216,11 +223,46 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
216
223
  .name('synomem')
217
224
  .description('Local-first communication, memory, recognition, and task infrastructure for agents')
218
225
  .version(packageVersion())
219
- .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.agents)')
226
+ .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.synomem)')
220
227
  .option('--json', 'emit stable machine-readable JSON', false)
221
228
  .showSuggestionAfterError()
222
229
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
223
230
  const remoteCommand = program.command('remote').description('Administer a remote workspace');
231
+ /*
232
+ * The browser counterpart to an access key naming its own workspace.
233
+ *
234
+ * A signed-in account may reach several organizations, each with several
235
+ * workspaces, so there is a genuine choice to make -- and no way to make it
236
+ * without seeing the list. Printing the IDs alongside the names is the point:
237
+ * the ID is what `backend use remote --workspace` takes.
238
+ */
239
+ remoteCommand
240
+ .command('workspaces')
241
+ .description('List the organizations and workspaces this credential can reach')
242
+ .option('--url <url>', 'internal: alternate HTTPS origin')
243
+ .action(async (options, command) => {
244
+ const global = globals(command);
245
+ const config = readSynomemConfig(global.home, env);
246
+ const baseUrl = options.url ??
247
+ (config?.backend.kind === 'remote' ? config.backend.baseUrl : cloudApiUrl(env));
248
+ const accessToken = env.SYNOMEM_ACCESS_TOKEN;
249
+ if (!accessToken) {
250
+ throw new SynomemError('AUTH_REQUIRED', 'Set SYNOMEM_ACCESS_TOKEN, or run `synomem auth login` first.');
251
+ }
252
+ const organizations = await discoverOrganizations({ baseUrl, accessToken });
253
+ const choices = workspaceChoices(organizations);
254
+ const human = organizations.length
255
+ ? organizations
256
+ .map((organization) => [
257
+ `${organization.displayName} (${organization.slug}) — ${organization.role}`,
258
+ ...(organization.workspaces.length
259
+ ? organization.workspaces.map((workspace) => ` ${workspace.id} ${workspace.displayName}`)
260
+ : [' (no workspaces yet)']),
261
+ ].join('\n'))
262
+ .join('\n')
263
+ : 'This account belongs to no organizations yet.';
264
+ output(io, global.json, { organizations, choices }, human);
265
+ });
224
266
  remoteCommand
225
267
  .command('import')
226
268
  .description('Preview or confirm a one-way import from a local Synomem home')
@@ -292,6 +334,242 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
292
334
  output(io, options.json, { home: info.home, database: info.databasePath }, `Initialized Synomem at ${info.home}`);
293
335
  });
294
336
  });
337
+ /*
338
+ * `synomem config` is the canonical entry point. `configure` and `setup` are
339
+ * accepted because people reach for them, and a setup program that rejects
340
+ * the word somebody guessed is needlessly unhelpful.
341
+ */
342
+ const configCommand = program
343
+ .command('config')
344
+ .aliases(['configure', 'setup'])
345
+ .description('Set up Synomem, interactively or deterministically');
346
+ const applyPlan = async (plan, token, global) => {
347
+ const home = plan.home;
348
+ const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
349
+ /*
350
+ * The workspace is discovered, not typed.
351
+ *
352
+ * An installation access key is bound to exactly one workspace, so the
353
+ * service can be asked which one rather than the person. An explicit
354
+ * --workspace still wins, because automation should not depend on a
355
+ * network round trip to configure a machine.
356
+ */
357
+ let workspaceId = plan.workspaceId;
358
+ if (plan.backend === 'remote' && !workspaceId && token) {
359
+ const bound = await discoverWorkspace({ baseUrl: serviceUrl, accessToken: token });
360
+ workspaceId = bound.workspaceId;
361
+ io.stdout(`Access key is bound to workspace ${workspaceId}.\n`);
362
+ }
363
+ if (plan.backend === 'remote' && !workspaceId) {
364
+ /*
365
+ * Signing in through a browser needs an actor identity and a client ID,
366
+ * which is `synomem auth login`'s job. Rather than write a remote
367
+ * backend with no workspace -- a configuration that fails on its first
368
+ * real use -- say exactly what remains.
369
+ */
370
+ output(io, global.json, { applied: false, pending: 'sign-in', home, serviceUrl }, [
371
+ '',
372
+ 'Nothing was configured yet: signing in through a browser is a separate step.',
373
+ '',
374
+ 'Run, with the actor this machine acts as:',
375
+ '',
376
+ ' synomem auth login --actor-id <agent> --client-id <client>',
377
+ '',
378
+ 'Then select the workspace it reports:',
379
+ '',
380
+ ' synomem backend use remote --workspace <workspace-id>',
381
+ ].join('\n'));
382
+ return;
383
+ }
384
+ const config = writeSynomemBackend(plan.backend === 'local'
385
+ ? { kind: 'local' }
386
+ : { kind: 'remote', baseUrl: serviceUrl, workspaceId: workspaceId }, home);
387
+ let credentialLocation;
388
+ if (token) {
389
+ if (plan.credentialStore === 'environment') {
390
+ io.stdout(`${environmentInstructions(token)}\n`);
391
+ credentialLocation = 'environment';
392
+ }
393
+ else if (plan.credentialStore === 'file') {
394
+ credentialLocation = writeCredentialFile(home, token);
395
+ }
396
+ else {
397
+ // The platform store is the default, and a failure falls back to the
398
+ // restricted file rather than leaving the credential nowhere.
399
+ try {
400
+ await credentialStore.set(`synomem:${workspaceId}`, {
401
+ kind: 'installation-key',
402
+ accessToken: token,
403
+ });
404
+ credentialLocation = 'platform credential store';
405
+ }
406
+ catch {
407
+ credentialLocation = writeCredentialFile(home, token);
408
+ }
409
+ }
410
+ }
411
+ // Diagnostics run before success is claimed: a configuration that cannot
412
+ // open its own database is not a finished setup.
413
+ const diagnostics = await withClient(home, defaultActor(env, 'system', 'cli'), (client) => client.doctor());
414
+ output(io, global.json, {
415
+ backend: config.backend,
416
+ home,
417
+ ...(credentialLocation ? { credentialSource: credentialLocation } : {}),
418
+ ...(token ? { credential: credentialFingerprint(token) } : {}),
419
+ healthy: diagnostics.healthy,
420
+ }, [
421
+ '',
422
+ 'Synomem is ready.',
423
+ '',
424
+ ` Backend: ${config.backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
425
+ ` Home: ${home}`,
426
+ ...(config.backend.kind === 'remote'
427
+ ? [` Service: ${config.backend.baseUrl}`, ` Workspace: ${config.backend.workspaceId}`]
428
+ : []),
429
+ ...(credentialLocation ? [` Credential: ${credentialLocation}`] : []),
430
+ ` Database: ${diagnostics.healthy ? 'Healthy' : 'Needs attention — run synomem doctor'}`,
431
+ ].join('\n'));
432
+ };
433
+ configCommand.action(async (_options, command) => {
434
+ const global = globals(command);
435
+ assertInteractive(promptIo);
436
+ const plan = await runConfigWizard(promptIo, {
437
+ ...(global.home ? { home: global.home } : {}),
438
+ env,
439
+ });
440
+ const token = plan.auth === 'access-key' ? await readAccessToken(promptIo) : undefined;
441
+ if (!(await confirmPlan(promptIo, plan))) {
442
+ output(io, global.json, { applied: false }, 'Nothing was changed.');
443
+ return;
444
+ }
445
+ await applyPlan(plan, token, global);
446
+ });
447
+ configCommand
448
+ .command('init')
449
+ .description('Configure Synomem without prompting')
450
+ .option('--backend <kind>', 'local or remote')
451
+ .option('--auth <method>', 'browser or access-key')
452
+ .option('--workspace <id>', 'remote workspace ID')
453
+ .option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
454
+ // The token is read from stdin, never taken as an argument: an argument is
455
+ // kept by the shell history and visible in the process list.
456
+ .option('--access-token-stdin', 'read the installation access key from stdin', false)
457
+ .option('--yes', 'apply without confirming', false)
458
+ .action(async (options, command) => {
459
+ const global = globals(command);
460
+ if (options.backend !== 'local' && options.backend !== 'remote') {
461
+ throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
462
+ }
463
+ const backend = options.backend;
464
+ // An access key names its own workspace, so --workspace is only
465
+ // required when there is no key to ask.
466
+ if (backend === 'remote' && !options.workspace && !options.accessTokenStdin) {
467
+ throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.');
468
+ }
469
+ const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
470
+ if (backend === 'remote' && options.auth === 'access-key' && !token) {
471
+ throw new SynomemError('INVALID_INPUT', 'Access-key setup requires --access-token-stdin so the key is not passed as an argument.');
472
+ }
473
+ const plan = {
474
+ backend,
475
+ home: resolveHome(global.home),
476
+ ...(backend === 'remote'
477
+ ? {
478
+ serviceUrl: cloudApiUrl(env),
479
+ auth: options.auth ?? 'access-key',
480
+ workspaceId: options.workspace,
481
+ credentialStore: options.credentialStore,
482
+ }
483
+ : {}),
484
+ };
485
+ if (!options.yes) {
486
+ throw new SynomemError('INVALID_INPUT', 'Re-run with --yes to apply this configuration.');
487
+ }
488
+ await applyPlan(plan, token, global);
489
+ });
490
+ configCommand
491
+ .command('show')
492
+ .description('Show the current configuration without revealing secrets')
493
+ .action(async (_options, command) => {
494
+ const global = globals(command);
495
+ const home = resolveHome(global.home);
496
+ const config = readSynomemConfig(global.home, env);
497
+ const backend = config?.backend ?? { kind: 'local' };
498
+ const credentialSource = env.SYNOMEM_ACCESS_TOKEN
499
+ ? 'environment (SYNOMEM_ACCESS_TOKEN)'
500
+ : existsSync(join(home, 'credentials', 'installation.json'))
501
+ ? 'restricted file'
502
+ : 'platform credential store or none';
503
+ output(io, global.json,
504
+ // Never the secret itself, only where it comes from.
505
+ { backend, home, credentialSource, stores: credentialStoreChoices().map((c) => c.value) }, [
506
+ `Backend: ${backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
507
+ `Home: ${home}`,
508
+ ...(backend.kind === 'remote'
509
+ ? [`Service: ${backend.baseUrl}`, `Workspace: ${backend.workspaceId}`]
510
+ : []),
511
+ `Credential: ${credentialSource}`,
512
+ ].join('\n'));
513
+ });
514
+ program
515
+ .command('reset')
516
+ .description('Remove Synomem configuration, database and credentials')
517
+ // Integrations are opt-in because they live in other tools' directories.
518
+ // Removing somebody's harness configuration as a side effect of resetting
519
+ // Synomem would be a surprise with no undo.
520
+ .option('--integrations', 'also remove installed skills and MCP registrations', false)
521
+ .option('--yes', 'apply the displayed plan', false)
522
+ .action(async (options, command) => {
523
+ const global = globals(command);
524
+ const home = resolveHome(global.home);
525
+ /*
526
+ * Every target is an exact path, listed before anything is touched. No
527
+ * recursive delete is ever derived from a variable that might be empty:
528
+ * a reset that computes `rm -rf $HOME/` from an unset home is the
529
+ * failure this shape exists to make impossible.
530
+ */
531
+ const targets = [
532
+ join(home, 'config.json'),
533
+ join(home, 'synomem.sqlite3'),
534
+ join(home, 'synomem.sqlite3-wal'),
535
+ join(home, 'synomem.sqlite3-shm'),
536
+ join(home, 'credentials', 'installation.json'),
537
+ ].filter((path) => existsSync(path));
538
+ const skillPlan = options.integrations ? uninstallSkill({ apply: false }) : undefined;
539
+ const skillTargets = skillPlan?.locations
540
+ // Installed Synomem-owned copies only; an unowned directory at
541
+ // the same path is not ours to remove.
542
+ .filter((location) => location.state === 'current' || location.state === 'stale')
543
+ .map((location) => location.target) ?? [];
544
+ if (!options.yes) {
545
+ output(io, global.json, { targets, skillTargets, applied: false }, [
546
+ 'This will remove:',
547
+ ...(targets.length ? targets.map((path) => ` ${path}`) : [' (nothing found)']),
548
+ ...(skillTargets.length ? ['', 'And these Synomem-owned skills:'] : []),
549
+ ...skillTargets.map((path) => ` ${path}`),
550
+ '',
551
+ ...(options.integrations
552
+ ? []
553
+ : ['Installed skills and MCP registrations are left alone.', '']),
554
+ 'Run with --yes to continue.',
555
+ ].join('\n'));
556
+ return;
557
+ }
558
+ const removed = [];
559
+ for (const path of targets) {
560
+ rmSync(path, { force: true });
561
+ removed.push(path);
562
+ }
563
+ // Only ownership-stamped Synomem skills are removed, which uninstall
564
+ // already enforces — an unowned directory at the same path is left.
565
+ const skillResult = options.integrations ? uninstallSkill({ apply: true }) : undefined;
566
+ output(io, global.json, { removed, skills: skillResult?.locations ?? [] }, [
567
+ `Removed ${removed.length} file(s).`,
568
+ ...(skillResult
569
+ ? [`Skill locations processed: ${skillResult.locations.length}.`]
570
+ : ['Installed skills and MCP registrations were left alone.']),
571
+ ].join('\n'));
572
+ });
295
573
  const backendCommand = program
296
574
  .command('backend')
297
575
  .description('Inspect or select the canonical backend');
@@ -311,21 +589,103 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
311
589
  .command('use')
312
590
  .description('Select local or remote canonical state')
313
591
  .argument('<kind>', 'local or remote')
314
- .option('--url <url>', 'remote HTTPS origin')
592
+ // --url is for development and private deployments. It stays out of the
593
+ // README, the public docs and the packaged skill: public onboarding must
594
+ // never ask for a service address, because a person has no way to tell a
595
+ // real one from a phished one.
596
+ .option('--url <url>', 'internal: alternate HTTPS origin')
315
597
  .option('--workspace <id>', 'remote workspace ID')
316
598
  .action((kind, options, command) => {
317
599
  const global = globals(command);
318
600
  if (kind !== 'local' && kind !== 'remote') {
319
601
  throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
320
602
  }
321
- if (kind === 'remote' && (!options.url || !options.workspace)) {
322
- throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --url and --workspace.');
603
+ if (kind === 'remote' && !options.workspace) {
604
+ throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --workspace.');
323
605
  }
324
606
  const config = writeSynomemBackend(kind === 'local'
325
607
  ? { kind: 'local' }
326
- : { kind: 'remote', baseUrl: options.url, workspaceId: options.workspace }, global.home);
608
+ : {
609
+ kind: 'remote',
610
+ baseUrl: options.url ?? cloudApiUrl(env),
611
+ workspaceId: options.workspace,
612
+ }, global.home);
327
613
  output(io, global.json, { backend: config.backend }, `Selected ${config.backend.kind} Synomem backend.`);
328
614
  });
615
+ /*
616
+ * `show` reads the config file; `status` proves the selection actually works.
617
+ *
618
+ * The two are deliberately separate. A person debugging a broken setup needs
619
+ * to know what is configured even when nothing can be reached, and a person
620
+ * checking that a setup is live needs a connection to have been made. One
621
+ * command doing both would make a printed workspace ID look like a reachable
622
+ * workspace.
623
+ */
624
+ backendCommand
625
+ .command('status')
626
+ .description('Connect to the selected backend and report what answered')
627
+ .action(async (_options, command) => {
628
+ const global = globals(command);
629
+ const config = readSynomemConfig(global.home);
630
+ if (!config) {
631
+ throw new SynomemError('CONFIG_INVALID', 'No Synomem home here yet. Run `synomem config init` first.');
632
+ }
633
+ const result = await withClient(global.home, defaultActor(env, 'system', 'cli'), async (client) => ({
634
+ info: await client.info(),
635
+ capabilities: await client.capabilities(),
636
+ diagnostics: (await client.doctor()).diagnostics.filter((item) => item.level === 'error' || item.level === 'warning'),
637
+ }));
638
+ const { info, capabilities, diagnostics } = result;
639
+ const where = info.backend === 'local'
640
+ ? `Home: ${info.home}\nDatabase: ${info.databasePath}`
641
+ : `URL: ${info.baseUrl}`;
642
+ const problems = diagnostics.length
643
+ ? diagnostics
644
+ .map((item) => `${item.level.toUpperCase()} ${item.code}: ${item.message}`)
645
+ .join('\n')
646
+ : 'No warnings or errors.';
647
+ const human = [
648
+ `Backend: ${info.backend} (reachable)`,
649
+ where,
650
+ `Workspace: ${capabilities.binding.workspaceId}`,
651
+ `Acting as: ${capabilities.binding.actor.kind} ${capabilities.binding.actor.id}`,
652
+ problems,
653
+ ].join('\n');
654
+ output(io, global.json, { reachable: true, info, capabilities, diagnostics }, human);
655
+ if (diagnostics.some((item) => item.level === 'error'))
656
+ cliExitCodes.set(program, 5);
657
+ });
658
+ const projectionCommand = program
659
+ .command('projection')
660
+ .description('Inspect the generated files Synomem derives from events');
661
+ projectionCommand
662
+ .command('status')
663
+ .description('Report whether the generated files match the canonical events')
664
+ .action(async (_options, command) => {
665
+ const global = globals(command);
666
+ const status = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => {
667
+ if (!client.projectionStatus) {
668
+ throw new SynomemError('INVALID_INPUT', 'The remote backend keeps no filesystem projections, so there is nothing to report.');
669
+ }
670
+ return client.projectionStatus();
671
+ });
672
+ const enabled = Object.entries(status.settings)
673
+ .filter(([, on]) => on)
674
+ .map(([name]) => name);
675
+ const lines = [
676
+ `Directory: ${status.directory ?? '(none)'}`,
677
+ `Enabled: ${enabled.length ? enabled.join(', ') : 'none'}`,
678
+ `Last rebuilt: ${status.lastRebuiltAt ?? 'never'}`,
679
+ status.current
680
+ ? `Current: ${status.counts.manifest} generated file(s) match the events.`
681
+ : `Stale: ${status.counts.missing} missing, ${status.counts.unexpected} no longer expected. Run \`synomem rebuild\`.`,
682
+ ];
683
+ for (const path of status.missing)
684
+ lines.push(` missing ${path}`);
685
+ for (const path of status.unexpected)
686
+ lines.push(` unexpected ${path}`);
687
+ output(io, global.json, status, lines.join('\n'));
688
+ });
329
689
  const authCommand = program.command('auth').description('Inspect remote authentication');
330
690
  authCommand
331
691
  .command('status')
@@ -419,20 +779,65 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
419
779
  .command('agent')
420
780
  .description('Create and inspect stable agent identities');
421
781
  agentCommand
422
- .command('create <id>')
423
- .description('Create a stable agent profile')
782
+ .command('create <handle>')
783
+ .description('Create an agent. The canonical ID is generated, not chosen.')
424
784
  .requiredOption('--name <display-name>', 'display name')
425
- .option('--alias <id>', 'alias (repeatable)', collect, [])
785
+ .option('--alias <name>', 'alias (repeatable)', collect, [])
426
786
  .option('--description <text>')
427
- .action(async (id, options, command) => {
787
+ .action(async (handle, options, command) => {
428
788
  const global = globals(command);
429
789
  const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.create({
430
- id,
790
+ handle,
431
791
  displayName: options.name,
432
792
  ...(options.alias.length ? { aliases: options.alias } : {}),
433
793
  ...(options.description ? { description: options.description } : {}),
434
794
  }));
435
- output(io, global.json, profile, `Created ${profile.displayName} (${profile.id})`);
795
+ // Both are printed because both matter: the handle is what people type,
796
+ // the ID is what every event records and what MCP registration uses.
797
+ output(io, global.json, profile, `Created ${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}`);
798
+ });
799
+ const aliasCommand = agentCommand
800
+ .command('alias')
801
+ .description('Add or remove discovery aliases without replacing the set');
802
+ aliasCommand
803
+ .command('add <agent> <alias...>')
804
+ .description('Add aliases, keeping the ones already there')
805
+ .action(async (agent, aliases, _options, command) => {
806
+ const global = globals(command);
807
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.addAliases(agent, aliases));
808
+ output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
809
+ });
810
+ aliasCommand
811
+ .command('remove <agent> <alias...>')
812
+ .description('Remove aliases, keeping the rest')
813
+ .action(async (agent, aliases, _options, command) => {
814
+ const global = globals(command);
815
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.removeAliases(agent, aliases));
816
+ output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
817
+ });
818
+ agentCommand
819
+ .command('rename <agent> <handle>')
820
+ .description('Change an agent handle. Its canonical ID never changes.')
821
+ .action(async (agent, handle, _options, command) => {
822
+ const global = globals(command);
823
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.update(agent, { handle }));
824
+ output(io, global.json, profile, `Handle: ${profile.handle}\nAgent ID: ${profile.id} (unchanged)`);
825
+ });
826
+ agentCommand
827
+ .command('archive <agent>')
828
+ .description('Stop an agent acting, keeping its records and history')
829
+ .action(async (agent, _options, command) => {
830
+ const global = globals(command);
831
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.archive(agent));
832
+ output(io, global.json, profile, `Archived ${profile.handle} (${profile.id})`);
833
+ });
834
+ agentCommand
835
+ .command('restore <agent>')
836
+ .description('Let an archived agent act again')
837
+ .action(async (agent, _options, command) => {
838
+ const global = globals(command);
839
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.restore(agent));
840
+ output(io, global.json, profile, `Restored ${profile.handle} (${profile.id})`);
436
841
  });
437
842
  const skillCommand = program
438
843
  .command('skill')
@@ -528,7 +933,10 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
528
933
  const agents = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.list());
529
934
  const human = agents.length
530
935
  ? agents
531
- .map((profile) => `${profile.id} ${profile.displayName}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}`)
936
+ .map((profile) =>
937
+ // Handle first: it is what people type. The canonical ID
938
+ // follows because MCP registration needs it.
939
+ `${profile.handle} ${profile.displayName}${profile.status === 'archived' ? ' [archived]' : ''}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}\n ${profile.id}`)
532
940
  .join('\n')
533
941
  : 'No agents configured.';
534
942
  output(io, global.json, { agents }, human);
@@ -539,7 +947,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
539
947
  .action(async (id, _options, command) => {
540
948
  const global = globals(command);
541
949
  const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.get(id));
542
- output(io, global.json, profile, `${profile.displayName} (${profile.id})\n${profile.description ?? 'No description.'}`);
950
+ output(io, global.json, profile, `${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}\nStatus: ${profile.status}\n\n${profile.description ?? 'No description.'}`);
543
951
  });
544
952
  agentCommand
545
953
  .command('update <id>')
@@ -615,18 +1023,36 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
615
1023
  }));
616
1024
  output(io, global.json, binding, `Bound ${binding.agentId} to ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} (${binding.id})`);
617
1025
  });
1026
+ /*
1027
+ * With no agent named this answers the question people actually arrive with:
1028
+ * "where is any of my stuff running?". Naming an agent narrows it. Requiring
1029
+ * the agent, as this once did, means you must already know the answer to the
1030
+ * question you came to ask.
1031
+ */
618
1032
  runtimeCommand
619
- .command('list <agent>')
620
- .description('List an agent runtime bindings')
1033
+ .command('list [agent]')
1034
+ .description('List runtime bindings for one agent, or for every agent')
621
1035
  .action(async (agent, _options, command) => {
622
1036
  const global = globals(command);
623
- const bindings = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.bindings(agent));
624
- const human = bindings.length
625
- ? bindings
626
- .map((binding) => `${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`)
1037
+ const result = await withClient(global.home, defaultActor(env, 'system', 'cli'), async (client) => {
1038
+ if (agent) {
1039
+ const profile = await client.agents.get(agent);
1040
+ return [{ profile, runtimeBindings: await client.agents.bindings(agent) }];
1041
+ }
1042
+ return (await client.agents.directory()).filter((entry) => entry.runtimeBindings.length > 0);
1043
+ });
1044
+ const describe = (binding) => ` ${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`;
1045
+ const human = result.length
1046
+ ? result
1047
+ .map((entry) => [
1048
+ `${entry.profile.handle} (${entry.profile.id})`,
1049
+ ...entry.runtimeBindings.map(describe),
1050
+ ].join('\n'))
627
1051
  .join('\n')
628
- : 'No runtime bindings.';
629
- output(io, global.json, { bindings }, human);
1052
+ : agent
1053
+ ? 'No runtime bindings.'
1054
+ : 'No agent in this workspace has a runtime binding.';
1055
+ output(io, global.json, { agents: result }, human);
630
1056
  });
631
1057
  runtimeCommand
632
1058
  .command('unbind <binding-id>')
@@ -1240,12 +1666,13 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1240
1666
  throw new SynomemError('INVALID_INPUT', 'Generated WINS.md files are available only with the local backend.');
1241
1667
  }
1242
1668
  const capabilities = await client.capabilities();
1243
- const path = join(info.home, profile.id, 'WINS.md');
1669
+ // Projections are written under the handle, since they exist to be read.
1670
+ const path = join(info.home, profile.handle, 'WINS.md');
1244
1671
  if (!existsSync(path)) {
1245
1672
  const hint = capabilities.projections.writeWinsMarkdown
1246
1673
  ? 'Run `synomem rebuild` to generate it.'
1247
1674
  : 'Enable projection.writeWinsMarkdown and run `synomem rebuild`.';
1248
- throw new SynomemError('INVALID_INPUT', `No generated WINS.md exists for ${profile.id}. ${hint}`);
1675
+ throw new SynomemError('INVALID_INPUT', `No generated WINS.md exists for ${profile.handle}. ${hint}`);
1249
1676
  }
1250
1677
  return { profile, path, content: readFileSync(path, 'utf8') };
1251
1678
  });