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/src/cli.ts CHANGED
@@ -1,10 +1,28 @@
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 {
11
+ assertInteractive,
12
+ confirmPlan,
13
+ credentialFingerprint,
14
+ credentialStoreChoices,
15
+ environmentInstructions,
16
+ readAccessToken,
17
+ runConfigWizard,
18
+ writeCredentialFile,
19
+ type AuthChoice,
20
+ type BackendChoice,
21
+ type ConfigPlan,
22
+ type CredentialStoreChoice,
23
+ } from './configure.js';
24
+ import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
25
+ import { defaultPromptIo, type PromptIo } from './prompt.js';
8
26
  import { credentialReference, OsCredentialStore, type CredentialStore } from './credentials.js';
9
27
  import { asSynomemError, SynomemError, type SynomemErrorCode } from './errors.js';
10
28
  import { atomicWriteFile } from './fs-utils.js';
@@ -28,6 +46,7 @@ import {
28
46
  } from './skill-install.js';
29
47
  import type {
30
48
  ActorIdentity,
49
+ AgentRuntimeBinding,
31
50
  EvidenceReference,
32
51
  KudosListInput,
33
52
  KudosRecord,
@@ -45,6 +64,8 @@ export interface CliIo {
45
64
  }
46
65
 
47
66
  export interface CliDependencies {
67
+ /** Injected so the wizard can be driven by a test without a terminal. */
68
+ promptIo?: PromptIo;
48
69
  credentialStore?: CredentialStore;
49
70
  oauthLogin?: (options: OAuthLoginOptions) => Promise<void>;
50
71
  env?: NodeJS.ProcessEnv;
@@ -55,6 +76,14 @@ export interface CliDependencies {
55
76
  reference: string;
56
77
  credentialStore: CredentialStore;
57
78
  }) => Promise<void>;
79
+ /*
80
+ * Injected so setup can be tested without a network. The default asks the
81
+ * service which workspace an access key is bound to.
82
+ */
83
+ discoverBoundWorkspace?: (options: {
84
+ baseUrl: string;
85
+ accessToken: string;
86
+ }) => Promise<{ workspaceId: string }>;
58
87
  createImportBundle?: (home: string) => Promise<ImportBundle>;
59
88
  remoteImport?: (options: {
60
89
  baseUrl: string;
@@ -281,6 +310,8 @@ export function createCli(
281
310
  const env = dependencies.env ?? process.env;
282
311
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
283
312
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
313
+ const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
314
+ const promptIo = dependencies.promptIo ?? defaultPromptIo();
284
315
  const verifyRemoteCredential =
285
316
  dependencies.verifyRemoteCredential ??
286
317
  (async (options) => {
@@ -314,12 +345,57 @@ export function createCli(
314
345
  'Local-first communication, memory, recognition, and task infrastructure for agents',
315
346
  )
316
347
  .version(packageVersion())
317
- .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.agents)')
348
+ .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.synomem)')
318
349
  .option('--json', 'emit stable machine-readable JSON', false)
319
350
  .showSuggestionAfterError()
320
351
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
321
352
 
322
353
  const remoteCommand = program.command('remote').description('Administer a remote workspace');
354
+
355
+ /*
356
+ * The browser counterpart to an access key naming its own workspace.
357
+ *
358
+ * A signed-in account may reach several organizations, each with several
359
+ * workspaces, so there is a genuine choice to make -- and no way to make it
360
+ * without seeing the list. Printing the IDs alongside the names is the point:
361
+ * the ID is what `backend use remote --workspace` takes.
362
+ */
363
+ remoteCommand
364
+ .command('workspaces')
365
+ .description('List the organizations and workspaces this credential can reach')
366
+ .option('--url <url>', 'internal: alternate HTTPS origin')
367
+ .action(async (options: { url?: string }, command: Command) => {
368
+ const global = globals(command);
369
+ const config = readSynomemConfig(global.home, env);
370
+ const baseUrl =
371
+ options.url ??
372
+ (config?.backend.kind === 'remote' ? config.backend.baseUrl : cloudApiUrl(env));
373
+ const accessToken = env.SYNOMEM_ACCESS_TOKEN;
374
+ if (!accessToken) {
375
+ throw new SynomemError(
376
+ 'AUTH_REQUIRED',
377
+ 'Set SYNOMEM_ACCESS_TOKEN, or run `synomem auth login` first.',
378
+ );
379
+ }
380
+ const organizations = await discoverOrganizations({ baseUrl, accessToken });
381
+ const choices = workspaceChoices(organizations);
382
+ const human = organizations.length
383
+ ? organizations
384
+ .map((organization) =>
385
+ [
386
+ `${organization.displayName} (${organization.slug}) — ${organization.role}`,
387
+ ...(organization.workspaces.length
388
+ ? organization.workspaces.map(
389
+ (workspace) => ` ${workspace.id} ${workspace.displayName}`,
390
+ )
391
+ : [' (no workspaces yet)']),
392
+ ].join('\n'),
393
+ )
394
+ .join('\n')
395
+ : 'This account belongs to no organizations yet.';
396
+ output(io, global.json, { organizations, choices }, human);
397
+ });
398
+
323
399
  remoteCommand
324
400
  .command('import')
325
401
  .description('Preview or confirm a one-way import from a local Synomem home')
@@ -416,6 +492,307 @@ export function createCli(
416
492
  });
417
493
  });
418
494
 
495
+ /*
496
+ * `synomem config` is the canonical entry point. `configure` and `setup` are
497
+ * accepted because people reach for them, and a setup program that rejects
498
+ * the word somebody guessed is needlessly unhelpful.
499
+ */
500
+ const configCommand = program
501
+ .command('config')
502
+ .aliases(['configure', 'setup'])
503
+ .description('Set up Synomem, interactively or deterministically');
504
+
505
+ const applyPlan = async (
506
+ plan: ConfigPlan,
507
+ token: string | undefined,
508
+ global: { home?: string; json: boolean },
509
+ ): Promise<void> => {
510
+ const home = plan.home;
511
+ const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
512
+
513
+ /*
514
+ * The workspace is discovered, not typed.
515
+ *
516
+ * An installation access key is bound to exactly one workspace, so the
517
+ * service can be asked which one rather than the person. An explicit
518
+ * --workspace still wins, because automation should not depend on a
519
+ * network round trip to configure a machine.
520
+ */
521
+ let workspaceId = plan.workspaceId;
522
+ if (plan.backend === 'remote' && !workspaceId && token) {
523
+ const bound = await discoverWorkspace({ baseUrl: serviceUrl, accessToken: token });
524
+ workspaceId = bound.workspaceId;
525
+ io.stdout(`Access key is bound to workspace ${workspaceId}.\n`);
526
+ }
527
+ if (plan.backend === 'remote' && !workspaceId) {
528
+ /*
529
+ * Signing in through a browser needs an actor identity and a client ID,
530
+ * which is `synomem auth login`'s job. Rather than write a remote
531
+ * backend with no workspace -- a configuration that fails on its first
532
+ * real use -- say exactly what remains.
533
+ */
534
+ output(
535
+ io,
536
+ global.json,
537
+ { applied: false, pending: 'sign-in', home, serviceUrl },
538
+ [
539
+ '',
540
+ 'Nothing was configured yet: signing in through a browser is a separate step.',
541
+ '',
542
+ 'Run, with the actor this machine acts as:',
543
+ '',
544
+ ' synomem auth login --actor-id <agent> --client-id <client>',
545
+ '',
546
+ 'Then select the workspace it reports:',
547
+ '',
548
+ ' synomem backend use remote --workspace <workspace-id>',
549
+ ].join('\n'),
550
+ );
551
+ return;
552
+ }
553
+
554
+ const config = writeSynomemBackend(
555
+ plan.backend === 'local'
556
+ ? { kind: 'local' }
557
+ : { kind: 'remote', baseUrl: serviceUrl, workspaceId: workspaceId! },
558
+ home,
559
+ );
560
+
561
+ let credentialLocation: string | undefined;
562
+ if (token) {
563
+ if (plan.credentialStore === 'environment') {
564
+ io.stdout(`${environmentInstructions(token)}\n`);
565
+ credentialLocation = 'environment';
566
+ } else if (plan.credentialStore === 'file') {
567
+ credentialLocation = writeCredentialFile(home, token);
568
+ } else {
569
+ // The platform store is the default, and a failure falls back to the
570
+ // restricted file rather than leaving the credential nowhere.
571
+ try {
572
+ await credentialStore.set(`synomem:${workspaceId}`, {
573
+ kind: 'installation-key',
574
+ accessToken: token,
575
+ });
576
+ credentialLocation = 'platform credential store';
577
+ } catch {
578
+ credentialLocation = writeCredentialFile(home, token);
579
+ }
580
+ }
581
+ }
582
+
583
+ // Diagnostics run before success is claimed: a configuration that cannot
584
+ // open its own database is not a finished setup.
585
+ const diagnostics = await withClient(home, defaultActor(env, 'system', 'cli'), (client) =>
586
+ client.doctor(),
587
+ );
588
+
589
+ output(
590
+ io,
591
+ global.json,
592
+ {
593
+ backend: config.backend,
594
+ home,
595
+ ...(credentialLocation ? { credentialSource: credentialLocation } : {}),
596
+ ...(token ? { credential: credentialFingerprint(token) } : {}),
597
+ healthy: diagnostics.healthy,
598
+ },
599
+ [
600
+ '',
601
+ 'Synomem is ready.',
602
+ '',
603
+ ` Backend: ${config.backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
604
+ ` Home: ${home}`,
605
+ ...(config.backend.kind === 'remote'
606
+ ? [` Service: ${config.backend.baseUrl}`, ` Workspace: ${config.backend.workspaceId}`]
607
+ : []),
608
+ ...(credentialLocation ? [` Credential: ${credentialLocation}`] : []),
609
+ ` Database: ${diagnostics.healthy ? 'Healthy' : 'Needs attention — run synomem doctor'}`,
610
+ ].join('\n'),
611
+ );
612
+ };
613
+
614
+ configCommand.action(async (_options, command: Command) => {
615
+ const global = globals(command);
616
+ assertInteractive(promptIo);
617
+ const plan = await runConfigWizard(promptIo, {
618
+ ...(global.home ? { home: global.home } : {}),
619
+ env,
620
+ });
621
+ const token = plan.auth === 'access-key' ? await readAccessToken(promptIo) : undefined;
622
+ if (!(await confirmPlan(promptIo, plan))) {
623
+ output(io, global.json, { applied: false }, 'Nothing was changed.');
624
+ return;
625
+ }
626
+ await applyPlan(plan, token, global);
627
+ });
628
+
629
+ configCommand
630
+ .command('init')
631
+ .description('Configure Synomem without prompting')
632
+ .option('--backend <kind>', 'local or remote')
633
+ .option('--auth <method>', 'browser or access-key')
634
+ .option('--workspace <id>', 'remote workspace ID')
635
+ .option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
636
+ // The token is read from stdin, never taken as an argument: an argument is
637
+ // kept by the shell history and visible in the process list.
638
+ .option('--access-token-stdin', 'read the installation access key from stdin', false)
639
+ .option('--yes', 'apply without confirming', false)
640
+ .action(
641
+ async (
642
+ options: {
643
+ backend?: string;
644
+ auth?: string;
645
+ workspace?: string;
646
+ credentialStore: string;
647
+ accessTokenStdin: boolean;
648
+ yes: boolean;
649
+ },
650
+ command: Command,
651
+ ) => {
652
+ const global = globals(command);
653
+ if (options.backend !== 'local' && options.backend !== 'remote') {
654
+ throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
655
+ }
656
+ const backend: BackendChoice = options.backend;
657
+ // An access key names its own workspace, so --workspace is only
658
+ // required when there is no key to ask.
659
+ if (backend === 'remote' && !options.workspace && !options.accessTokenStdin) {
660
+ throw new SynomemError(
661
+ 'INVALID_INPUT',
662
+ 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.',
663
+ );
664
+ }
665
+ const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
666
+ if (backend === 'remote' && options.auth === 'access-key' && !token) {
667
+ throw new SynomemError(
668
+ 'INVALID_INPUT',
669
+ 'Access-key setup requires --access-token-stdin so the key is not passed as an argument.',
670
+ );
671
+ }
672
+ const plan: ConfigPlan = {
673
+ backend,
674
+ home: resolveHome(global.home),
675
+ ...(backend === 'remote'
676
+ ? {
677
+ serviceUrl: cloudApiUrl(env),
678
+ auth: (options.auth as AuthChoice | undefined) ?? 'access-key',
679
+ workspaceId: options.workspace,
680
+ credentialStore: options.credentialStore as CredentialStoreChoice,
681
+ }
682
+ : {}),
683
+ };
684
+ if (!options.yes) {
685
+ throw new SynomemError('INVALID_INPUT', 'Re-run with --yes to apply this configuration.');
686
+ }
687
+ await applyPlan(plan, token, global);
688
+ },
689
+ );
690
+
691
+ configCommand
692
+ .command('show')
693
+ .description('Show the current configuration without revealing secrets')
694
+ .action(async (_options, command: Command) => {
695
+ const global = globals(command);
696
+ const home = resolveHome(global.home);
697
+ const config = readSynomemConfig(global.home, env);
698
+ const backend = config?.backend ?? { kind: 'local' as const };
699
+ const credentialSource = env.SYNOMEM_ACCESS_TOKEN
700
+ ? 'environment (SYNOMEM_ACCESS_TOKEN)'
701
+ : existsSync(join(home, 'credentials', 'installation.json'))
702
+ ? 'restricted file'
703
+ : 'platform credential store or none';
704
+ output(
705
+ io,
706
+ global.json,
707
+ // Never the secret itself, only where it comes from.
708
+ { backend, home, credentialSource, stores: credentialStoreChoices().map((c) => c.value) },
709
+ [
710
+ `Backend: ${backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
711
+ `Home: ${home}`,
712
+ ...(backend.kind === 'remote'
713
+ ? [`Service: ${backend.baseUrl}`, `Workspace: ${backend.workspaceId}`]
714
+ : []),
715
+ `Credential: ${credentialSource}`,
716
+ ].join('\n'),
717
+ );
718
+ });
719
+
720
+ program
721
+ .command('reset')
722
+ .description('Remove Synomem configuration, database and credentials')
723
+ // Integrations are opt-in because they live in other tools' directories.
724
+ // Removing somebody's harness configuration as a side effect of resetting
725
+ // Synomem would be a surprise with no undo.
726
+ .option('--integrations', 'also remove installed skills and MCP registrations', false)
727
+ .option('--yes', 'apply the displayed plan', false)
728
+ .action(async (options: { integrations: boolean; yes: boolean }, command: Command) => {
729
+ const global = globals(command);
730
+ const home = resolveHome(global.home);
731
+
732
+ /*
733
+ * Every target is an exact path, listed before anything is touched. No
734
+ * recursive delete is ever derived from a variable that might be empty:
735
+ * a reset that computes `rm -rf $HOME/` from an unset home is the
736
+ * failure this shape exists to make impossible.
737
+ */
738
+ const targets = [
739
+ join(home, 'config.json'),
740
+ join(home, 'synomem.sqlite3'),
741
+ join(home, 'synomem.sqlite3-wal'),
742
+ join(home, 'synomem.sqlite3-shm'),
743
+ join(home, 'credentials', 'installation.json'),
744
+ ].filter((path) => existsSync(path));
745
+
746
+ const skillPlan = options.integrations ? uninstallSkill({ apply: false }) : undefined;
747
+ const skillTargets =
748
+ skillPlan?.locations
749
+ // Installed Synomem-owned copies only; an unowned directory at
750
+ // the same path is not ours to remove.
751
+ .filter((location) => location.state === 'current' || location.state === 'stale')
752
+ .map((location) => location.target) ?? [];
753
+
754
+ if (!options.yes) {
755
+ output(
756
+ io,
757
+ global.json,
758
+ { targets, skillTargets, applied: false },
759
+ [
760
+ 'This will remove:',
761
+ ...(targets.length ? targets.map((path) => ` ${path}`) : [' (nothing found)']),
762
+ ...(skillTargets.length ? ['', 'And these Synomem-owned skills:'] : []),
763
+ ...skillTargets.map((path) => ` ${path}`),
764
+ '',
765
+ ...(options.integrations
766
+ ? []
767
+ : ['Installed skills and MCP registrations are left alone.', '']),
768
+ 'Run with --yes to continue.',
769
+ ].join('\n'),
770
+ );
771
+ return;
772
+ }
773
+
774
+ const removed: string[] = [];
775
+ for (const path of targets) {
776
+ rmSync(path, { force: true });
777
+ removed.push(path);
778
+ }
779
+ // Only ownership-stamped Synomem skills are removed, which uninstall
780
+ // already enforces — an unowned directory at the same path is left.
781
+ const skillResult = options.integrations ? uninstallSkill({ apply: true }) : undefined;
782
+
783
+ output(
784
+ io,
785
+ global.json,
786
+ { removed, skills: skillResult?.locations ?? [] },
787
+ [
788
+ `Removed ${removed.length} file(s).`,
789
+ ...(skillResult
790
+ ? [`Skill locations processed: ${skillResult.locations.length}.`]
791
+ : ['Installed skills and MCP registrations were left alone.']),
792
+ ].join('\n'),
793
+ );
794
+ });
795
+
419
796
  const backendCommand = program
420
797
  .command('backend')
421
798
  .description('Inspect or select the canonical backend');
@@ -436,23 +813,28 @@ export function createCli(
436
813
  .command('use')
437
814
  .description('Select local or remote canonical state')
438
815
  .argument('<kind>', 'local or remote')
439
- .option('--url <url>', 'remote HTTPS origin')
816
+ // --url is for development and private deployments. It stays out of the
817
+ // README, the public docs and the packaged skill: public onboarding must
818
+ // never ask for a service address, because a person has no way to tell a
819
+ // real one from a phished one.
820
+ .option('--url <url>', 'internal: alternate HTTPS origin')
440
821
  .option('--workspace <id>', 'remote workspace ID')
441
822
  .action((kind: string, options: { url?: string; workspace?: string }, command: Command) => {
442
823
  const global = globals(command);
443
824
  if (kind !== 'local' && kind !== 'remote') {
444
825
  throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
445
826
  }
446
- if (kind === 'remote' && (!options.url || !options.workspace)) {
447
- throw new SynomemError(
448
- 'INVALID_INPUT',
449
- 'Remote backend selection requires --url and --workspace.',
450
- );
827
+ if (kind === 'remote' && !options.workspace) {
828
+ throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --workspace.');
451
829
  }
452
830
  const config = writeSynomemBackend(
453
831
  kind === 'local'
454
832
  ? { kind: 'local' }
455
- : { kind: 'remote', baseUrl: options.url!, workspaceId: options.workspace! },
833
+ : {
834
+ kind: 'remote',
835
+ baseUrl: options.url ?? cloudApiUrl(env),
836
+ workspaceId: options.workspace!,
837
+ },
456
838
  global.home,
457
839
  );
458
840
  output(
@@ -463,6 +845,92 @@ export function createCli(
463
845
  );
464
846
  });
465
847
 
848
+ /*
849
+ * `show` reads the config file; `status` proves the selection actually works.
850
+ *
851
+ * The two are deliberately separate. A person debugging a broken setup needs
852
+ * to know what is configured even when nothing can be reached, and a person
853
+ * checking that a setup is live needs a connection to have been made. One
854
+ * command doing both would make a printed workspace ID look like a reachable
855
+ * workspace.
856
+ */
857
+ backendCommand
858
+ .command('status')
859
+ .description('Connect to the selected backend and report what answered')
860
+ .action(async (_options, command: Command) => {
861
+ const global = globals(command);
862
+ const config = readSynomemConfig(global.home);
863
+ if (!config) {
864
+ throw new SynomemError(
865
+ 'CONFIG_INVALID',
866
+ 'No Synomem home here yet. Run `synomem config init` first.',
867
+ );
868
+ }
869
+ const result = await withClient(
870
+ global.home,
871
+ defaultActor(env, 'system', 'cli'),
872
+ async (client) => ({
873
+ info: await client.info(),
874
+ capabilities: await client.capabilities(),
875
+ diagnostics: (await client.doctor()).diagnostics.filter(
876
+ (item) => item.level === 'error' || item.level === 'warning',
877
+ ),
878
+ }),
879
+ );
880
+ const { info, capabilities, diagnostics } = result;
881
+ const where =
882
+ info.backend === 'local'
883
+ ? `Home: ${info.home}\nDatabase: ${info.databasePath}`
884
+ : `URL: ${info.baseUrl}`;
885
+ const problems = diagnostics.length
886
+ ? diagnostics
887
+ .map((item) => `${item.level.toUpperCase()} ${item.code}: ${item.message}`)
888
+ .join('\n')
889
+ : 'No warnings or errors.';
890
+ const human = [
891
+ `Backend: ${info.backend} (reachable)`,
892
+ where,
893
+ `Workspace: ${capabilities.binding.workspaceId}`,
894
+ `Acting as: ${capabilities.binding.actor.kind} ${capabilities.binding.actor.id}`,
895
+ problems,
896
+ ].join('\n');
897
+ output(io, global.json, { reachable: true, info, capabilities, diagnostics }, human);
898
+ if (diagnostics.some((item) => item.level === 'error')) cliExitCodes.set(program, 5);
899
+ });
900
+
901
+ const projectionCommand = program
902
+ .command('projection')
903
+ .description('Inspect the generated files Synomem derives from events');
904
+ projectionCommand
905
+ .command('status')
906
+ .description('Report whether the generated files match the canonical events')
907
+ .action(async (_options, command: Command) => {
908
+ const global = globals(command);
909
+ const status = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => {
910
+ if (!client.projectionStatus) {
911
+ throw new SynomemError(
912
+ 'INVALID_INPUT',
913
+ 'The remote backend keeps no filesystem projections, so there is nothing to report.',
914
+ );
915
+ }
916
+ return client.projectionStatus();
917
+ });
918
+ const enabled = Object.entries(status.settings)
919
+ .filter(([, on]) => on)
920
+ .map(([name]) => name);
921
+ const lines = [
922
+ `Directory: ${status.directory ?? '(none)'}`,
923
+ `Enabled: ${enabled.length ? enabled.join(', ') : 'none'}`,
924
+ `Last rebuilt: ${status.lastRebuiltAt ?? 'never'}`,
925
+ status.current
926
+ ? `Current: ${status.counts.manifest} generated file(s) match the events.`
927
+ : `Stale: ${status.counts.missing} missing, ${status.counts.unexpected} no longer expected. Run \`synomem rebuild\`.`,
928
+ ];
929
+ for (const path of status.missing) lines.push(` missing ${path}`);
930
+ for (const path of status.unexpected) lines.push(` unexpected ${path}`);
931
+ output(io, global.json, status, lines.join('\n'));
932
+ });
933
+
466
934
  const authCommand = program.command('auth').description('Inspect remote authentication');
467
935
  authCommand
468
936
  .command('status')
@@ -599,14 +1067,14 @@ export function createCli(
599
1067
  .command('agent')
600
1068
  .description('Create and inspect stable agent identities');
601
1069
  agentCommand
602
- .command('create <id>')
603
- .description('Create a stable agent profile')
1070
+ .command('create <handle>')
1071
+ .description('Create an agent. The canonical ID is generated, not chosen.')
604
1072
  .requiredOption('--name <display-name>', 'display name')
605
- .option('--alias <id>', 'alias (repeatable)', collect, [])
1073
+ .option('--alias <name>', 'alias (repeatable)', collect, [])
606
1074
  .option('--description <text>')
607
1075
  .action(
608
1076
  async (
609
- id: string,
1077
+ handle: string,
610
1078
  options: { name: string; alias: string[]; description?: string },
611
1079
  command: Command,
612
1080
  ) => {
@@ -616,16 +1084,87 @@ export function createCli(
616
1084
  defaultActor(env, 'system', 'cli'),
617
1085
  (client) =>
618
1086
  client.agents.create({
619
- id,
1087
+ handle,
620
1088
  displayName: options.name,
621
1089
  ...(options.alias.length ? { aliases: options.alias } : {}),
622
1090
  ...(options.description ? { description: options.description } : {}),
623
1091
  }),
624
1092
  );
625
- output(io, global.json, profile, `Created ${profile.displayName} (${profile.id})`);
1093
+ // Both are printed because both matter: the handle is what people type,
1094
+ // the ID is what every event records and what MCP registration uses.
1095
+ output(
1096
+ io,
1097
+ global.json,
1098
+ profile,
1099
+ `Created ${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}`,
1100
+ );
626
1101
  },
627
1102
  );
628
1103
 
1104
+ const aliasCommand = agentCommand
1105
+ .command('alias')
1106
+ .description('Add or remove discovery aliases without replacing the set');
1107
+
1108
+ aliasCommand
1109
+ .command('add <agent> <alias...>')
1110
+ .description('Add aliases, keeping the ones already there')
1111
+ .action(async (agent: string, aliases: string[], _options, command: Command) => {
1112
+ const global = globals(command);
1113
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
1114
+ client.agents.addAliases(agent, aliases),
1115
+ );
1116
+ output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
1117
+ });
1118
+
1119
+ aliasCommand
1120
+ .command('remove <agent> <alias...>')
1121
+ .description('Remove aliases, keeping the rest')
1122
+ .action(async (agent: string, aliases: string[], _options, command: Command) => {
1123
+ const global = globals(command);
1124
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
1125
+ client.agents.removeAliases(agent, aliases),
1126
+ );
1127
+ output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
1128
+ });
1129
+
1130
+ agentCommand
1131
+ .command('rename <agent> <handle>')
1132
+ .description('Change an agent handle. Its canonical ID never changes.')
1133
+ .action(async (agent: string, handle: string, _options, command: Command) => {
1134
+ const global = globals(command);
1135
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
1136
+ client.agents.update(agent, { handle }),
1137
+ );
1138
+ output(
1139
+ io,
1140
+ global.json,
1141
+ profile,
1142
+ `Handle: ${profile.handle}\nAgent ID: ${profile.id} (unchanged)`,
1143
+ );
1144
+ });
1145
+
1146
+ agentCommand
1147
+ .command('archive <agent>')
1148
+ .description('Stop an agent acting, keeping its records and history')
1149
+ .action(async (agent: string, _options, command: Command) => {
1150
+ const global = globals(command);
1151
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
1152
+ client.agents.archive(agent),
1153
+ );
1154
+ output(io, global.json, profile, `Archived ${profile.handle} (${profile.id})`);
1155
+ });
1156
+
1157
+ agentCommand
1158
+ .command('restore <agent>')
1159
+ .description('Let an archived agent act again')
1160
+ .action(async (agent: string, _options, command: Command) => {
1161
+ const global = globals(command);
1162
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
1163
+ client.agents.restore(agent),
1164
+ );
1165
+ output(io, global.json, profile, `Restored ${profile.handle} (${profile.id})`);
1166
+ });
1167
+
629
1168
  const skillCommand = program
