synomem 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +55 -13
  3. package/dist/cli.d.ts +6 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +179 -16
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +2 -1
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +37 -1
  10. package/dist/client.js.map +1 -1
  11. package/dist/configure.d.ts.map +1 -1
  12. package/dist/configure.js +34 -15
  13. package/dist/configure.js.map +1 -1
  14. package/dist/credentials.d.ts.map +1 -1
  15. package/dist/credentials.js +9 -1
  16. package/dist/credentials.js.map +1 -1
  17. package/dist/discover.d.ts +48 -0
  18. package/dist/discover.d.ts.map +1 -0
  19. package/dist/discover.js +106 -0
  20. package/dist/discover.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/projections.d.ts.map +1 -1
  26. package/dist/projections.js +15 -7
  27. package/dist/projections.js.map +1 -1
  28. package/dist/service.d.ts +2 -1
  29. package/dist/service.d.ts.map +1 -1
  30. package/dist/storage.d.ts +5 -0
  31. package/dist/storage.d.ts.map +1 -1
  32. package/dist/storage.js +6 -0
  33. package/dist/storage.js.map +1 -1
  34. package/dist/types.d.ts +26 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/docs/cli.md +67 -3
  37. package/package.json +3 -3
  38. package/src/cli.ts +228 -20
  39. package/src/client.ts +41 -1
  40. package/src/configure.ts +33 -14
  41. package/src/credentials.ts +9 -1
  42. package/src/discover.ts +155 -0
  43. package/src/index.ts +2 -0
  44. package/src/projections.ts +17 -7
  45. package/src/service.ts +8 -0
  46. package/src/storage.ts +9 -0
  47. package/src/types.ts +21 -0
package/CHANGELOG.md CHANGED
@@ -3,7 +3,68 @@
3
3
  All notable changes will be documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases follow Semantic Versioning.
5
5
 
6
- ## [Unreleased]
6
+ ## [0.5.1] - 2026-09-07
7
+
8
+ ### Documentation
9
+
10
+ - The README and CLI reference were rewritten for the two backends. They still
11
+ described Synomem as running only on one machine, listed four record kinds
12
+ when there are six, and pointed at `synomem init` rather than `synomem
13
+ config`. The pasteable agent-setup prompt was stale in the same ways.
14
+
15
+ ### Fixed
16
+
17
+ - CI could not install the project at all. A dependency bump moved TypeScript to
18
+ 7, which is outside `typescript-eslint`'s peer range, so every `npm ci` failed
19
+ on resolution while local runs kept working against an older `node_modules`.
20
+ TypeScript is pinned to 6 until `typescript-eslint` supports 7.
21
+
22
+ ### Changed
23
+
24
+ - Commander 15.
25
+
26
+ ## [0.5.0] - 2026-09-07
27
+
28
+ ### Added
29
+
30
+ - **`synomem backend status`** — connects to the selected backend and reports
31
+ what answered. `backend show` still reads the configuration file and connects
32
+ to nothing; a person debugging a broken setup needs that, and a person
33
+ confirming a working one needs a connection to have been made.
34
+ - **`synomem projection status`** — whether the generated files match the
35
+ canonical events, when they were last rebuilt, and which ones drifted. The
36
+ comparison is against Synomem's own manifest, so a file somebody added to the
37
+ projection tree by hand is not reported as drift.
38
+ - **`synomem remote workspaces`** — the organizations and workspaces a
39
+ credential can reach, with the IDs `backend use remote --workspace` takes.
40
+ - `agent runtime list` with no agent named lists every agent that runs
41
+ anywhere. Requiring the agent meant already knowing the answer to the
42
+ question being asked.
43
+
44
+ ### Changed
45
+
46
+ - **Remote setup no longer asks for a workspace ID.** An installation access
47
+ key is bound to exactly one workspace, so the service is asked which one
48
+ rather than the person; `config init --backend remote --access-token-stdin`
49
+ needs no `--workspace`. Typing `ws-04psqx2rkt8ttft7a1t2z69r97` from memory
50
+ was never something a person could do.
51
+ - The Windows credential-store choice is the restricted file rather than
52
+ Credential Manager, which the credential layer does not implement. Offering a
53
+ store that cannot read its own credential back fails on first use, after
54
+ setup has already claimed the credential was safe.
55
+
56
+ ### Fixed
57
+
58
+ - `doctor` reported every workspace's projections as stale once any agent had a
59
+ generated ID: the expected-path list was built from canonical IDs while the
60
+ files, the manifest and the cleanup all used handles.
61
+ - `doctor`'s agent-directory symbolic-link check inspected a path built from the
62
+ canonical ID, so it examined a directory that does not exist and passed on a
63
+ workspace whose agent directory really had been replaced with a link.
64
+
65
+ ## [0.4.0] - 2026-09-07
66
+
67
+ Published as 0.3.0 and 0.4.0 on the same day; the entries below cover both.
7
68
 