630
1169
  .command('skill')
631
1170
  .description('Install and maintain the packaged agent skill');
@@ -750,7 +1289,11 @@ export function createCli(
750
1289
  ? agents
751
1290
  .map(
752
1291
  (profile) =>
753
- `${profile.id} ${profile.displayName}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}`,
1292
+ // Handle first: it is what people type. The canonical ID
1293
+ // follows because MCP registration needs it.
1294
+ `${profile.handle} ${profile.displayName}${
1295
+ profile.status === 'archived' ? ' [archived]' : ''
1296
+ }${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}\n ${profile.id}`,
754
1297
  )
755
1298
  .join('\n')
756
1299
  : 'No agents configured.';
@@ -769,7 +1312,7 @@ export function createCli(
769
1312
  io,
770
1313
  global.json,
771
1314
  profile,
772
- `${profile.displayName} (${profile.id})\n${profile.description ?? 'No description.'}`,
1315
+ `${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}\nStatus: ${profile.status}\n\n${profile.description ?? 'No description.'}`,
773
1316
  );
774
1317
  });
775
1318
 
@@ -888,23 +1431,45 @@ export function createCli(
888
1431
  },
889
1432
  );
890
1433
 
1434
+ /*
1435
+ * With no agent named this answers the question people actually arrive with:
1436
+ * "where is any of my stuff running?". Naming an agent narrows it. Requiring
1437
+ * the agent, as this once did, means you must already know the answer to the
1438
+ * question you came to ask.
1439
+ */
891
1440
  runtimeCommand
892
- .command('list <agent>')
893
- .description('List an agent runtime bindings')
894
- .action(async (agent: string, _options, command: Command) => {
1441
+ .command('list [agent]')
1442
+ .description('List runtime bindings for one agent, or for every agent')
1443
+ .action(async (agent: string | undefined, _options, command: Command) => {
895
1444
  const global = globals(command);
896
- const bindings = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
897
- client.agents.bindings(agent),
1445
+ const result = await withClient(
1446
+ global.home,
1447
+ defaultActor(env, 'system', 'cli'),
1448
+ async (client) => {
1449
+ if (agent) {
1450
+ const profile = await client.agents.get(agent);
1451
+ return [{ profile, runtimeBindings: await client.agents.bindings(agent) }];
1452
+ }
1453
+ return (await client.agents.directory()).filter(
1454
+ (entry) => entry.runtimeBindings.length > 0,
1455
+ );
1456
+ },
898
1457
  );
899
- const human = bindings.length
900
- ? bindings
901
- .map(
902
- (binding) =>
903
- `${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`,
1458
+ const describe = (binding: AgentRuntimeBinding) =>
1459
+ ` ${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`;
1460
+ const human = result.length
1461
+ ? result
1462
+ .map((entry) =>
1463
+ [
1464
+ `${entry.profile.handle} (${entry.profile.id})`,
1465
+ ...entry.runtimeBindings.map(describe),
1466
+ ].join('\n'),
904
1467
  )
905
1468
  .join('\n')
906
- : 'No runtime bindings.';
907
- output(io, global.json, { bindings }, human);
1469
+ : agent
1470
+ ? 'No runtime bindings.'
1471
+ : 'No agent in this workspace has a runtime binding.';
1472
+ output(io, global.json, { agents: result }, human);
908
1473
  });
909
1474
 
910
1475
  runtimeCommand
@@ -1983,14 +2548,15 @@ export function createCli(
1983
2548
  );
1984
2549
  }
1985
2550
  const capabilities = await client.capabilities();
1986
- const path = join(info.home, profile.id, 'WINS.md');
2551
+ // Projections are written under the handle, since they exist to be read.
2552
+ const path = join(info.home, profile.handle, 'WINS.md');
1987
2553
  if (!existsSync(path)) {
1988
2554
  const hint = capabilities.projections.writeWinsMarkdown
1989
2555
  ? 'Run `synomem rebuild` to generate it.'
1990
2556
  : 'Enable projection.writeWinsMarkdown and run `synomem rebuild`.';
1991
2557
  throw new SynomemError(
1992
2558
  'INVALID_INPUT',
1993
- `No generated WINS.md exists for ${profile.id}. ${hint}`,
2559
+ `No generated WINS.md exists for ${profile.handle}. ${hint}`,
1994
2560
  );
1995
2561
  }
1996
2562
  return { profile, path, content: readFileSync(path, 'utf8') };