8
69
  ### Changed
9
70
 
package/README.md CHANGED
@@ -14,20 +14,24 @@
14
14
 
15
15
  [CLI reference](docs/cli.md) · [MCP guide](docs/mcp.md) · [Storage](docs/storage-format.md) · [Security](SECURITY.md)
16
16
 
17
- The experimental remote client supports explicit backend configuration. `synomem backend use remote
18
- --url <https-origin> --workspace <id>` makes the CLI and stdio MCP use the versioned domain API with
19
- actor-scoped OAuth credentials; it does not synchronize local history or create a shadow SQLite
20
- database. Hosted API and HTTP-MCP implementations are separate products and are not included here.
21
- See the [CLI reference](docs/cli.md#backend-and-authentication).
17
+ Synomem runs two ways, and the CLI, the library and the MCP server behave identically on both.
18
+ **Local** keeps an append-only SQLite database on this machine, needs no account, and opens no
19
+ network listener. **Synomem Cloud** keeps canonical state in a hosted workspace shared across
20
+ machines and agents, with organizations, roles and administration. `synomem config` sets up either
21
+ one. Nothing is synchronized between them and choosing the hosted backend never creates a shadow
22
+ local database. See the [CLI reference](docs/cli.md#backend-and-authentication).
22
23
 
23
24
  </div>
24
25
 
25
- Synomem gives humans and AI agents four durable ways to coordinate beyond a disappearing chat:
26
+ Synomem gives humans and AI agents durable ways to coordinate beyond a disappearing chat:
26
27
 
27
28
  - **Kudos** recognize a concrete contribution.
28
29
  - **Memos** deliver a message to another agent or to one's future self.
29
30
  - **Notes** retain agent-owned, revisable knowledge.
30
- - **Todos** track assigned actions with optional date-only or timezone-aware deadlines.
31
+ - **Posts** announce something to everyone in the workspace, and record who has acknowledged it.
32
+ - **Tasks** delegate work to another agent, with their consent.
33
+ - **Todos** track an agent's own actions, private to them, with optional date-only or
34
+ timezone-aware deadlines.
31
35
 
32
36
  ## Core philosophy
33
37
 
@@ -38,9 +42,11 @@ collaboration through one auditable protocol. V1 provides that substrate locally
38
42
  designed so the same agent identities and semantics can later cross machines through an explicitly
39
43
  configured service.
40
44
 
41
- One append-only SQLite event store powers the TypeScript library, `synomem` CLI, actor-bound stdio
42
- MCP server, compact change feeds, and readable Markdown projections. V1 runs entirely on one machine
43
- and opens no network listener.
45
+ One append-only event store powers the TypeScript library, `synomem` CLI, actor-bound stdio MCP
46
+ server, compact change feeds, and readable Markdown projections. On the local backend that store is
47
+ SQLite on this machine, and nothing listens on the network. On Synomem Cloud it is a hosted
48
+ Postgres workspace reached over HTTPS with actor-scoped credentials. Events are never rewritten on
49
+ either one.
44
50
 
45
51
  > [!IMPORTANT]
46
52
  > Synomem is pre-1.0 software. Review the release notes before upgrading persisted storage or public
@@ -50,7 +56,34 @@ and opens no network listener.
50
56
 
51
57
  ```bash
52
58
  npm install --global synomem
59
+ synomem config # asks where state should live, then sets it up
60
+ ```
61
+
62
+ `synomem config` is interactive. Its deterministic equivalents, for a machine with no terminal:
53
63
 
64
+ ```bash
65
+ # Local: SQLite on this machine, no account.
66
+ synomem config init --backend local --yes
67
+
68
+ # Synomem Cloud with an installation access key from
69
+ # https://portal.synomem.ai/installations. The key names its own workspace, so
70
+ # there is no ID to look up -- and it is piped rather than passed as an
71
+ # argument, which the shell history and the process list would both keep.
72
+ printf '%s' "$SYNOMEM_KEY" | synomem config init \
73
+ --backend remote --auth access-key --access-token-stdin --yes
74
+ ```
75
+
76
+ Check either one at any time:
77
+
78
+ ```bash
79
+ synomem backend status # connects, and reports what answered
80
+ synomem projection status # local only: are the generated files current?
81
+ synomem doctor
82
+ ```
83
+
84
+ Everything below works the same on both backends.
85
+
86
+ ```bash
54
87
  export SYNOMEM_HOME="$(mktemp -d)/.synomem"
55
88
  synomem init
56
89
  synomem agent create codex --name "Codex"
@@ -78,8 +111,15 @@ synomem task accept <task-id> --as codex --response "Starting after the tests."
78
111
  # A todo is private to the agent that wrote it; nobody else can assign one.
79
112
  synomem todo create --as codex --title "Re-read the migration notes"
80
113
 
114
+ # A post is readable by everyone in the workspace, and tracks acknowledgement.
115
+ synomem post create --as gracie \
116
+ --title "Migration tonight" --body "Expect a short read-only window."
117
+ synomem post acknowledge <post-id> --as codex --note "Already handled."
118
+ synomem post roster <post-id>
119
+
81
120
  synomem agent resolve Mike
82
121
  synomem agent directory
122
+ synomem agent runtime list
83
123
  synomem list
84
124
  ```
85
125
 
@@ -93,13 +133,15 @@ terminal-capable agent. The Synomem package contains the portable
93
133
  installer for the six named local harnesses.
94
134
 
95
135
  ```text
96
- Set up Synomem for this agent and runtime. Synomem is a local-first coordination system for durable kudos, one-to-one memos, private agent notes, consent-based assigned tasks, and private todos. It uses an append-only SQLite database under ~/.synomem by default, an actor-bound stdio MCP server, and a portable Agent Skill. Multiple local agents may share the database, but every MCP server must be bound to its own stable identity.
136
+ Set up Synomem for this agent and runtime. Synomem is a coordination system for durable kudos, one-to-one memos, private agent notes, workspace-wide posts with acknowledgement, consent-based assigned tasks, and private todos. It runs on an append-only event store, an actor-bound stdio MCP server, and a portable Agent Skill. The store is either local SQLite under ~/.synomem, which needs no account and opens no network listener, or a hosted Synomem Cloud workspace reached over HTTPS. Nothing is synchronized between the two. Multiple local agents may share a local database, but every MCP server must be bound to its own stable identity.
137
+
138
+ Each agent has an opaque canonical ID, generated at creation and never reused, and a separate handle that people type and that can be renamed later. `agent create` takes the handle. Never assert an ID yourself.
97
139
 
98
140
  Work autonomously through the safe, reversible steps below. Do not expose secrets, overwrite unrelated configuration, invent an identity, use --force without my explicit approval, or modify another agent's integration.
99
141
 
100
142
  1. Verify Node.js 22.13+ and npm are available. Install or update the public package with `npm install --global synomem` if needed, then report `synomem --version`.
101
- 2. Preserve an existing `SYNOMEM_HOME`; otherwise use the default ~/.synomem. Run `synomem init`, then `synomem doctor`. Never point tests or experiments at another Synomem home.
102
- 3. Run `synomem agent list`. Determine this agent's existing stable ID from the current harness or Synomem configuration and reuse it. If no identity is clearly established, ask me for the agent ID and display name before running `synomem agent create <id> --name <name>`. Never silently merge or rename identities.
143
+ 2. Preserve an existing `SYNOMEM_HOME`; otherwise use the default ~/.synomem. Run `synomem config show` first: if a backend is already configured, leave it alone and do not reconfigure it. Otherwise ask me whether this machine should use the local backend or Synomem Cloud, and never guess. For local, run `synomem config init --backend local --yes`. For Synomem Cloud, ask me for an installation access key from https://portal.synomem.ai/installations and pipe it in — `printf '%s' "$KEY" | synomem config init --backend remote --auth access-key --access-token-stdin --yes` — because an argument is kept by both the shell history and the process list. The key names its own workspace, so do not ask me for a workspace ID. Then run `synomem backend status` and `synomem doctor`. Never point tests or experiments at another Synomem home.
144
+ 3. Run `synomem agent list`. Determine this agent's existing identity from the current harness or Synomem configuration and reuse its canonical ID. If no identity is clearly established, ask me for the handle and display name before running `synomem agent create <handle> --name <name>`, then record the canonical ID it reports. Never silently merge or rename identities.
103
145
  4. Detect the current harness from actual local evidence and its installed CLI help. Use runtime `claude` for Claude Code, `codex` for Codex, `hermes` for Hermes, `openclaw` for OpenClaw, `cursor` for Cursor, or `grok` for local Grok Build (`grokbot` is accepted as an alias). Check `synomem skill install --help`, then preview with `synomem skill install --runtime <runtime> --agent <agent-id>`. Review the exact destination and apply the same command with `--yes`; it must report `current`. If the installed release does not yet list this runtime, locate the packaged source under the global npm root at `synomem/skills/synomem` and follow the verified destination and conflict rules in https://github.com/Coaden/synomem/blob/main/docs/skill.md instead. Do not guess a path, overwrite an existing skill, or create a fake harness home to make an unavailable runtime appear installed.
104
146
  5. Inspect any actor-bound MCP registration command printed by the installer. Check the harness's existing MCP list/config first, then run the command only if `synomem` is absent or incorrect. Do not create duplicates. Cursor has no noninteractive MCP-add command: carefully merge a `synomem` stdio entry into its documented user `~/.cursor/mcp.json`, using command `synomem-mcp` and the single argument `--agent-id <agent-id>` (the display name and kind come from the agent's profile, so nothing in a shared config file asserts an identity); preserve every existing entry.
105
147
  6. Verify the harness can discover the installed skill and MCP server using its own list/status commands, then run `synomem doctor`. Start a new agent session if that harness does not live-reload a newly created skills directory.
package/dist/cli.d.ts CHANGED
@@ -23,6 +23,12 @@ export interface CliDependencies {
23
23
  reference: string;
24
24
  credentialStore: CredentialStore;
25
25
  }) => Promise<void>;
26
+ discoverBoundWorkspace?: (options: {
27
+ baseUrl: string;
28
+ accessToken: string;
29
+ }) => Promise<{
30
+ workspaceId: string;
31
+ }>;
26
32
  createImportBundle?: (home: string) => Promise<ImportBundle>;
27
33
  remoteImport?: (options: {
28
34
  baseUrl: string;
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAE,OAAO,EAA0B,MAAM,WAAW,CAAC;AAkB5D,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIhG,OAAO,EAA4C,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,OAAO,KAAK,EACV,aAAa,EAQd,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAkB,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,eAAe,CAAC;KAClC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpB,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,aAAa,GAAG,YAAY,CAAC,CAAC;CAC7C;AAkND,wBAAgB,SAAS,CACvB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAuqET;AAED,wBAAsB,MAAM,CAC1B,IAAI,WAAe,EACnB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAAC,MAAM,CAAC,CAqBjB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAE,OAAO,EAA0B,MAAM,WAAW,CAAC;AAmB5D,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIhG,OAAO,EAA4C,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,OAAO,KAAK,EACV,aAAa,EASd,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAkB,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,eAAe,CAAC;KAClC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAKpB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;KACrB,KAAK,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvC,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,aAAa,GAAG,YAAY,CAAC,CAAC;CAC7C;AAkND,wBAAgB,SAAS,CACvB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CA62ET;AAED,wBAAsB,MAAM,CAC1B,IAAI,WAAe,EACnB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAAC,MAAM,CAAC,CAqBjB"}
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { configuredServiceFactory, readSynomemConfig, writeSynomemBackend } from
8
8
  import { cloudApiUrl } from './cloud.js';
9
9
  import { resolveHome } from './config.js';
10
10
  import { assertInteractive, confirmPlan, credentialFingerprint, credentialStoreChoices, environmentInstructions, readAccessToken, runConfigWizard, writeCredentialFile, } from './configure.js';
11
+ import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
11
12
  import { defaultPromptIo } from './prompt.js';
12
13
  import { credentialReference, OsCredentialStore } from './credentials.js';
13
14
  import { asSynomemError, SynomemError } from './errors.js';
@@ -198,6 +199,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
198
199
  const env = dependencies.env ?? process.env;
199
200
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
200
201
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
202
+ const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
201
203
  const promptIo = dependencies.promptIo ?? defaultPromptIo();
202
204
  const verifyRemoteCredential = dependencies.verifyRemoteCredential ??
203
205
  (async (options) => {
@@ -226,6 +228,41 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
226
228
  .showSuggestionAfterError()
227
229
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
228
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
+ });
229
266
  remoteCommand
230
267
  .command('import')
231
268
  .description('Preview or confirm a one-way import from a local Synomem home')
@@ -308,13 +345,45 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
308
345
  .description('Set up Synomem, interactively or deterministically');
309
346
  const applyPlan = async (plan, token, global) => {
310
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
+ }
311
384
  const config = writeSynomemBackend(plan.backend === 'local'
312
385
  ? { kind: 'local' }
313
- : {
314
- kind: 'remote',
315
- baseUrl: plan.serviceUrl ?? cloudApiUrl(env),
316
- workspaceId: plan.workspaceId,
317
- }, home);
386
+ : { kind: 'remote', baseUrl: serviceUrl, workspaceId: workspaceId }, home);
318
387
  let credentialLocation;
319
388
  if (token) {
320
389
  if (plan.credentialStore === 'environment') {
@@ -328,7 +397,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
328
397
  // The platform store is the default, and a failure falls back to the
329
398
  // restricted file rather than leaving the credential nowhere.
330
399
  try {
331
- await credentialStore.set(`synomem:${plan.workspaceId}`, {
400
+ await credentialStore.set(`synomem:${workspaceId}`, {
332
401
  kind: 'installation-key',
333
402
  accessToken: token,
334
403
  });
@@ -392,8 +461,10 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
392
461
  throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
393
462
  }
394
463
  const backend = options.backend;
395
- if (backend === 'remote' && !options.workspace) {
396
- throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace.');
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.');
397
468
  }
398
469
  const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
399
470
  if (backend === 'remote' && options.auth === 'access-key' && !token) {
@@ -541,6 +612,80 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
541
612
  }, global.home);
542
613
  output(io, global.json, { backend: config.backend }, `Selected ${config.backend.kind} Synomem backend.`);
543
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
+ });
544
689
  const authCommand = program.command('auth').description('Inspect remote authentication');
545
690
  authCommand
546
691
  .command('status')
@@ -878,18 +1023,36 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
878
1023
  }));
879
1024
  output(io, global.json, binding, `Bound ${binding.agentId} to ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} (${binding.id})`);
880
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
+ */
881
1032
  runtimeCommand
882
- .command('list <agent>')
883
- .description('List an agent runtime bindings')
1033
+ .command('list [agent]')
1034
+ .description('List runtime bindings for one agent, or for every agent')
884
1035
  .action(async (agent, _options, command) => {
885
1036
  const global = globals(command);
886
- const bindings = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.bindings(agent));
887
- const human = bindings.length
888
- ? bindings
889
- .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'))
890
1051
  .join('\n')
891
- : 'No runtime bindings.';
892
- 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);
893
1056
  });
894
1057
  runtimeCommand
895
1058
  .command('unbind <binding-id>')