synomem 0.7.2 → 0.9.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 (93) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +47 -68
  3. package/dist/backend.d.ts +18 -6
  4. package/dist/backend.d.ts.map +1 -1
  5. package/dist/backend.js +55 -41
  6. package/dist/backend.js.map +1 -1
  7. package/dist/cli.d.ts +20 -25
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +1394 -1281
  10. package/dist/cli.js.map +1 -1
  11. package/dist/configure.d.ts +12 -46
  12. package/dist/configure.d.ts.map +1 -1
  13. package/dist/configure.js +51 -192
  14. package/dist/configure.js.map +1 -1
  15. package/dist/credentials.d.ts +73 -33
  16. package/dist/credentials.d.ts.map +1 -1
  17. package/dist/credentials.js +167 -43
  18. package/dist/credentials.js.map +1 -1
  19. package/dist/discover.d.ts +10 -13
  20. package/dist/discover.d.ts.map +1 -1
  21. package/dist/discover.js +45 -30
  22. package/dist/discover.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +5 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/import.d.ts +3 -0
  28. package/dist/import.d.ts.map +1 -1
  29. package/dist/import.js +3 -0
  30. package/dist/import.js.map +1 -1
  31. package/dist/index.d.ts +9 -7
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -5
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/index.d.ts +18 -7
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +402 -183
  38. package/dist/mcp/index.js.map +1 -1
  39. package/dist/mcp-server.d.ts +5 -1
  40. package/dist/mcp-server.d.ts.map +1 -1
  41. package/dist/mcp-server.js +27 -105
  42. package/dist/mcp-server.js.map +1 -1
  43. package/dist/oauth.d.ts +31 -33
  44. package/dist/oauth.d.ts.map +1 -1
  45. package/dist/oauth.js +178 -125
  46. package/dist/oauth.js.map +1 -1
  47. package/dist/profiles.d.ts +243 -0
  48. package/dist/profiles.d.ts.map +1 -0
  49. package/dist/profiles.js +465 -0
  50. package/dist/profiles.js.map +1 -0
  51. package/dist/project.d.ts +8 -39
  52. package/dist/project.d.ts.map +1 -1
  53. package/dist/project.js +36 -94
  54. package/dist/project.js.map +1 -1
  55. package/dist/remote.d.ts +23 -15
  56. package/dist/remote.d.ts.map +1 -1
  57. package/dist/remote.js +54 -49
  58. package/dist/remote.js.map +1 -1
  59. package/dist/resolvers.d.ts +47 -0
  60. package/dist/resolvers.d.ts.map +1 -0
  61. package/dist/resolvers.js +255 -0
  62. package/dist/resolvers.js.map +1 -0
  63. package/dist/service.d.ts +2 -0
  64. package/dist/service.d.ts.map +1 -1
  65. package/dist/skill-install.d.ts +4 -6
  66. package/dist/skill-install.d.ts.map +1 -1
  67. package/dist/skill-install.js +13 -12
  68. package/dist/skill-install.js.map +1 -1
  69. package/dist/types.d.ts +51 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/docs/cli.md +173 -196
  72. package/docs/mcp.md +69 -65
  73. package/package.json +1 -1
  74. package/skills/synomem/SKILL.md +30 -4
  75. package/skills/synomem/references/examples.md +14 -0
  76. package/src/backend.ts +66 -64
  77. package/src/cli.ts +2137 -2163
  78. package/src/configure.ts +62 -241
  79. package/src/credentials.ts +208 -84
  80. package/src/discover.ts +60 -36
  81. package/src/errors.ts +5 -0
  82. package/src/import.ts +5 -0
  83. package/src/index.ts +14 -12
  84. package/src/mcp/index.ts +473 -194
  85. package/src/mcp-server.ts +32 -114
  86. package/src/oauth.ts +229 -130
  87. package/src/profiles.ts +644 -0
  88. package/src/project.ts +42 -108
  89. package/src/remote.ts +69 -58
  90. package/src/resolvers.ts +299 -0
  91. package/src/service.ts +2 -0
  92. package/src/skill-install.ts +17 -18
  93. package/src/types.ts +46 -0
package/dist/cli.js CHANGED
@@ -4,28 +4,69 @@ 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
- import { configuredServiceFactory, readSynomemConfig, writeSynomemBackend } from './backend.js';
7
+ import { ensureLocalStore, LOCAL_OPERATOR, localStoreWorkspaceId, openLocalService, } from './backend.js';
8
8
  import { cloudApiUrl } from './cloud.js';
9
9
  import { resolveHome } from './config.js';
10
- import { assertInteractive, confirmPlan, credentialFingerprint, credentialStoreChoices, environmentInstructions, readAccessToken, runConfigWizard, writeCredentialFile, } from './configure.js';
11
- import { discoverAccessKeyWorkspaces, discoverOrganizations, workspaceChoices, } from './discover.js';
12
- import { DEFAULT_WORKSPACE, listLocalWorkspaces, localWorkspaceHome } from './workspaces.js';
13
- import { findProjectSelection, resolveWorkspaceSelection, writeProjectSelection, } from './project.js';
14
- import { defaultPromptIo, select } from './prompt.js';
15
- import { credentialReference, OsCredentialStore } from './credentials.js';
10
+ import { assertInteractive, credentialFingerprint, parseCredentialBackend, readAccessKey, } from './configure.js';
11
+ import { defaultCredentialStores, newSecretReference, } from './credentials.js';
12
+ import { describeIdentity, discoverContexts } from './discover.js';
16
13
  import { asSynomemError, SynomemError } from './errors.js';
17
14
  import { atomicWriteFile } from './fs-utils.js';
18
- import { startMcpServer } from './mcp/index.js';
19
- import { loginWithOAuth, StoredCredentialProvider } from './oauth.js';
20
- import { RemoteSynomemService } from './remote.js';
21
15
  import { createLocalImportBundle, RemoteImportClient, } from './import.js';
16
+ import { serveStdio } from './mcp/index.js';
17
+ import { loginWithOAuth, secureUrl } from './oauth.js';
18
+ import { assertName, credentialSourceFor, describeContext, describeProfile, isLocalProfile, noSelectionError, presetResolver, profileResolver, ProfileStore, resolveSelection, storeFor, } from './profiles.js';
19
+ import { writeProjectSelection } from './project.js';
20
+ import { defaultPromptIo, ask, select } from './prompt.js';
21
+ import { localContextId } from './resolvers.js';
22
22
  import { formatSkillResult, installSkill, skillRuntimeNames, skillStatus, uninstallSkill, } from './skill-install.js';
23
23
  import { packageVersion } from './version.js';
24
+ import { listLocalWorkspaces, localWorkspaceHome } from './workspaces.js';
24
25
  const defaultIo = {
25
26
  stdout: (text) => process.stdout.write(text),
26
27
  stderr: (text) => process.stderr.write(text),
27
28
  };
28
29
  const cliExitCodes = new WeakMap();
30
+ /**
31
+ * Identity overrides the profile model replaced. Named here so using one fails
32
+ * with a message that points at `--profile`, rather than a generic unknown
33
+ * option or — worse — being silently ignored.
34
+ */
35
+ const OBSOLETE_FLAGS = [
36
+ '--actor',
37
+ '--as',
38
+ '--from',
39
+ '--actor-kind',
40
+ '--actor-id',
41
+ '--agent-id',
42
+ '--actor-name',
43
+ ];
44
+ const OBSOLETE_ENV = [
45
+ 'SYNOMEM_ACTOR_ID',
46
+ 'SYNOMEM_ACTOR_KIND',
47
+ 'SYNOMEM_ACTOR_NAME',
48
+ 'SYNOMEM_AGENT_ID',
49
+ 'SYNOMEM_WORKSPACE',
50
+ ];
51
+ function obsoleteIdentityInput(argv, env) {
52
+ const args = argv.slice(2);
53
+ for (const argument of args) {
54
+ const flag = argument.split('=')[0];
55
+ if (OBSOLETE_FLAGS.includes(flag))
56
+ return flag;
57
+ // `--workspace` survives only as a filter on `profile create`.
58
+ if (flag === '--workspace' && !(args.includes('profile') && args.includes('create'))) {
59
+ return flag;
60
+ }
61
+ }
62
+ if (args.length === 0 ||
63
+ args.includes('--help') ||
64
+ args.includes('-h') ||
65
+ args.includes('--version')) {
66
+ return undefined;
67
+ }
68
+ return OBSOLETE_ENV.find((name) => env[name]?.trim());
69
+ }
29
70
  function collect(value, previous) {
30
71
  return [...previous, value];
31
72
  }
@@ -50,33 +91,6 @@ function parseEvidence(value) {
50
91
  value: value.slice(separator + 1),
51
92
  };
52
93
  }
53
- function actor(kind, id, displayName) {
54
- return {
55
- kind: kind,
56
- id,
57
- ...(displayName ? { displayName } : {}),
58
- };
59
- }
60
- /**
61
- * The acting identity for commands that do not take an explicit actor.
62
- * Local storage accepts any actor, so these fall back to the historical CLI defaults; a remote
63
- * backend binds the credential to one actor, so SYNOMEM_ACTOR_ID/KIND/NAME must be able to
64
- * override them or those commands cannot authenticate.
65
- */
66
- function defaultActor(env, fallbackKind, fallbackId,
67
- /** `--actor`, which outranks the environment: it is said on this invocation. */
68
- override) {
69
- const id = override?.trim() || env.SYNOMEM_ACTOR_ID?.trim();
70
- if (!id)
71
- return actor(fallbackKind, fallbackId);
72
- const kind = override?.trim()
73
- ? // An explicit --actor names an agent unless told otherwise; the historical
74
- // fallbacks here are `system`/`cli`, which is not what somebody means when
75
- // they name one.
76
- env.SYNOMEM_ACTOR_KIND?.trim() || 'agent'
77
- : env.SYNOMEM_ACTOR_KIND?.trim() || fallbackKind;
78
- return actor(kind, id, env.SYNOMEM_ACTOR_NAME?.trim());
79
- }
80
94
  function taskDue(options) {
81
95
  if (options.dueDate && options.dueAt)
82
96
  throw new SynomemError('INVALID_INPUT', 'Use due-date or due-at, not both.');
@@ -95,7 +109,8 @@ function exitCode(code) {
95
109
  if (code.endsWith('_FORBIDDEN') ||
96
110
  code === 'READ_ONLY' ||
97
111
  code === 'AUTH_REQUIRED' ||
98
- code === 'AUTH_FORBIDDEN')
112
+ code === 'AUTH_FORBIDDEN' ||
113
+ code === 'REAUTHORIZATION_REQUIRED')
99
114
  return 4;
100
115
  if (code.startsWith('DATABASE_') || code === 'UNSUPPORTED_SCHEMA' || code === 'UNSUPPORTED_EVENT')
101
116
  return 5;
@@ -122,7 +137,7 @@ function itemListInput(options) {
122
137
  ? { kinds: options.kind }
123
138
  : {}),
124
139
  ...(typeof options.participant === 'string' ? { participantAgentId: options.participant } : {}),
125
- ...(typeof options.actor === 'string' ? { actorId: options.actor } : {}),
140
+ ...(typeof options.author === 'string' ? { actorId: options.author } : {}),
126
141
  ...(typeof options.status === 'string' ? { status: options.status } : {}),
127
142
  ...(typeof options.tag === 'string' ? { tag: options.tag } : {}),
128
143
  ...(typeof options.topic === 'string' ? { topicId: options.topic } : {}),
@@ -159,51 +174,15 @@ function showRecord(record) {
159
174
  function output(io, json, value, human) {
160
175
  io.stdout(json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`);
161
176
  }
162
- /**
163
- * The resolved global options for a command.
164
- *
165
- * `--workspace` is turned into a home HERE, before any service exists, which is
166
- * the whole reason it costs nothing downstream: a local workspace is a separate
167
- * database in its own home, and choosing one is choosing a home. Nothing in the
168
- * domain, the commands, or the MCP tools learns that a workspace was selected.
169
- *
170
- * On a remote backend the name means a hosted workspace instead, which
171
- * `backend use remote --workspace` already handles; passing both here would be
172
- * two different answers to the same question, so it is refused.
173
- */
174
- /** `parent child`, so a subcommand name cannot be confused with another's. */
175
- function commandPath(command) {
176
- const parent = command.parent?.name();
177
- return parent && parent !== 'synomem' ? `${parent} ${command.name()}` : command.name();
178
- }
179
- /**
180
- * Commands where `--workspace` names a HOSTED workspace being configured,
181
- * rather than a local one to act in.
182
- */
183
- const CONFIGURES_BACKEND = new Set(['config init', 'backend use', 'remote import']);
184
- async function withService(serviceFactory, home, configuredActor, operation, assertActor) {
185
- const client = serviceFactory({
186
- ...(home ? { home } : {}),
187
- actor: configuredActor,
188
- ...(assertActor !== undefined ? { assertActor } : {}),
189
- });
190
- await client.init();
191
- try {
192
- return await operation(client);
193
- }
194
- finally {
195
- await client.close();
196
- }
197
- }
198
177
  function addListOptions(command) {
199
178
  return command
200
179
  .option('--recipient <agent>')
201
- .option('--actor <id>')
202
- .option('--actor-kind <kind>', 'human, agent, or system')
180
+ .option('--author <id>', 'only kudos given by this actor')
181
+ .option('--author-kind <kind>', 'human, agent, or system')
203
182
  .option('--tag <tag>')
204
183
  .option('--topic <id>', 'only records carrying this topic')
205
184
  .option('--status <status>', 'acknowledged or unacknowledged')
206
- .option('--visibility <visibility>', 'private, local, or public')
185
+ .option('--visibility <visibility>', 'private, workspace, or public')
207
186
  .addOption(new Option('--revoked <state>').choices(['include', 'only', 'exclude']).default('include'))
208
187
  .option('--from-date <iso>')
209
188
  .option('--to-date <iso>')
@@ -214,8 +193,8 @@ function addListOptions(command) {
214
193
  function listInput(options) {
215
194
  return {
216
195
  ...(options.recipient ? { recipientAgentId: options.recipient } : {}),
217
- ...(options.actor ? { actorId: options.actor } : {}),
218
- ...(options.actorKind ? { actorKind: options.actorKind } : {}),
196
+ ...(options.author ? { actorId: options.author } : {}),
197
+ ...(options.authorKind ? { actorKind: options.authorKind } : {}),
219
198
  ...(options.tag ? { tag: options.tag } : {}),
220
199
  ...(options.topic ? { topicId: options.topic } : {}),
221
200
  ...(options.status ? { status: options.status } : {}),
@@ -231,226 +210,789 @@ function listInput(options) {
231
210
  offset: Number(options.offset),
232
211
  };
233
212
  }
234
- /**
235
- * The value `--actor` should supply to commands that name an actor.
236
- *
237
- * Read from argv directly, before the commands are built, because Commander
238
- * evaluates option defaults at DECLARATION time: a `--as` declared without one
239
- * is required, and a `--as` declared with one is already satisfied. Supplying
240
- * it here is a single change point instead of a fallback threaded through
241
- * twenty action bodies, and `--as` still wins when both are given because an
242
- * explicitly passed option overrides its default.
243
- */
244
- function actorDefault(argv, env) {
245
- for (let index = 0; index < argv.length; index += 1) {
246
- const argument = argv[index];
247
- if (argument === '--actor')
248
- return argv[index + 1]?.trim() || undefined;
249
- if (argument.startsWith('--actor='))
250
- return argument.slice('--actor='.length).trim() || undefined;
251
- }
252
- if (env.SYNOMEM_ACTOR_ID?.trim())
253
- return env.SYNOMEM_ACTOR_ID.trim();
254
- /*
255
- * Last, the project's own binding. `workspace use --as` exists so a
256
- * repository can settle both questions once — which workspace, and as whom —
257
- * and a command run there needs neither flag afterwards.
258
- */
259
- try {
260
- return findProjectSelection()?.actor;
261
- }
262
- catch {
263
- // A malformed project file is reported by the resolver when the command
264
- // actually runs, with the path in the message. Failing here would turn it
265
- // into an error before any command had been parsed.
266
- return undefined;
267
- }
213
+ function describeEffective(context) {
214
+ return `${context.actor.displayName ?? context.actor.id} (${context.actor.kind}:${context.actor.id}) in ${context.workspaceId}`;
268
215
  }
269
- export function createCli(io = defaultIo, serviceFactory = configuredServiceFactory, dependencies = {}, argv = process.argv) {
216
+ export function createCli(io = defaultIo, dependencies = {}) {
270
217
  const env = dependencies.env ?? process.env;
271
- const actingDefault = actorDefault(argv, env);
218
+ const cwd = dependencies.cwd ?? process.cwd();
219
+ const platform = dependencies.platform ?? process.platform;
220
+ const fetchImplementation = dependencies.fetch ?? fetch;
221
+ const promptIo = dependencies.promptIo ?? defaultPromptIo();
222
+ const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
223
+ const storesFor = dependencies.credentialStores ?? defaultCredentialStores;
224
+ const profileStoreFor = dependencies.profileStore ?? ((home) => new ProfileStore(home));
272
225
  const globals = (command) => {
273
226
  const options = command.optsWithGlobals();
274
- if (CONFIGURES_BACKEND.has(commandPath(command)))
275
- return options;
276
- /*
277
- * Resolution runs even with no `--workspace`, because a project's
278
- * `.synomem/config.json` selects one without anybody passing a flag — that is
279
- * the whole point of it. `--home` still wins outright: it names a home
280
- * directly rather than a workspace within one.
281
- */
282
- if (!options.home) {
283
- const selection = resolveWorkspaceSelection({
284
- ...(options.workspace ? { flag: options.workspace } : {}),
285
- ...(options.actor ? { actorFlag: options.actor } : {}),
286
- env,
287
- });
288
- return { ...options, home: selection.home, actor: selection.actor ?? options.actor };
227
+ return {
228
+ home: resolveHome(options.home),
229
+ ...(options.home ? { explicitHome: options.home } : {}),
230
+ json: options.json,
231
+ ...(options.profile ? { profile: options.profile } : {}),
232
+ ...(options.preset ? { preset: options.preset } : {}),
233
+ };
234
+ };
235
+ const resolverDeps = (home) => ({
236
+ stores: storesFor(home),
237
+ env,
238
+ fetch: fetchImplementation,
239
+ ...(dependencies.createRemoteResolver
240
+ ? { createRemoteResolver: dependencies.createRemoteResolver }
241
+ : {}),
242
+ ...(dependencies.createLocalResolver
243
+ ? { createLocalResolver: dependencies.createLocalResolver }
244
+ : {}),
245
+ });
246
+ const selectionFor = (global, config) => resolveSelection(config, {
247
+ ...(global.profile ? { profile: global.profile } : {}),
248
+ ...(global.preset ? { preset: global.preset } : {}),
249
+ env,
250
+ cwd,
251
+ home: global.home,
252
+ });
253
+ /**
254
+ * Runs a domain command as the selected profile's single context. Every
255
+ * acting identity comes from here — there is no per-command actor flag.
256
+ */
257
+ const withProfile = async (command, operation) => {
258
+ const global = globals(command);
259
+ const config = profileStoreFor(global.home).read();
260
+ const selection = selectionFor(global, config);
261
+ if (!selection)
262
+ throw noSelectionError();
263
+ if (selection.kind === 'preset') {
264
+ throw new SynomemError('INVALID_INPUT', `A command acts as exactly one profile; preset ${selection.name} is for \`synomem mcp --preset ${selection.name} --contexts explicit\`. Pass --profile <name>.`);
265
+ }
266
+ const resolver = profileResolver(global.home, config, selection.name, resolverDeps(global.home));
267
+ try {
268
+ const { service, context } = await resolver.resolve();
269
+ return await operation(service, context, selection.name);
270
+ }
271
+ finally {
272
+ await resolver.close?.();
273
+ }
274
+ };
275
+ /**
276
+ * Store administration: agent management, rebuild, backup, export, doctor.
277
+ *
278
+ * A selected REMOTE profile routes through its context (the API decides
279
+ * whether that context may administer). Otherwise it is a local store — the
280
+ * selected local profile's store, or the root home — administered as the
281
+ * local operator, since the filesystem owner is the authority over a local
282
+ * store.
283
+ */
284
+ const withManagement = async (command, operation) => {
285
+ const global = globals(command);
286
+ const config = profileStoreFor(global.home).read();
287
+ const selection = selectionFor(global, config);
288
+ const profile = selection?.kind === 'profile' ? config.profiles[selection.name] : undefined;
289
+ if (profile && !isLocalProfile(profile)) {
290
+ return await withProfile(command, (service, context) => operation(service, context, undefined));
291
+ }
292
+ const storeHome = profile && isLocalProfile(profile) ? (profile.home ?? global.home) : global.home;
293
+ const client = await openLocalService(storeHome, LOCAL_OPERATOR);
294
+ try {
295
+ return await operation(client, undefined, storeHome);
296
+ }
297
+ finally {
298
+ await client.close();
289
299
  }
290
- if (!options.workspace)
291
- return options;
292
- /*
293
- * One flag, one meaning — "which workspace" — resolved differently by the
294
- * handful of commands that CONFIGURE a backend rather than act inside one.
295
- * For those, the value is a hosted workspace ID to be written to the config,
296
- * so it is passed through raw and they read `workspace`. Everywhere else it
297
- * names a local workspace, which is a home.
298
- *
299
- * These commands used to declare their own `--workspace`, which does not
300
- * work: Commander gives a duplicated long flag to the parent, so the
301
- * subcommand never received it at all.
302
- */
303
- return { ...options, home: localWorkspaceHome(options.workspace, options.home) };
304
300
  };
305
- const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
306
- const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
307
- const discoverWorkspaces = dependencies.discoverAccessKeyWorkspaces ?? discoverAccessKeyWorkspaces;
308
- const promptIo = dependencies.promptIo ?? defaultPromptIo();
309
- const verifyRemoteCredential = dependencies.verifyRemoteCredential ??
310
- (async (options) => {
311
- const remote = new RemoteSynomemService({
312
- baseUrl: options.baseUrl,
313
- workspaceId: options.workspaceId,
314
- expectedActor: options.actor,
315
- credentialProvider: new StoredCredentialProvider(options.reference, options.credentialStore, {}),
316
- });
317
- try {
318
- await remote.init();
319
- }
320
- finally {
321
- await remote.close();
322
- }
323
- });
324
301
  const program = new Command();
325
- const withClient = (home, configuredActor, operation, assertActor) => withService(serviceFactory, home, configuredActor, operation, assertActor);
326
302
  cliExitCodes.set(program, 0);
327
303
  program
328
304
  .name('synomem')
329
- .description('Local-first communication, memory, recognition, and task infrastructure for agents')
305
+ .description('Durable communication, memory, recognition, and task infrastructure for agents')
330
306
  .version(packageVersion())
331
- .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.synomem)')
332
- // A local workspace is its own database under the root, so this selects a
333
- // home. On a remote backend the hosted workspace is chosen by
334
- // `backend use remote --workspace` instead.
335
- .option('--workspace <name>', 'local workspace to act in (see `synomem workspace list`)')
336
- .option('--actor <id>', 'act as this agent (overrides SYNOMEM_ACTOR_ID)')
307
+ .option('--home <path>', 'Synomem home (defaults to SYNOMEM_HOME or ~/.synomem)')
308
+ .option('--profile <name>', 'act as this profile (or SYNOMEM_PROFILE)')
309
+ .option('--preset <name>', 'MCP preset of several profiles (or SYNOMEM_PRESET)')
337
310
  .option('--json', 'emit stable machine-readable JSON', false)
338
311
  .showSuggestionAfterError()
339
312
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
340
- /*
341
- * Local workspaces.
342
- *
343
- * Each is a separate database in its own home, which is what makes the
344
- * isolation real: SQLite has no row-level security, so a shared file would
345
- * rest on every query remembering to filter, with nothing to catch a miss.
346
- * Separate files mean cross-workspace leakage is not something anybody can
347
- * write by accident.
348
- */
349
- const workspaceCommand = program
350
- .command('workspace')
351
- .description('Work in a separate local store, isolated from the others');
352
- workspaceCommand
353
- .command('list')
354
- .description('List the local workspaces on this machine')
355
- .action((_options, command) => {
313
+ /* ------------------------------------------------------------ setup */
314
+ program
315
+ .command('setup')
316
+ .description('Set up a local store with its first agent and a matching fixed profile')
317
+ .option('--backend <kind>', 'local (hosted access uses `connection login`)', 'local')
318
+ .option('--agent <handle>', 'handle of the first agent')
319
+ .option('--name <display-name>', 'display name of the first agent')
320
+ .option('--description <text>')
321
+ .option('--profile-name <name>', 'profile name (defaults to the handle)')
322
+ .action(async (options, command) => {
356
323
  const global = globals(command);
357
- // Read from disk, so nothing is listed that does not exist.
358
- const workspaces = listLocalWorkspaces(global.home);
359
- // Which one is in effect here, and what decided it — a flag, the
360
- // environment, a project file, or nothing.
361
- const selection = resolveWorkspaceSelection({ env });
362
- const human = workspaces
363
- .map((workspace) => `${workspace.name === DEFAULT_WORKSPACE ? '*' : ' '} ${workspace.name.padEnd(24)} ${workspace.initialized ? workspace.home : `${workspace.home} (not initialized)`}`)
364
- .join('\n');
365
- output(io, global.json, { workspaces, active: selection.workspace ?? DEFAULT_WORKSPACE, source: selection.source }, [
366
- human,
324
+ if (options.backend !== 'local') {
325
+ throw new SynomemError('INVALID_INPUT', 'Hosted access is a connection: run `synomem connection login --name <name>`, then `synomem profile create`.');
326
+ }
327
+ let handle = options.agent?.trim();
328
+ let displayName = options.name?.trim();
329
+ if (!handle || !displayName) {
330
+ assertInteractive(promptIo, 'synomem setup --backend local --agent <handle> --name "<display name>"');
331
+ handle ||= await ask(promptIo, 'Agent handle (what people type, e.g. gracie)');
332
+ displayName ||= await ask(promptIo, 'Agent display name', handle);
333
+ }
334
+ if (!handle)
335
+ throw new SynomemError('INVALID_INPUT', 'An agent handle is required.');
336
+ const profileName = assertName(options.profileName ?? handle, 'profile');
337
+ const store = profileStoreFor(global.home);
338
+ const storeConfig = ensureLocalStore(global.home);
339
+ const config = store.read();
340
+ const existing = config.profiles[profileName];
341
+ const client = await openLocalService(global.home, LOCAL_OPERATOR);
342
+ let agent;
343
+ let resumed = false;
344
+ try {
345
+ const resolution = await client.agents.resolve(handle);
346
+ const match = resolution.match && resolution.match.handle.toLowerCase() === handle.toLowerCase()
347
+ ? resolution.match
348
+ : undefined;
349
+ if (existing) {
350
+ // Idempotent re-run: the same profile, same store, same agent.
351
+ if (isLocalProfile(existing) &&
352
+ (existing.home ?? global.home) === global.home &&
353
+ match &&
354
+ existing.actorId === match.id) {
355
+ output(io, global.json, {
356
+ applied: false,
357
+ profile: profileName,
358
+ agentId: match.id,
359
+ contextId: existing.contextId,
360
+ }, `Already set up: profile ${profileName} acts as ${match.displayName} (${match.id}).\n\nStart the MCP server with:\n synomem mcp --profile ${profileName}`);
361
+ return;
362
+ }
363
+ throw new SynomemError('CONFIG_INVALID', `Profile ${profileName} already exists and points somewhere else; setup will not overwrite it. Pass --profile-name <other>.`);
364
+ }
365
+ if (match) {
366
+ agent = match;
367
+ resumed = true;
368
+ }
369
+ else {
370
+ agent = await client.agents.create({
371
+ handle,
372
+ displayName: displayName || handle,
373
+ ...(options.description ? { description: options.description } : {}),
374
+ });
375
+ }
376
+ }
377
+ finally {
378
+ await client.close();
379
+ }
380
+ const actor = { kind: 'agent', id: agent.id };
381
+ const profile = {
382
+ backend: 'local',
383
+ ...(global.explicitHome ? { home: global.home } : {}),
384
+ actorId: agent.id,
385
+ actorName: agent.displayName,
386
+ contextId: localContextId(storeConfig.workspaceId, actor),
387
+ };
388
+ const next = {
389
+ ...config,
390
+ profiles: { ...config.profiles, [profileName]: profile },
391
+ ...(config.defaultProfile ? {} : { defaultProfile: profileName }),
392
+ };
393
+ store.write(next);
394
+ output(io, global.json, {
395
+ applied: true,
396
+ resumed,
397
+ profile: profileName,
398
+ agentId: agent.id,
399
+ contextId: profile.contextId,
400
+ default: next.defaultProfile === profileName,
401
+ mcpCommand: `synomem mcp --profile ${profileName}`,
402
+ }, [
403
+ '',
404
+ `${resumed ? 'Found existing agent' : 'Created'} ${agent.displayName} (handle ${agent.handle}, id ${agent.id})`,
405
+ `Profile ${profileName} acts as it in ${global.home}${next.defaultProfile === profileName ? ' (default)' : ''}.`,
367
406
  '',
368
- `Acting in: ${selection.workspace ?? DEFAULT_WORKSPACE} (from ${selection.source})`,
407
+ 'Start the MCP server with:',
408
+ ` synomem mcp --profile ${profileName}`,
369
409
  '',
370
- 'Bind a directory with `synomem workspace use <name>`, or pass --workspace once.',
371
- 'A local workspace is a separate store on this machine; a hosted workspace is shared,',
372
- 'and is selected with `backend use remote --workspace`.',
410
+ 'Register it with your harness and install the skill:',
411
+ ` synomem skill install --runtime <claude|codex|hermes|...> --profile ${profileName} --yes`,
373
412
  ].join('\n'));
374
413
  });
375
- workspaceCommand
376
- .command('use <name>')
377
- .description('Bind this directory to a workspace, for every session opened here')
378
- .option('--as <actor-id>', 'also always write as this agent', actingDefault)
379
- .action((name, options, command) => {
414
+ /* ------------------------------------------------------------ connections */
415
+ const connectionCommand = program
416
+ .command('connection')
417
+ .description('Hosted credentials: one per harness installation, shared by its profiles');
418
+ const saveCredential = async (global, name, entry, credential) => {
419
+ const store = profileStoreFor(global.home);
420
+ const config = store.read();
421
+ if (credential && entry.store !== 'environment') {
422
+ await storeFor(storesFor(global.home), entry).set(entry.secretRef, credential);
423
+ }
424
+ const next = { ...config, credentials: { ...config.credentials, [name]: entry } };
425
+ store.write(next);
426
+ return next;
427
+ };
428
+ const verifyConnection = async (apiUrl, bearer) => {
429
+ const identity = await describeIdentity({
430
+ baseUrl: apiUrl,
431
+ accessToken: bearer,
432
+ fetch: fetchImplementation,
433
+ });
434
+ const listing = await discoverContexts({
435
+ baseUrl: apiUrl,
436
+ accessToken: bearer,
437
+ fetch: fetchImplementation,
438
+ });
439
+ return { identity, contexts: listing.contexts };
440
+ };
441
+ const connectionSummary = (name, identity, contexts) => [
442
+ `Connection ${name}${identity.connection ? ` — ${identity.connection.label}` : ''}`,
443
+ ...(identity.grant
444
+ ? [`Grant: ${identity.grant.mode}, ${identity.grant.actions.join(' ')}`]
445
+ : []),
446
+ contexts.length ? 'May act as:' : 'This connection may not act as anything yet.',
447
+ ...contexts.map((context) => ` ${context.contextId} ${describeContext(context)}`),
448
+ '',
449
+ contexts.length
450
+ ? `Next: synomem profile create <name> --connection ${name} --context <context-id>`
451
+ : 'Authorize an agent for it on the consent screen or in the portal.',
452
+ ].join('\n');
453
+ connectionCommand
454
+ .command('login')
455
+ .description('Sign in through the browser (OAuth 2.1 + PKCE) and store the credential')
456
+ .requiredOption('--name <name>', 'connection name, e.g. codex-mac')
457
+ // Internal: development and private deployments. Kept out of public docs —
458
+ // onboarding must never ask a person for a service address.
459
+ .option('--api-url <url>', 'internal: alternate API origin')
460
+ .option('--client-id <id>', 'OAuth client id (default synomem-cli)')
461
+ .option('--store <where>', 'keychain (default) or file')
462
+ .option('--callback-port <port>', 'loopback callback port', '43817')
463
+ .action(async (options, command) => {
380
464
  const global = globals(command);
381
- // Validated by resolving it, so a name that could never work is refused
382
- // before a file claiming it is written.
383
- const home = localWorkspaceHome(name, undefined);
384
- const path = writeProjectSelection(process.cwd(), {
385
- workspace: name,
386
- ...(options.as ? { actor: options.as } : {}),
465
+ const name = assertName(options.name, 'connection');
466
+ const apiUrl = secureUrl(options.apiUrl ?? cloudApiUrl(env), 'API URL').origin;
467
+ const config = profileStoreFor(global.home).read();
468
+ const existing = config.credentials[name];
469
+ if (existing && existing.kind !== 'oauth') {
470
+ throw new SynomemError('INVALID_INPUT', `Connection ${name} is an access key, not a browser sign-in. Choose another name.`);
471
+ }
472
+ const backend = parseCredentialBackend(options.store ?? existing?.store, platform);
473
+ if (backend === 'environment') {
474
+ throw new SynomemError('INVALID_INPUT', 'A browser sign-in is stored in the keychain or a file, never the environment.');
475
+ }
476
+ const callbackPort = Number(options.callbackPort);
477
+ if (!Number.isSafeInteger(callbackPort) || callbackPort < 1 || callbackPort > 65_535) {
478
+ throw new SynomemError('INVALID_INPUT', '--callback-port must be from 1 through 65535.');
479
+ }
480
+ const credential = await oauthLogin({
481
+ apiUrl,
482
+ ...(options.clientId ? { clientId: options.clientId } : {}),
483
+ callbackPort,
484
+ fetch: fetchImplementation,
387
485
  });
388
- output(io, global.json, { path, workspace: name, home, ...(options.as ? { actor: options.as } : {}) }, [
389
- `Wrote ${path}`,
390
- '',
391
- `Every Synomem command and MCP server started in this directory now acts in ${name}${options.as ? ` as ${options.as}` : ''}, with no flag.`,
392
- `Records live in ${home} — nothing is stored in this directory.`,
393
- '',
394
- 'Commit it to share the choice with the repository, or ignore it to keep it yours.',
486
+ const { identity, contexts } = await verifyConnection(apiUrl, credential.accessToken);
487
+ // Re-login keeps the same secret reference, so every profile routing
488
+ // through this connection keeps working with the new credential.
489
+ const entry = {
490
+ kind: 'oauth',
491
+ apiUrl,
492
+ store: backend,
493
+ secretRef: existing?.secretRef ?? newSecretReference(),
494
+ issuer: credential.issuer,
495
+ resource: credential.resource,
496
+ clientId: credential.clientId,
497
+ ...(identity.connection
498
+ ? { connectionId: identity.connection.id, label: identity.connection.label }
499
+ : {}),
500
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
501
+ };
502
+ await saveCredential(global, name, entry, credential);
503
+ output(io, global.json, { connection: name, store: backend, identity, contexts }, connectionSummary(name, identity, contexts));
504
+ });
505
+ connectionCommand
506
+ .command('add-key')
507
+ .description('Store a member-owned access key (read from stdin, never an argument)')
508
+ .requiredOption('--name <name>', 'connection name')
509
+ .option('--api-url <url>', 'internal: alternate API origin')
510
+ .option('--store <where>', 'keychain (default), file, or environment')
511
+ .action(async (options, command) => {
512
+ const global = globals(command);
513
+ const name = assertName(options.name, 'connection');
514
+ const apiUrl = secureUrl(options.apiUrl ?? cloudApiUrl(env), 'API URL').origin;
515
+ const config = profileStoreFor(global.home).read();
516
+ const existing = config.credentials[name];
517
+ if (existing && existing.kind !== 'access-key') {
518
+ throw new SynomemError('INVALID_INPUT', `Connection ${name} is a browser sign-in. Choose another name.`);
519
+ }
520
+ const backend = parseCredentialBackend(options.store, platform);
521
+ const secret = backend === 'environment'
522
+ ? env.SYNOMEM_ACCESS_TOKEN?.trim()
523
+ : await readAccessKey(promptIo);
524
+ if (!secret) {
525
+ throw new SynomemError('INVALID_INPUT', 'Set SYNOMEM_ACCESS_TOKEN to the key before adding an environment connection.');
526
+ }
527
+ const { identity, contexts } = await verifyConnection(apiUrl, secret);
528
+ const entry = {
529
+ kind: 'access-key',
530
+ apiUrl,
531
+ store: backend,
532
+ ...(backend === 'environment'
533
+ ? {}
534
+ : { secretRef: existing?.secretRef ?? newSecretReference() }),
535
+ ...(identity.connection
536
+ ? { connectionId: identity.connection.id, label: identity.connection.label }
537
+ : {}),
538
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
539
+ };
540
+ await saveCredential(global, name, entry, backend === 'environment' ? undefined : { kind: 'access-key', secret });
541
+ output(io, global.json, {
542
+ connection: name,
543
+ store: backend,
544
+ key: credentialFingerprint(secret),
545
+ identity,
546
+ contexts,
547
+ }, connectionSummary(name, identity, contexts));
548
+ });
549
+ connectionCommand
550
+ .command('list')
551
+ .description('List connections and the profiles using each, without secrets')
552
+ .action((_options, command) => {
553
+ const global = globals(command);
554
+ const config = profileStoreFor(global.home).read();
555
+ const connections = Object.entries(config.credentials).map(([name, entry]) => ({
556
+ name,
557
+ kind: entry.kind,
558
+ store: entry.store,
559
+ apiUrl: entry.apiUrl,
560
+ ...(entry.label ? { label: entry.label } : {}),
561
+ ...(entry.connectionId ? { connectionId: entry.connectionId } : {}),
562
+ profiles: Object.entries(config.profiles)
563
+ .filter(([, profile]) => !isLocalProfile(profile) && profile.credentialRef === name)
564
+ .map(([profileName]) => profileName),
565
+ }));
566
+ output(io, global.json, { connections }, connections.length
567
+ ? connections
568
+ .map((connection) => `${connection.name} ${connection.kind} ${connection.store} ${connection.label ?? ''}\n profiles: ${connection.profiles.join(', ') || 'none'}`)
569
+ .join('\n')
570
+ : 'No connections. Run `synomem connection login --name <name>`.');
571
+ });
572
+ connectionCommand
573
+ .command('status [name]')
574
+ .description('Check that a connection (or every connection) authenticates')
575
+ .action(async (name, _options, command) => {
576
+ const global = globals(command);
577
+ const config = profileStoreFor(global.home).read();
578
+ const names = name ? [name] : Object.keys(config.credentials);
579
+ const results = [];
580
+ let failed = false;
581
+ for (const connection of names) {
582
+ const entry = config.credentials[connection];
583
+ if (!entry)
584
+ throw new SynomemError('CONFIG_INVALID', `Unknown connection "${connection}".`);
585
+ try {
586
+ const bearer = await credentialSourceFor(global.home, config, connection, resolverDeps(global.home)).bearer();
587
+ const { identity, contexts } = await verifyConnection(entry.apiUrl, bearer);
588
+ results.push({
589
+ name: connection,
590
+ ok: true,
591
+ store: entry.store,
592
+ identity,
593
+ contexts: contexts.length,
594
+ });
595
+ }
596
+ catch (error) {
597
+ failed = true;
598
+ const synomemError = asSynomemError(error);
599
+ results.push({
600
+ name: connection,
601
+ ok: false,
602
+ store: entry.store,
603
+ error: { code: synomemError.code, message: synomemError.message },
604
+ });
605
+ }
606
+ }
607
+ output(io, global.json, { connections: results }, results.length
608
+ ? results
609
+ .map((result) => result.ok
610
+ ? `${String(result.name)} ok (${String(result.store)}) — ${String(result.contexts)} context(s)`
611
+ : `${String(result.name)} FAILED ${result.error.message}`)
612
+ .join('\n')
613
+ : 'No connections.');
614
+ if (failed)
615
+ cliExitCodes.set(program, 4);
616
+ });
617
+ connectionCommand
618
+ .command('remove')
619
+ .description('Forget a connection and delete its locally stored secret')
620
+ .requiredOption('--name <name>')
621
+ .option('--force', 'also remove the profiles that use it', false)
622
+ .action(async (options, command) => {
623
+ const global = globals(command);
624
+ const store = profileStoreFor(global.home);
625
+ const config = store.read();
626
+ const entry = config.credentials[options.name];
627
+ if (!entry)
628
+ throw new SynomemError('CONFIG_INVALID', `Unknown connection "${options.name}".`);
629
+ const dependents = Object.entries(config.profiles)
630
+ .filter(([, profile]) => !isLocalProfile(profile) && profile.credentialRef === options.name)
631
+ .map(([profileName]) => profileName);
632
+ if (dependents.length && !options.force) {
633
+ throw new SynomemError('INVALID_INPUT', `Profiles ${dependents.join(', ')} use connection ${options.name}. Remove them first, or pass --force to remove them too.`);
634
+ }
635
+ if (entry.store !== 'environment') {
636
+ await storeFor(storesFor(global.home), entry).delete(entry.secretRef);
637
+ }
638
+ const profiles = Object.fromEntries(Object.entries(config.profiles).filter(([profileName]) => !dependents.includes(profileName)));
639
+ const harnessPresets = Object.fromEntries(Object.entries(config.harnessPresets)
640
+ .map(([preset, members]) => [preset, members.filter((member) => !dependents.includes(member))])
641
+ .filter(([, members]) => members.length > 0));
642
+ const credentials = Object.fromEntries(Object.entries(config.credentials).filter(([name]) => name !== options.name));
643
+ store.write({
644
+ ...config,
645
+ credentials,
646
+ profiles,
647
+ harnessPresets,
648
+ ...(config.defaultProfile && dependents.includes(config.defaultProfile)
649
+ ? { defaultProfile: undefined }
650
+ : {}),
651
+ });
652
+ output(io, global.json, { removed: options.name, profilesRemoved: dependents }, [
653
+ `Removed connection ${options.name} and its local secret.`,
654
+ ...(dependents.length ? [`Also removed profiles: ${dependents.join(', ')}.`] : []),
655
+ 'The server-side authorization still exists: revoke it in the portal (Connections) to stop it everywhere.',
395
656
  ].join('\n'));
396
657
  });
658
+ /* ------------------------------------------------------------ profiles */
659
+ const profileCommand = program
660
+ .command('profile')
661
+ .description('Named identities: one stable context through one connection or local store');
662
+ profileCommand
663
+ .command('create <name>')
664
+ .description('Create a profile for a context this connection may already use')
665
+ .option('--connection <name>', 'hosted connection to route through')
666
+ .option('--context <context-id>', 'exact context id (see `connection status`)')
667
+ .option('--agent <handle-or-id>', 'narrow by agent handle, id or display name')
668
+ .option('--workspace <name-or-id>', 'narrow by workspace name or id')
669
+ .option('--local', 'a local-store profile for an existing local agent', false)
670
+ .option('--store-home <path>', 'local store home (default: the Synomem home)')
671
+ .option('--default', 'make this the default profile', false)
672
+ .action(async (rawName, options, command) => {
673
+ const global = globals(command);
674
+ const name = assertName(rawName, 'profile');
675
+ const store = profileStoreFor(global.home);
676
+ const config = store.read();
677
+ if (config.profiles[name]) {
678
+ throw new SynomemError('CONFIG_INVALID', `Profile ${name} already exists. Remove it first to change what it points at.`);
679
+ }
680
+ if (options.local) {
681
+ if (!options.agent)
682
+ throw new SynomemError('INVALID_INPUT', 'A local profile needs --agent <handle-or-id>.');
683
+ const storeHome = options.storeHome ? resolve(options.storeHome) : global.home;
684
+ const client = await openLocalService(storeHome, LOCAL_OPERATOR);
685
+ let agent;
686
+ try {
687
+ const resolution = await client.agents.resolve(options.agent);
688
+ if (!resolution.match) {
689
+ throw new SynomemError(resolution.candidates.length ? 'CONTEXT_AMBIGUOUS' : 'AGENT_NOT_FOUND', resolution.candidates.length
690
+ ? `"${options.agent}" matches several agents: ${resolution.candidates.map((c) => `${c.handle} (${c.id})`).join(', ')}.`
691
+ : `No local agent answers to "${options.agent}". Profiles never create agents: run \`synomem agent create\` first.`);
692
+ }
693
+ agent = resolution.match;
694
+ }
695
+ finally {
696
+ await client.close();
697
+ }
698
+ const profile = {
699
+ backend: 'local',
700
+ ...(storeHome !== global.home ? { home: storeHome } : {}),
701
+ actorId: agent.id,
702
+ actorName: agent.displayName,
703
+ contextId: localContextId(localStoreWorkspaceId(storeHome), {
704
+ kind: 'agent',
705
+ id: agent.id,
706
+ }),
707
+ };
708
+ store.write({
709
+ ...config,
710
+ profiles: { ...config.profiles, [name]: profile },
711
+ ...(options.default || !config.defaultProfile ? { defaultProfile: name } : {}),
712
+ });
713
+ output(io, global.json, { profile: name, ...profile }, `Created profile ${name}: ${agent.displayName} in ${storeHome}.`);
714
+ return;
715
+ }
716
+ if (!options.connection) {
717
+ throw new SynomemError('INVALID_INPUT', 'Pass --connection <name> (or --local for a local store).');
718
+ }
719
+ const entry = config.credentials[options.connection];
720
+ if (!entry)
721
+ throw new SynomemError('CONFIG_INVALID', `Unknown connection "${options.connection}".`);
722
+ const bearer = await credentialSourceFor(global.home, config, options.connection, resolverDeps(global.home)).bearer();
723
+ const listing = await discoverContexts({
724
+ baseUrl: entry.apiUrl,
725
+ accessToken: bearer,
726
+ fetch: fetchImplementation,
727
+ });
728
+ const lower = (value) => value?.toLowerCase();
729
+ let candidates = listing.contexts.filter((context) => {
730
+ if (options.context && context.contextId !== options.context)
731
+ return false;
732
+ if (options.agent) {
733
+ const wanted = options.agent.toLowerCase();
734
+ if (context.actor.id !== options.agent &&
735
+ lower(context.actor.handle) !== wanted &&
736
+ lower(context.actor.displayName) !== wanted)
737
+ return false;
738
+ }
739
+ if (options.workspace) {
740
+ const wanted = options.workspace.toLowerCase();
741
+ if (context.workspaceId !== options.workspace &&
742
+ lower(context.workspaceName) !== wanted)
743
+ return false;
744
+ }
745
+ return true;
746
+ });
747
+ if (candidates.length === 0) {
748
+ throw new SynomemError('CONTEXT_FORBIDDEN', `Connection ${options.connection} may not use any context matching that. Profiles never create agents or grant access — authorize it on the consent screen or in the portal. Available: ${listing.contexts
749
+ .map((context) => `${context.contextId} (${describeContext(context)})`)
750
+ .join('; ') || 'none'}.`);
751
+ }
752
+ if (candidates.length > 1) {
753
+ if (!promptIo.interactive) {
754
+ throw new SynomemError('CONTEXT_AMBIGUOUS', `Several contexts match; pass --context <id>:\n${candidates
755
+ .map((context) => ` ${context.contextId} ${describeContext(context)}`)
756
+ .join('\n')}`);
757
+ }
758
+ const chosen = await select(promptIo, 'Which identity should this profile act as?', candidates.map((context) => ({
759
+ value: context.contextId,
760
+ label: describeContext(context),
761
+ detail: context.contextId,
762
+ })));
763
+ candidates = candidates.filter((context) => context.contextId === chosen);
764
+ }
765
+ const context = candidates[0];
766
+ const profile = {
767
+ credentialRef: options.connection,
768
+ contextId: context.contextId,
769
+ workspaceId: context.workspaceId,
770
+ ...(context.workspaceName ? { workspaceName: context.workspaceName } : {}),
771
+ actor: {
772
+ kind: context.actor.kind,
773
+ id: context.actor.id,
774
+ ...(context.actor.displayName ? { displayName: context.actor.displayName } : {}),
775
+ },
776
+ };
777
+ store.write({
778
+ ...config,
779
+ profiles: { ...config.profiles, [name]: profile },
780
+ ...(options.default || !config.defaultProfile ? { defaultProfile: name } : {}),
781
+ });
782
+ output(io, global.json, { profile: name, ...profile }, `Created profile ${name}: ${describeContext(context)} via ${options.connection}.\n\nUse it with --profile ${name}, or start MCP with:\n synomem mcp --profile ${name}`);
783
+ });
784
+ profileCommand
785
+ .command('list')
786
+ .description('List profiles and presets')
787
+ .action((_options, command) => {
788
+ const global = globals(command);
789
+ const config = profileStoreFor(global.home).read();
790
+ const lines = Object.entries(config.profiles).map(([name, profile]) => `${name === config.defaultProfile ? '*' : ' '} ${describeProfile(name, profile)}`);
791
+ output(io, global.json, {
792
+ profiles: config.profiles,
793
+ presets: config.harnessPresets,
794
+ defaultProfile: config.defaultProfile ?? null,
795
+ }, lines.length
796
+ ? [
797
+ ...lines,
798
+ ...(Object.keys(config.harnessPresets).length
799
+ ? [
800
+ '',
801
+ 'Presets:',
802
+ ...Object.entries(config.harnessPresets).map(([name, members]) => ` ${name}: ${members.join(', ')}`),
803
+ ]
804
+ : []),
805
+ ].join('\n')
806
+ : 'No profiles. Run `synomem setup --backend local`, or `synomem connection login` then `synomem profile create`.');
807
+ });
808
+ profileCommand
809
+ .command('show <name>')
810
+ .description('Show one profile')
811
+ .action((name, _options, command) => {
812
+ const global = globals(command);
813
+ const config = profileStoreFor(global.home).read();
814
+ const profile = config.profiles[name];
815
+ if (!profile)
816
+ throw new SynomemError('CONFIG_INVALID', `Unknown profile "${name}".`);
817
+ output(io, global.json, { name, ...profile, default: config.defaultProfile === name }, describeProfile(name, profile));
818
+ });
819
+ profileCommand
820
+ .command('remove <name>')
821
+ .description('Remove a profile (its connection and credential are kept)')
822
+ .option('--force', 'also remove it from presets that use it', false)
823
+ .action((name, options, command) => {
824
+ const global = globals(command);
825
+ const store = profileStoreFor(global.home);
826
+ const config = store.read();
827
+ if (!config.profiles[name])
828
+ throw new SynomemError('CONFIG_INVALID', `Unknown profile "${name}".`);
829
+ const presets = Object.entries(config.harnessPresets)
830
+ .filter(([, members]) => members.includes(name))
831
+ .map(([preset]) => preset);
832
+ if (presets.length && !options.force) {
833
+ throw new SynomemError('INVALID_INPUT', `Presets ${presets.join(', ')} use profile ${name}. Pass --force to remove it from them too.`);
834
+ }
835
+ const profiles = Object.fromEntries(Object.entries(config.profiles).filter(([profileName]) => profileName !== name));
836
+ store.write({
837
+ ...config,
838
+ profiles,
839
+ harnessPresets: Object.fromEntries(Object.entries(config.harnessPresets)
840
+ .map(([preset, members]) => [preset, members.filter((member) => member !== name)])
841
+ .filter(([, members]) => members.length > 0)),
842
+ ...(config.defaultProfile === name ? { defaultProfile: undefined } : {}),
843
+ });
844
+ output(io, global.json, { removed: name }, `Removed profile ${name}. Its connection and credential were kept.`);
845
+ });
846
+ profileCommand
847
+ .command('default <name>')
848
+ .description('Make a profile the default when nothing else selects one')
849
+ .action((name, _options, command) => {
850
+ const global = globals(command);
851
+ const store = profileStoreFor(global.home);
852
+ const config = store.read();
853
+ if (!config.profiles[name])
854
+ throw new SynomemError('CONFIG_INVALID', `Unknown profile "${name}".`);
855
+ store.write({ ...config, defaultProfile: name });
856
+ output(io, global.json, { defaultProfile: name }, `Default profile: ${name}.`);
857
+ });
858
+ profileCommand
859
+ .command('use <name>')
860
+ .description('Bind this directory to a profile (writes .synomem/project.json)')
861
+ .action((name, _options, command) => {
862
+ const global = globals(command);
863
+ const config = profileStoreFor(global.home).read();
864
+ if (!config.profiles[name])
865
+ throw new SynomemError('CONFIG_INVALID', `Unknown profile "${name}".`);
866
+ const path = writeProjectSelection(cwd, { profile: name });
867
+ output(io, global.json, { path, profile: name }, `Wrote ${path}\n\nCommands and MCP servers started here now act as profile ${name} unless --profile or SYNOMEM_PROFILE says otherwise.`);
868
+ });
869
+ const presetCommand = program
870
+ .command('preset')
871
+ .description('Several profiles served by one explicit-context MCP server');
872
+ presetCommand
873
+ .command('create <name> <profiles...>')
874
+ .description('Create a preset from existing profiles')
875
+ .action((rawName, members, _options, command) => {
876
+ const global = globals(command);
877
+ const name = assertName(rawName, 'preset');
878
+ const store = profileStoreFor(global.home);
879
+ const config = store.read();
880
+ if (config.harnessPresets[name])
881
+ throw new SynomemError('CONFIG_INVALID', `Preset ${name} already exists.`);
882
+ const missing = members.filter((member) => !config.profiles[member]);
883
+ if (missing.length)
884
+ throw new SynomemError('CONFIG_INVALID', `Unknown profiles: ${missing.join(', ')}.`);
885
+ const contexts = members.map((member) => config.profiles[member].contextId);
886
+ if (new Set(contexts).size !== contexts.length) {
887
+ throw new SynomemError('CONFIG_INVALID', 'Two of those profiles select the same context.');
888
+ }
889
+ store.write({ ...config, harnessPresets: { ...config.harnessPresets, [name]: members } });
890
+ output(io, global.json, { preset: name, profiles: members }, `Created preset ${name}: ${members.join(', ')}.\n\nStart it with:\n synomem mcp --preset ${name} --contexts explicit`);
891
+ });
892
+ presetCommand.command('list').action((_options, command) => {
893
+ const global = globals(command);
894
+ const config = profileStoreFor(global.home).read();
895
+ output(io, global.json, { presets: config.harnessPresets }, Object.entries(config.harnessPresets)
896
+ .map(([name, members]) => `${name}: ${members.join(', ')}`)
897
+ .join('\n') || 'No presets.');
898
+ });
899
+ presetCommand.command('remove <name>').action((name, _options, command) => {
900
+ const global = globals(command);
901
+ const store = profileStoreFor(global.home);
902
+ const config = store.read();
903
+ if (!config.harnessPresets[name])
904
+ throw new SynomemError('CONFIG_INVALID', `Unknown preset "${name}".`);
905
+ const harnessPresets = Object.fromEntries(Object.entries(config.harnessPresets).filter(([preset]) => preset !== name));
906
+ store.write({ ...config, harnessPresets });
907
+ output(io, global.json, { removed: name }, `Removed preset ${name}.`);
908
+ });
909
+ /* ------------------------------------------------------------ identity */
910
+ program
911
+ .command('whoami')
912
+ .description('Show the selected profile, its effective context, and its credential source')
913
+ .action(async (_options, command) => {
914
+ const global = globals(command);
915
+ const config = profileStoreFor(global.home).read();
916
+ const selection = selectionFor(global, config);
917
+ if (!selection)
918
+ throw noSelectionError();
919
+ const deps = resolverDeps(global.home);
920
+ if (selection.kind === 'preset') {
921
+ const resolver = presetResolver(global.home, config, selection.name, deps);
922
+ try {
923
+ const listing = await resolver.list();
924
+ output(io, global.json, { selection, listing }, [
925
+ `Preset ${selection.name} (from ${selection.source}) — explicit contexts:`,
926
+ ...listing.contexts.map((context) => ` ${context.contextId} ${describeContext(context)}`),
927
+ ].join('\n'));
928
+ }
929
+ finally {
930
+ await resolver.close?.();
931
+ }
932
+ return;
933
+ }
934
+ const profile = config.profiles[selection.name];
935
+ const resolver = profileResolver(global.home, config, selection.name, deps);
936
+ try {
937
+ const { context } = await resolver.resolve();
938
+ const description = await resolver.describe?.().catch(() => undefined);
939
+ const source = isLocalProfile(profile)
940
+ ? `local store ${profile.home ?? global.home}`
941
+ : `connection ${profile.credentialRef} (${config.credentials[profile.credentialRef]?.store ?? '?'})`;
942
+ output(io, global.json, {
943
+ selection,
944
+ effectiveContext: context,
945
+ credentialSource: source,
946
+ identity: description ?? null,
947
+ }, [
948
+ `Profile: ${selection.name} (from ${selection.source})`,
949
+ `Acting as: ${describeEffective(context)}`,
950
+ `Context: ${context.contextId}`,
951
+ `Source: ${source}`,
952
+ ...(description?.connection
953
+ ? [`Connection: ${description.connection.label} (${description.connection.id})`]
954
+ : []),
955
+ ...(description?.grant
956
+ ? [`Grant: ${description.grant.mode}, ${description.grant.actions.join(' ')}`]
957
+ : []),
958
+ ].join('\n'));
959
+ }
960
+ finally {
961
+ await resolver.close?.();
962
+ }
963
+ });
964
+ /* ------------------------------------------------------------ local stores */
965
+ const workspaceCommand = program
966
+ .command('workspace')
967
+ .description('Local stores on this machine, each a separate SQLite database');
968
+ workspaceCommand
969
+ .command('list')
970
+ .description('List the local stores on this machine')
971
+ .action((_options, command) => {
972
+ const global = globals(command);
973
+ const workspaces = listLocalWorkspaces(global.home);
974
+ output(io, global.json, { workspaces }, workspaces
975
+ .map((workspace) => `${workspace.name.padEnd(24)} ${workspace.initialized ? workspace.home : `${workspace.home} (not initialized)`}`)
976
+ .join('\n') || 'No local stores.');
977
+ });
397
978
  workspaceCommand
398
979
  .command('create <name>')
399
- .description('Create a local workspace and initialize its store')
400
- .action(async (name, _options, command) => {
980
+ .description('Create a local store (then `profile create --local --store-home <path>`)')
981
+ .action((name, _options, command) => {
401
982
  const global = globals(command);
402
983
  const home = localWorkspaceHome(name, global.home);
403
- if (readSynomemConfig(home)) {
984
+ if (existsSync(join(home, 'config.json'))) {
404
985
  throw new SynomemError('INVALID_INPUT', `Workspace already exists: ${name}`);
405
986
  }
406
- writeSynomemBackend({ kind: 'local' }, home);
407
- // Opening it once creates the database, so `list` does not report a
408
- // workspace that exists in name only.
409
- await withClient(home, defaultActor(env, 'system', 'cli', global.actor), async () => undefined);
410
- output(io, global.json, { name, home }, `Created workspace ${name} at ${home}.\nAct in it with --workspace ${name}.`);
411
- });
412
- const remoteCommand = program.command('remote').description('Administer a remote workspace');
413
- /*
414
- * The browser counterpart to `discoverAccessKeyWorkspaces` — same purpose,
415
- * different credential: a signed-in account may reach several
416
- * organizations, each with several workspaces, so there is a genuine choice
417
- * to make, and no way to make it without seeing the list. Printing the IDs
418
- * alongside the names is the point: the ID is what `backend use remote
419
- * --workspace` takes.
420
- */
421
- remoteCommand
422
- .command('workspaces')
423
- .description('List the organizations and workspaces this credential can reach')
424
- .option('--url <url>', 'internal: alternate HTTPS origin')
425
- .action(async (options, command) => {
426
- const global = globals(command);
427
- const config = readSynomemConfig(global.home, env);
428
- const baseUrl = options.url ??
429
- (config?.backend.kind === 'remote' ? config.backend.baseUrl : cloudApiUrl(env));
430
- const accessToken = env.SYNOMEM_ACCESS_TOKEN;
431
- if (!accessToken) {
432
- throw new SynomemError('AUTH_REQUIRED', 'Set SYNOMEM_ACCESS_TOKEN, or run `synomem auth login` first.');
433
- }
434
- const organizations = await discoverOrganizations({ baseUrl, accessToken });
435
- const choices = workspaceChoices(organizations);
436
- const human = organizations.length
437
- ? organizations
438
- .map((organization) => [
439
- `${organization.displayName} (${organization.slug}) — ${organization.role}`,
440
- ...(organization.workspaces.length
441
- ? organization.workspaces.map((workspace) => ` ${workspace.id} ${workspace.displayName}`)
442
- : [' (no workspaces yet)']),
443
- ].join('\n'))
444
- .join('\n')
445
- : 'This account belongs to no organizations yet.';
446
- output(io, global.json, { organizations, choices }, human);
987
+ ensureLocalStore(home);
988
+ output(io, global.json, { name, home }, `Created local store ${name} at ${home}.`);
447
989
  });
990
+ /* ------------------------------------------------------------ remote import */
991
+ const remoteCommand = program.command('remote').description('Hosted workspace administration');
448
992
  remoteCommand
449
993
  .command('import')
450
- .description('Preview or confirm a one-way import from a local Synomem home')
994
+ .description('Preview or confirm a one-way import from a local store into the profile’s workspace')
451
995
  .requiredOption('--from-home <path>', 'source local Synomem home')
452
- .requiredOption('--actor-id <id>', 'bound human administrator actor ID')
453
- .option('--actor-name <name>', 'expected administrator display name')
454
996
  .option('--preview', 'validate and return a short-lived import plan')
455
997
  .option('--confirm <plan-id>', 'commit the exact bundle authorized by a preview')
456
998
  .action(async (options, command) => {
@@ -458,29 +1000,33 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
458
1000
  if (Boolean(options.preview) === Boolean(options.confirm)) {
459
1001
  throw new SynomemError('INVALID_INPUT', 'Choose exactly one of --preview or --confirm.');
460
1002
  }
461
- const config = readSynomemConfig(global.home, env);
462
- if (config?.backend.kind !== 'remote') {
463
- throw new SynomemError('INVALID_INPUT', 'Select a remote backend before importing.');
1003
+ const config = profileStoreFor(global.home).read();
1004
+ const selection = selectionFor(global, config);
1005
+ if (!selection || selection.kind !== 'profile')
1006
+ throw noSelectionError();
1007
+ const profile = config.profiles[selection.name];
1008
+ if (isLocalProfile(profile)) {
1009
+ throw new SynomemError('INVALID_INPUT', 'Importing needs a hosted profile (a human context with administration).');
464
1010
  }
465
- const backend = config.backend;
466
- const administrator = actor('human', options.actorId, options.actorName);
1011
+ const entry = config.credentials[profile.credentialRef];
467
1012
  const bundle = await (dependencies.createImportBundle ?? createLocalImportBundle)(options.fromHome);
1013
+ const workspaceId = await withProfile(command, async (_service, context) => context.workspaceId);
468
1014
  const result = dependencies.remoteImport
469
1015
  ? await dependencies.remoteImport({
470
- baseUrl: backend.baseUrl,
471
- workspaceId: backend.workspaceId,
472
- actor: administrator,
1016
+ baseUrl: entry.apiUrl,
1017
+ workspaceId,
1018
+ contextId: profile.contextId,
473
1019
  bundle,
474
1020
  ...(options.confirm ? { planId: options.confirm } : {}),
475
1021
  })
476
1022
  : await (async () => {
477
- const credentialProvider = env.SYNOMEM_ACCESS_TOKEN
478
- ? { getAccessToken: async () => env.SYNOMEM_ACCESS_TOKEN }
479
- : new StoredCredentialProvider(credentialReference(backend.baseUrl, backend.workspaceId, administrator), credentialStore, env, fetch, resolveHome(global.home));
1023
+ const source = credentialSourceFor(global.home, config, profile.credentialRef, resolverDeps(global.home));
480
1024
  const importer = new RemoteImportClient({
481
- baseUrl: backend.baseUrl,
482
- workspaceId: backend.workspaceId,
483
- credentialProvider,
1025
+ baseUrl: entry.apiUrl,
1026
+ workspaceId,
1027
+ contextId: profile.contextId,
1028
+ credentialProvider: { getAccessToken: () => source.bearer() },
1029
+ fetch: fetchImplementation,
484
1030
  });
485
1031
  return options.confirm
486
1032
  ? await importer.confirm(bundle, options.confirm)
@@ -499,527 +1045,72 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
499
1045
  : `Imported ${result.events} events and ${result.profiles} agent profiles into ${result.targetWorkspaceId}.`;
500
1046
  output(io, global.json, result, human);
501
1047
  });
1048
+ /* ------------------------------------------------------------ reset */
502
1049
  program
503
- .command('init')
504
- .description('Initialize the local Synomem database')
505
- .action(async (_options, command) => {
506
- const options = globals(command);
507
- const persisted = readSynomemConfig(options.home);
508
- if (persisted?.backend.kind === 'remote') {
509
- throw new SynomemError('INVALID_INPUT', 'The init command requires a local backend.');
510
- }
511
- await withClient(options.home, defaultActor(env, 'system', 'cli', options.actor), async (client) => {
512
- const info = await client.info();
513
- if (info.backend !== 'local') {
514
- throw new SynomemError('INVALID_INPUT', 'The init command requires a local backend.');
515
- }
516
- output(io, options.json, { home: info.home, database: info.databasePath }, `Initialized Synomem at ${info.home}`);
517
- });
518
- });
519
- /*
520
- * `synomem config` is the canonical entry point. `configure` and `setup` are
521
- * accepted because people reach for them, and a setup program that rejects
522
- * the word somebody guessed is needlessly unhelpful.
523
- */
524
- const configCommand = program
525
- .command('config')
526
- .aliases(['configure', 'setup'])
527
- .description('Set up Synomem, interactively or deterministically');
528
- const applyPlan = async (plan, token, global) => {
529
- const home = plan.home;
530
- const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
531
- /*
532
- * A member-owned access key reaches every workspace in its organization,
533
- * never just one, so the workspace is discovered and then chosen — not
534
- * typed from memory, and not assumed. An explicit --workspace still wins
535
- * regardless: automation should not depend on a network round trip, and a
536
- * person who already knows which one they want should not be asked again.
537
- */
538
- let workspaceId = plan.workspaceId;
539
- // Only known once discovered below — printed in the final summary rather
540
- // than the bare ID, so "what did config just do" reads like an answer
541
- // instead of a lookup key.
542
- let workspaceName;
543
- if (plan.backend === 'remote' && !workspaceId && token) {
544
- const discovered = await discoverWorkspaces({ baseUrl: serviceUrl, accessToken: token });
545
- if (discovered.workspaces.length === 0) {
546
- throw new SynomemError('INVALID_INPUT', [
547
- "This access key's organization has no workspaces yet.",
548
- 'Create one in the Synomem portal, then re-run this command — or pass',
549
- '--workspace <workspace-id> once one exists.',
550
- ].join('\n'));
551
- }
552
- else if (discovered.workspaces.length === 1) {
553
- workspaceId = discovered.workspaces[0].id;
554
- workspaceName = discovered.workspaces[0].displayName;
555
- }
556
- else if (promptIo.interactive) {
557
- workspaceId = await select(promptIo, 'Which workspace should this machine use?', discovered.workspaces.map((workspace) => ({
558
- value: workspace.id,
559
- label: workspace.displayName,
560
- detail: workspace.id,
561
- })));
562
- workspaceName = discovered.workspaces.find((w) => w.id === workspaceId)?.displayName;
563
- }
564
- else {
565
- throw new SynomemError('INVALID_INPUT', [
566
- 'This access key can reach more than one workspace, so a non-interactive',
567
- 'setup needs to be told which one:',
568
- '',
569
- ...discovered.workspaces.map((workspace) => ` ${workspace.id} ${workspace.displayName}`),
570
- '',
571
- 'Re-run with --workspace <workspace-id>.',
572
- ].join('\n'));
573
- }
574
- }
575
- if (plan.backend === 'remote' && !workspaceId) {
576
- /*
577
- * Signing in through a browser needs an actor identity and a client ID,
578
- * which is `synomem auth login`'s job. Rather than write a remote
579
- * backend with no workspace -- a configuration that fails on its first
580
- * real use -- say exactly what remains.
581
- */
582
- output(io, global.json, { applied: false, pending: 'sign-in', home, serviceUrl }, [
583
- '',
584
- 'Nothing was configured yet: signing in through a browser is a separate step.',
585
- '',
586
- 'Run, with the actor this machine acts as:',
587
- '',
588
- ' synomem auth login --actor-id <agent> --client-id <client>',
589
- '',
590
- 'Then select the workspace it reports:',
1050
+ .command('reset')
1051
+ .description('Remove Synomem configuration, the local database, profiles and stored secrets')
1052
+ .option('--integrations', 'also remove installed skills', false)
1053
+ .option('--yes', 'apply the displayed plan', false)
1054
+ .action(async (options, command) => {
1055
+ const global = globals(command);
1056
+ const home = global.home;
1057
+ const store = profileStoreFor(home);
1058
+ const config = existsSync(store.path) ? store.read() : undefined;
1059
+ const secrets = Object.entries(config?.credentials ?? {})
1060
+ .filter(([, entry]) => entry.store !== 'environment')
1061
+ .map(([name, entry]) => ({ name, entry }));
1062
+ // Every target is an exact path; no recursive delete is derived from a
1063
+ // variable that might be empty.
1064
+ const targets = [
1065
+ join(home, 'config.json'),
1066
+ join(home, 'profiles.json'),
1067
+ join(home, 'synomem.sqlite3'),
1068
+ join(home, 'synomem.sqlite3-wal'),
1069
+ join(home, 'synomem.sqlite3-shm'),
1070
+ ...secrets
1071
+ .filter(({ entry }) => entry.store === 'file')
1072
+ .map(({ entry }) => join(home, 'credentials', `${entry.secretRef}.json`)),
1073
+ ].filter((path) => existsSync(path));
1074
+ const keychain = secrets
1075
+ .filter(({ entry }) => entry.store === 'keychain')
1076
+ .map(({ name }) => name);
1077
+ const skillPlan = options.integrations ? uninstallSkill({ apply: false }) : undefined;
1078
+ const skillTargets = skillPlan?.locations
1079
+ .filter((location) => location.state === 'current' || location.state === 'stale')
1080
+ .map((location) => location.target) ?? [];
1081
+ if (!options.yes) {
1082
+ output(io, global.json, { targets, keychain, skillTargets, applied: false }, [
1083
+ 'This will remove:',
1084
+ ...(targets.length ? targets.map((path) => ` ${path}`) : [' (no files found)']),
1085
+ ...(keychain.length
1086
+ ? [
1087
+ '',
1088
+ 'And the keychain secrets of connections:',
1089
+ ...keychain.map((name) => ` ${name}`),
1090
+ ]
1091
+ : []),
1092
+ ...(skillTargets.length
1093
+ ? ['', 'And these Synomem-owned skills:', ...skillTargets.map((path) => ` ${path}`)]
1094
+ : []),
591
1095
  '',
592
- ' synomem backend use remote --workspace <workspace-id>',
1096
+ 'Server-side authorizations are not revoked; do that in the portal.',
1097
+ 'Run with --yes to continue.',
593
1098
  ].join('\n'));
594
1099
  return;
595
1100
  }
596
- const config = writeSynomemBackend(plan.backend === 'local'
597
- ? { kind: 'local' }
598
- : { kind: 'remote', baseUrl: serviceUrl, workspaceId: workspaceId }, home);
599
- let credentialLocation;
600
- if (token) {
601
- if (plan.credentialStore === 'environment') {
602
- io.stdout(`${environmentInstructions(token)}\n`);
603
- credentialLocation = 'environment';
604
- }
605
- else if (plan.credentialStore === 'file') {
606
- credentialLocation = writeCredentialFile(home, token);
607
- }
608
- else {
609
- // The platform store is the default, and a failure falls back to the
610
- // restricted file rather than leaving the credential nowhere.
611
- try {
612
- await credentialStore.set(`synomem:${workspaceId}`, {
613
- kind: 'installation-key',
614
- accessToken: token,
615
- });
616
- credentialLocation = 'platform credential store';
617
- }
618
- catch {
619
- credentialLocation = writeCredentialFile(home, token);
620
- }
621
- }
622
- }
623
- // Diagnostics run before success is claimed: a configuration that cannot
624
- // open its own database is not a finished setup.
625
- const diagnostics = await withClient(home, defaultActor(env, 'system', 'cli'), (client) => client.doctor());
626
- /*
627
- * What to do next differs by exactly one thing: whether an agent can be
628
- * created from here at all. An access key never carries the
629
- * administrator authority agent creation requires (§ deliberate,
630
- * independent of the key owner's own role) — local storage and a human
631
- * browser sign-in both can. Telling everyone to just run `agent create`
632
- * regardless was the CLI recommending a command guaranteed to fail for
633
- * the single most common setup path.
634
- */
635
- const canCreateAgentHere = config.backend.kind === 'local' || plan.auth !== 'access-key';
636
- const nextSteps = canCreateAgentHere
637
- ? [
638
- ' synomem agent create <handle> --name "<display name>"',
639
- ' synomem skill install --runtime <claude|codex|cursor|...> --agent <agent-id>',
640
- ]
641
- : [
642
- " An access key can't create an agent — that needs an administrator,",
643
- " which a key never asserts on its own, regardless of the member's own",
644
- " role. Create one in the Synomem portal (a workspace's Actors page →",
645
- ' New agent), then use its ID:',
646
- '',
647
- ' synomem skill install --runtime <claude|codex|cursor|...> --agent <agent-id>',
648
- ];
649
- output(io, global.json, {
650
- backend: config.backend,
651
- home,
652
- ...(credentialLocation ? { credentialSource: credentialLocation } : {}),
653
- ...(token ? { credential: credentialFingerprint(token) } : {}),
654
- healthy: diagnostics.healthy,
655
- canCreateAgentHere,
656
- }, [
657
- '',
658
- 'Synomem is ready.',
659
- '',
660
- ` Backend: ${config.backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
661
- ` Home: ${home}`,
662
- ...(config.backend.kind === 'remote'
663
- ? [
664
- ` Service: ${config.backend.baseUrl}`,
665
- ` Workspace: ${workspaceName ? `${workspaceName} (${config.backend.workspaceId})` : config.backend.workspaceId}`,
666
- ]
667
- : []),
668
- ...(credentialLocation ? [` Credential: ${credentialLocation}`] : []),
669
- ` Database: ${diagnostics.healthy ? 'Healthy' : 'Needs attention — run synomem doctor'}`,
670
- '',
671
- 'Next: set up an agent for this machine.',
672
- '',
673
- ...nextSteps,
674
- '',
675
- 'Full walkthrough, including the MCP connector for each platform, and a',
676
- 'prompt that does all of this for you:',
677
- ' https://github.com/Coaden/synomem#let-your-agent-set-it-up',
678
- ].join('\n'));
679
- };
680
- configCommand.action(async (_options, command) => {
681
- const global = globals(command);
682
- assertInteractive(promptIo);
683
- const plan = await runConfigWizard(promptIo, {
684
- ...(global.home ? { home: global.home } : {}),
685
- env,
686
- });
687
- const token = plan.auth === 'access-key' ? await readAccessToken(promptIo) : undefined;
688
- if (!(await confirmPlan(promptIo, plan))) {
689
- output(io, global.json, { applied: false }, 'Nothing was changed.');
690
- return;
691
- }
692
- await applyPlan(plan, token, global);
693
- });
694
- configCommand
695
- .command('init')
696
- .description('Configure Synomem without prompting')
697
- .option('--backend <kind>', 'local or remote')
698
- .option('--auth <method>', 'browser or access-key')
699
- .option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
700
- // The token is read from stdin, never taken as an argument: an argument is
701
- // kept by the shell history and visible in the process list.
702
- .option('--access-token-stdin', 'read the access key from stdin', false)
703
- .option('--yes', 'apply without confirming', false)
704
- .action(async (options, command) => {
705
- const global = globals(command);
706
- if (options.backend !== 'local' && options.backend !== 'remote') {
707
- throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
708
- }
709
- const backend = options.backend;
710
- // An access key can be asked which workspaces it reaches, so
711
- // --workspace is only required when there is no key to ask.
712
- if (backend === 'remote' && !global.workspace && !options.accessTokenStdin) {
713
- throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace, or --access-token-stdin so the key can be asked.');
714
- }
715
- const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
716
- if (backend === 'remote' && options.auth === 'access-key' && !token) {
717
- throw new SynomemError('INVALID_INPUT', 'Access-key setup requires --access-token-stdin so the key is not passed as an argument.');
718
- }
719
- const plan = {
720
- backend,
721
- home: resolveHome(global.home),
722
- ...(backend === 'remote'
723
- ? {
724
- serviceUrl: cloudApiUrl(env),
725
- auth: options.auth ?? 'access-key',
726
- workspaceId: global.workspace,
727
- credentialStore: options.credentialStore,
728
- }
729
- : {}),
730
- };
731
- if (!options.yes) {
732
- throw new SynomemError('INVALID_INPUT', 'Re-run with --yes to apply this configuration.');
733
- }
734
- await applyPlan(plan, token, global);
735
- });
736
- configCommand
737
- .command('show')
738
- .description('Show the current configuration without revealing secrets')
739
- .action(async (_options, command) => {
740
- const global = globals(command);
741
- const home = resolveHome(global.home);
742
- const config = readSynomemConfig(global.home, env);
743
- const backend = config?.backend ?? { kind: 'local' };
744
- const credentialSource = env.SYNOMEM_ACCESS_TOKEN
745
- ? 'environment (SYNOMEM_ACCESS_TOKEN)'
746
- : existsSync(join(home, 'credentials', 'installation.json'))
747
- ? 'restricted file'
748
- : 'platform credential store or none';
749
- output(io, global.json,
750
- // Never the secret itself, only where it comes from.
751
- { backend, home, credentialSource, stores: credentialStoreChoices().map((c) => c.value) }, [
752
- `Backend: ${backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
753
- `Home: ${home}`,
754
- ...(backend.kind === 'remote'
755
- ? [`Service: ${backend.baseUrl}`, `Workspace: ${backend.workspaceId}`]
756
- : []),
757
- `Credential: ${credentialSource}`,
758
- ].join('\n'));
759
- });
760
- program
761
- .command('reset')
762
- .description('Remove Synomem configuration, database and credentials')
763
- // Integrations are opt-in because they live in other tools' directories.
764
- // Removing somebody's harness configuration as a side effect of resetting
765
- // Synomem would be a surprise with no undo.
766
- .option('--integrations', 'also remove installed skills and MCP registrations', false)
767
- .option('--yes', 'apply the displayed plan', false)
768
- .action(async (options, command) => {
769
- const global = globals(command);
770
- const home = resolveHome(global.home);
771
- /*
772
- * Every target is an exact path, listed before anything is touched. No
773
- * recursive delete is ever derived from a variable that might be empty:
774
- * a reset that computes `rm -rf $HOME/` from an unset home is the
775
- * failure this shape exists to make impossible.
776
- */
777
- const targets = [
778
- join(home, 'config.json'),
779
- join(home, 'synomem.sqlite3'),
780
- join(home, 'synomem.sqlite3-wal'),
781
- join(home, 'synomem.sqlite3-shm'),
782
- join(home, 'credentials', 'installation.json'),
783
- ].filter((path) => existsSync(path));
784
- const skillPlan = options.integrations ? uninstallSkill({ apply: false }) : undefined;
785
- const skillTargets = skillPlan?.locations
786
- // Installed Synomem-owned copies only; an unowned directory at
787
- // the same path is not ours to remove.
788
- .filter((location) => location.state === 'current' || location.state === 'stale')
789
- .map((location) => location.target) ?? [];
790
- if (!options.yes) {
791
- output(io, global.json, { targets, skillTargets, applied: false }, [
792
- 'This will remove:',
793
- ...(targets.length ? targets.map((path) => ` ${path}`) : [' (nothing found)']),
794
- ...(skillTargets.length ? ['', 'And these Synomem-owned skills:'] : []),
795
- ...skillTargets.map((path) => ` ${path}`),
796
- '',
797
- ...(options.integrations
798
- ? []
799
- : ['Installed skills and MCP registrations are left alone.', '']),
800
- 'Run with --yes to continue.',
801
- ].join('\n'));
802
- return;
1101
+ const stores = storesFor(home);
1102
+ for (const { entry } of secrets.filter(({ entry }) => entry.store === 'keychain')) {
1103
+ await stores.keychain.delete(entry.secretRef).catch(() => false);
803
1104
  }
804
- const removed = [];
805
- for (const path of targets) {
1105
+ for (const path of targets)
806
1106
  rmSync(path, { force: true });
807
- removed.push(path);
808
- }
809
- // Only ownership-stamped Synomem skills are removed, which uninstall
810
- // already enforces — an unowned directory at the same path is left.
811
1107
  const skillResult = options.integrations ? uninstallSkill({ apply: true }) : undefined;
812
- output(io, global.json, { removed, skills: skillResult?.locations ?? [] }, [
813
- `Removed ${removed.length} file(s).`,
814
- ...(skillResult
815
- ? [`Skill locations processed: ${skillResult.locations.length}.`]
816
- : ['Installed skills and MCP registrations were left alone.']),
1108
+ output(io, global.json, { removed: targets, keychain, skills: skillResult?.locations ?? [] }, [
1109
+ `Removed ${targets.length} file(s)${keychain.length ? ` and ${keychain.length} keychain secret(s)` : ''}.`,
1110
+ ...(skillResult ? [`Skill locations processed: ${skillResult.locations.length}.`] : []),
817
1111
  ].join('\n'));
818
1112
  });
819
- const backendCommand = program
820
- .command('backend')
821
- .description('Inspect or select the canonical backend');
822
- backendCommand
823
- .command('show')
824
- .description('Show backend selection without connecting')
825
- .action((_options, command) => {
826
- const global = globals(command);
827
- const config = readSynomemConfig(global.home);
828
- const backend = config?.backend ?? { kind: 'local' };
829
- const human = backend.kind === 'local'
830
- ? `Backend: local${config ? `\nWorkspace: ${config.workspaceId}` : ' (not initialized)'}`
831
- : `Backend: remote\nURL: ${backend.baseUrl}\nWorkspace: ${backend.workspaceId}`;
832
- output(io, global.json, { backend, initialized: config !== undefined }, human);
833
- });
834
- backendCommand
835
- .command('use')
836
- .description('Select local or remote canonical state')
837
- .argument('<kind>', 'local or remote')
838
- // --url is for development and private deployments. It stays out of the
839
- // README, the public docs and the packaged skill: public onboarding must
840
- // never ask for a service address, because a person has no way to tell a
841
- // real one from a phished one.
842
- .option('--url <url>', 'internal: alternate HTTPS origin')
843
- .action((kind, options, command) => {
844
- const global = globals(command);
845
- if (kind !== 'local' && kind !== 'remote') {
846
- throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
847
- }
848
- if (kind === 'remote' && !global.workspace) {
849
- throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --workspace.');
850
- }
851
- const config = writeSynomemBackend(kind === 'local'
852
- ? { kind: 'local' }
853
- : {
854
- kind: 'remote',
855
- baseUrl: options.url ?? cloudApiUrl(env),
856
- workspaceId: global.workspace,
857
- }, global.home);
858
- output(io, global.json, { backend: config.backend }, `Selected ${config.backend.kind} Synomem backend.`);
859
- });
860
- /*
861
- * `show` reads the config file; `status` proves the selection actually works.
862
- *
863
- * The two are deliberately separate. A person debugging a broken setup needs
864
- * to know what is configured even when nothing can be reached, and a person
865
- * checking that a setup is live needs a connection to have been made. One
866
- * command doing both would make a printed workspace ID look like a reachable
867
- * workspace.
868
- */
869
- backendCommand
870
- .command('status')
871
- .description('Connect to the selected backend and report what answered')
872
- .action(async (_options, command) => {
873
- const global = globals(command);
874
- const config = readSynomemConfig(global.home);
875
- if (!config) {
876
- throw new SynomemError('CONFIG_INVALID', 'No Synomem home here yet. Run `synomem config init` first.');
877
- }
878
- const result = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), async (client) => ({
879
- info: await client.info(),
880
- capabilities: await client.capabilities(),
881
- diagnostics: (await client.doctor()).diagnostics.filter((item) => item.level === 'error' || item.level === 'warning'),
882
- }));
883
- const { info, capabilities, diagnostics } = result;
884
- const where = info.backend === 'local'
885
- ? `Home: ${info.home}\nDatabase: ${info.databasePath}`
886
- : `URL: ${info.baseUrl}`;
887
- const problems = diagnostics.length
888
- ? diagnostics
889
- .map((item) => `${item.level.toUpperCase()} ${item.code}: ${item.message}`)
890
- .join('\n')
891
- : 'No warnings or errors.';
892
- const human = [
893
- `Backend: ${info.backend} (reachable)`,
894
- where,
895
- `Workspace: ${capabilities.binding.workspaceId}`,
896
- `Acting as: ${capabilities.binding.actor.kind} ${capabilities.binding.actor.id}`,
897
- problems,
898
- ].join('\n');
899
- output(io, global.json, { reachable: true, info, capabilities, diagnostics }, human);
900
- if (diagnostics.some((item) => item.level === 'error'))
901
- cliExitCodes.set(program, 5);
902
- });
903
- const projectionCommand = program
904
- .command('projection')
905
- .description('Inspect the generated files Synomem derives from events');
906
- projectionCommand
907
- .command('status')
908
- .description('Report whether the generated files match the canonical events')
909
- .action(async (_options, command) => {
910
- const global = globals(command);
911
- const status = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => {
912
- if (!client.projectionStatus) {
913
- throw new SynomemError('INVALID_INPUT', 'The remote backend keeps no filesystem projections, so there is nothing to report.');
914
- }
915
- return client.projectionStatus();
916
- });
917
- const enabled = Object.entries(status.settings)
918
- .filter(([, on]) => on)
919
- .map(([name]) => name);
920
- const lines = [
921
- `Directory: ${status.directory ?? '(none)'}`,
922
- `Enabled: ${enabled.length ? enabled.join(', ') : 'none'}`,
923
- `Last rebuilt: ${status.lastRebuiltAt ?? 'never'}`,
924
- status.current
925
- ? `Current: ${status.counts.manifest} generated file(s) match the events.`
926
- : `Stale: ${status.counts.missing} missing, ${status.counts.unexpected} no longer expected. Run \`synomem rebuild\`.`,
927
- ];
928
- for (const path of status.missing)
929
- lines.push(` missing ${path}`);
930
- for (const path of status.unexpected)
931
- lines.push(` unexpected ${path}`);
932
- output(io, global.json, status, lines.join('\n'));
933
- });
934
- const authCommand = program.command('auth').description('Inspect remote authentication');
935
- authCommand
936
- .command('status')
937
- .description('Report token availability without printing it')
938
- .option('--actor-id <id>', 'bound actor ID (or SYNOMEM_ACTOR_ID)')
939
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
940
- .action(async (options, command) => {
941
- const global = globals(command);
942
- if (env.SYNOMEM_ACCESS_TOKEN) {
943
- output(io, global.json, { authenticated: true, source: 'environment' }, 'Remote authentication token is available from SYNOMEM_ACCESS_TOKEN.');
944
- return;
945
- }
946
- const config = readSynomemConfig(global.home, env);
947
- if (config?.backend.kind !== 'remote') {
948
- throw new SynomemError('INVALID_INPUT', 'Select a remote backend before checking authentication.');
949
- }
950
- const actorId = options.actorId ?? env.SYNOMEM_ACTOR_ID;
951
- if (!actorId)
952
- throw new SynomemError('INVALID_INPUT', 'Specify --actor-id.');
953
- const reference = credentialReference(config.backend.baseUrl, config.backend.workspaceId, actor(options.actorKind, actorId));
954
- const available = Boolean(await credentialStore.get(reference));
955
- output(io, global.json, { authenticated: available, source: available ? 'os-credential-store' : undefined }, available
956
- ? 'A remote credential is available in the operating-system credential store.'
957
- : 'Remote authentication is not configured.');
958
- });
959
- authCommand
960
- .command('login')
961
- .description('Authorize this actor with OAuth 2.1 authorization code and PKCE')
962
- .requiredOption('--actor-id <id>', 'bound actor ID')
963
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
964
- .option('--actor-name <name>', 'expected actor display name')
965
- .option('--client-id <id>', 'registered public OAuth client ID (or SYNOMEM_OAUTH_CLIENT_ID)')
966
- .option('--scope <scope>', 'requested OAuth scopes')
967
- .option('--callback-port <port>', 'loopback callback port', '43817')
968
- .action(async (options, command) => {
969
- const global = globals(command);
970
- const config = readSynomemConfig(global.home, env);
971
- if (config?.backend.kind !== 'remote') {
972
- throw new SynomemError('INVALID_INPUT', 'Select a remote backend before login.');
973
- }
974
- const configuredActor = actor(options.actorKind, options.actorId, options.actorName);
975
- const clientId = options.clientId ?? env.SYNOMEM_OAUTH_CLIENT_ID;
976
- if (!clientId)
977
- throw new SynomemError('INVALID_INPUT', 'Specify --client-id.');
978
- const callbackPort = Number(options.callbackPort);
979
- if (!Number.isSafeInteger(callbackPort) || callbackPort < 1 || callbackPort > 65_535) {
980
- throw new SynomemError('INVALID_INPUT', '--callback-port must be from 1 through 65535.');
981
- }
982
- const reference = credentialReference(config.backend.baseUrl, config.backend.workspaceId, configuredActor);
983
- await oauthLogin({
984
- baseUrl: config.backend.baseUrl,
985
- clientId,
986
- credentialReference: reference,
987
- credentialStore,
988
- callbackPort,
989
- ...(options.scope ? { scope: options.scope } : {}),
990
- });
991
- try {
992
- await verifyRemoteCredential({
993
- baseUrl: config.backend.baseUrl,
994
- workspaceId: config.backend.workspaceId,
995
- actor: configuredActor,
996
- reference,
997
- credentialStore,
998
- });
999
- }
1000
- catch (error) {
1001
- await credentialStore.delete(reference);
1002
- throw error;
1003
- }
1004
- output(io, global.json, { authenticated: true, source: 'os-credential-store', actor: configuredActor }, `Authorized ${configuredActor.kind}:${configuredActor.id}; the credential is stored by the operating system.`);
1005
- });
1006
- authCommand
1007
- .command('logout')
1008
- .description('Remove the stored OAuth credential for one actor')
1009
- .requiredOption('--actor-id <id>', 'bound actor ID')
1010
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1011
- .action(async (options, command) => {
1012
- const global = globals(command);
1013
- const config = readSynomemConfig(global.home, env);
1014
- if (config?.backend.kind !== 'remote') {
1015
- throw new SynomemError('INVALID_INPUT', 'Select a remote backend before logout.');
1016
- }
1017
- const configuredActor = actor(options.actorKind, options.actorId);
1018
- const removed = await credentialStore.delete(credentialReference(config.backend.baseUrl, config.backend.workspaceId, configuredActor));
1019
- output(io, global.json, { authenticated: false, removed }, removed
1020
- ? 'Removed the stored Synomem credential.'
1021
- : 'No stored Synomem credential existed.');
1022
- });
1113
+ /* ------------------------------------------------------------ agents */
1023
1114
  const agentCommand = program
1024
1115
  .command('agent')
1025
1116
  .description('Create and inspect stable agent identities');
@@ -1029,17 +1120,44 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1029
1120
  .requiredOption('--name <display-name>', 'display name')
1030
1121
  .option('--alias <name>', 'alias (repeatable)', collect, [])
1031
1122
  .option('--description <text>')
1123
+ .option('--create-profile', 'local store: also create a same-named fixed profile', false)
1032
1124
  .action(async (handle, options, command) => {
1033
1125
  const global = globals(command);
1034
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.create({
1035
- handle,
1036
- displayName: options.name,
1037
- ...(options.alias.length ? { aliases: options.alias } : {}),
1038
- ...(options.description ? { description: options.description } : {}),
1126
+ const created = await withManagement(command, async (service, context, storeHome) => ({
1127
+ agent: await service.agents.create({
1128
+ handle,
1129
+ displayName: options.name,
1130
+ ...(options.alias.length ? { aliases: options.alias } : {}),
1131
+ ...(options.description ? { description: options.description } : {}),
1132
+ }),
1133
+ remote: context !== undefined,
1134
+ storeHome,
1039
1135
  }));
1040
- // Both are printed because both matter: the handle is what people type,
1041
- // the ID is what every event records and what MCP registration uses.
1042
- output(io, global.json, profile, `Created ${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}`);
1136
+ const agent = created.agent;
1137
+ let profileName;
1138
+ if (options.createProfile) {
1139
+ if (created.remote || !created.storeHome) {
1140
+ throw new SynomemError('INVALID_INPUT', `Created ${agent.handle} (${agent.id}), but --create-profile works for local stores only. For a hosted agent, authorize it for a connection and run \`synomem profile create\`.`);
1141
+ }
1142
+ const store = profileStoreFor(global.home);
1143
+ const config = store.read();
1144
+ profileName = assertName(agent.handle, 'profile');
1145
+ if (config.profiles[profileName]) {
1146
+ throw new SynomemError('CONFIG_INVALID', `Created ${agent.handle} (${agent.id}), but profile ${profileName} already exists.`);
1147
+ }
1148
+ const profile = {
1149
+ backend: 'local',
1150
+ ...(created.storeHome !== global.home ? { home: created.storeHome } : {}),
1151
+ actorId: agent.id,
1152
+ actorName: agent.displayName,
1153
+ contextId: localContextId(localStoreWorkspaceId(created.storeHome), {
1154
+ kind: 'agent',
1155
+ id: agent.id,
1156
+ }),
1157
+ };
1158
+ store.write({ ...config, profiles: { ...config.profiles, [profileName]: profile } });
1159
+ }
1160
+ output(io, global.json, { ...agent, ...(profileName ? { profileCreated: profileName } : {}) }, `Created ${agent.displayName}\n\nHandle: ${agent.handle}\nAgent ID: ${agent.id}${profileName ? `\nProfile: ${profileName} (synomem mcp --profile ${profileName})` : ''}`);
1043
1161
  });
1044
1162
  const aliasCommand = agentCommand
1045
1163
  .command('alias')
@@ -1049,7 +1167,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1049
1167
  .description('Add aliases, keeping the ones already there')
1050
1168
  .action(async (agent, aliases, _options, command) => {
1051
1169
  const global = globals(command);
1052
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.addAliases(agent, aliases));
1170
+ const profile = await withManagement(command, (service) => service.agents.addAliases(agent, aliases));
1053
1171
  output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
1054
1172
  });
1055
1173
  aliasCommand
@@ -1057,7 +1175,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1057
1175
  .description('Remove aliases, keeping the rest')
1058
1176
  .action(async (agent, aliases, _options, command) => {
1059
1177
  const global = globals(command);
1060
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.removeAliases(agent, aliases));
1178
+ const profile = await withManagement(command, (service) => service.agents.removeAliases(agent, aliases));
1061
1179
  output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
1062
1180
  });
1063
1181
  agentCommand
@@ -1065,7 +1183,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1065
1183
  .description('Change an agent handle. Its canonical ID never changes.')
1066
1184
  .action(async (agent, handle, _options, command) => {
1067
1185
  const global = globals(command);
1068
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.update(agent, { handle }));
1186
+ const profile = await withManagement(command, (service) => service.agents.update(agent, { handle }));
1069
1187
  output(io, global.json, profile, `Handle: ${profile.handle}\nAgent ID: ${profile.id} (unchanged)`);
1070
1188
  });
1071
1189
  agentCommand
@@ -1073,7 +1191,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1073
1191
  .description('Stop an agent acting, keeping its records and history')
1074
1192
  .action(async (agent, _options, command) => {
1075
1193
  const global = globals(command);
1076
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.archive(agent));
1194
+ const profile = await withManagement(command, (service) => service.agents.archive(agent));
1077
1195
  output(io, global.json, profile, `Archived ${profile.handle} (${profile.id})`);
1078
1196
  });
1079
1197
  agentCommand
@@ -1081,9 +1199,132 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1081
1199
  .description('Let an archived agent act again')
1082
1200
  .action(async (agent, _options, command) => {
1083
1201
  const global = globals(command);
1084
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.restore(agent));
1202
+ const profile = await withManagement(command, (service) => service.agents.restore(agent));
1085
1203
  output(io, global.json, profile, `Restored ${profile.handle} (${profile.id})`);
1086
1204
  });
1205
+ agentCommand
1206
+ .command('list')
1207
+ .description('List known agent identities')
1208
+ .action(async (_options, command) => {
1209
+ const global = globals(command);
1210
+ const agents = await withManagement(command, (service) => service.agents.list());
1211
+ const human = agents.length
1212
+ ? agents
1213
+ .map((profile) => `${profile.handle} ${profile.displayName}${profile.status === 'archived' ? ' [archived]' : ''}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}\n ${profile.id}`)
1214
+ .join('\n')
1215
+ : 'No agents configured.';
1216
+ output(io, global.json, { agents }, human);
1217
+ });
1218
+ agentCommand
1219
+ .command('show <id>')
1220
+ .description('Show one agent profile, resolving aliases')
1221
+ .action(async (id, _options, command) => {
1222
+ const global = globals(command);
1223
+ const profile = await withManagement(command, (service) => service.agents.get(id));
1224
+ output(io, global.json, profile, `${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}\nStatus: ${profile.status}\n\n${profile.description ?? 'No description.'}`);
1225
+ });
1226
+ agentCommand
1227
+ .command('update <id>')
1228
+ .description('Update an agent profile without rewriting history')
1229
+ .option('--name <display-name>')
1230
+ .option('--alias <id>', 'replace aliases (repeatable)', collect, [])
1231
+ .option('--clear-aliases', 'remove every alias', false)
1232
+ .option('--description <text>')
1233
+ .action(async (id, options, command) => {
1234
+ const global = globals(command);
1235
+ const hasAliases = options.clearAliases || options.alias.length > 0;
1236
+ const profile = await withManagement(command, (service) => service.agents.update(id, {
1237
+ ...(options.name ? { displayName: options.name } : {}),
1238
+ ...(hasAliases ? { aliases: options.clearAliases ? [] : options.alias } : {}),
1239
+ ...(options.description !== undefined ? { description: options.description } : {}),
1240
+ }));
1241
+ output(io, global.json, profile, `Updated ${profile.displayName} (${profile.id})`);
1242
+ });
1243
+ agentCommand
1244
+ .command('resolve <name>')
1245
+ .description('Resolve a name or alias to one agent, or list the candidates')
1246
+ .action(async (name, _options, command) => {
1247
+ const global = globals(command);
1248
+ const resolution = await withManagement(command, (service) => service.agents.resolve(name));
1249
+ const human = resolution.match
1250
+ ? `${resolution.match.displayName} (${resolution.match.id})`
1251
+ : resolution.candidates.length
1252
+ ? `"${resolution.query}" is ambiguous. Candidates:\n${resolution.candidates
1253
+ .map((profile) => ` ${profile.id} ${profile.displayName}`)
1254
+ .join('\n')}`
1255
+ : `No agent answers to "${resolution.query}".`;
1256
+ output(io, global.json, resolution, human);
1257
+ });
1258
+ agentCommand
1259
+ .command('directory')
1260
+ .description('List agents with their runtime bindings')
1261
+ .action(async (_options, command) => {
1262
+ const global = globals(command);
1263
+ const entries = await withManagement(command, (service) => service.agents.directory());
1264
+ const human = entries.length
1265
+ ? entries
1266
+ .map((entry) => {
1267
+ const runtimes = entry.runtimeBindings.length
1268
+ ? entry.runtimeBindings
1269
+ .map((binding) => ` ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''}` +
1270
+ `${binding.lastSeenAt ? ` last seen ${binding.lastSeenAt}` : ' not yet seen'}`)
1271
+ .join('\n')
1272
+ : ' no runtime bindings';
1273
+ return `${entry.profile.id} ${entry.profile.displayName}\n${runtimes}`;
1274
+ })
1275
+ .join('\n')
1276
+ : 'No agents configured.';
1277
+ output(io, global.json, { entries }, human);
1278
+ });
1279
+ const runtimeCommand = agentCommand.command('runtime').description('Record where an agent runs');
1280
+ runtimeCommand
1281
+ .command('bind <agent>')
1282
+ .description('Bind an agent to a runtime')
1283
+ .requiredOption('--runtime <name>', 'runtime family, e.g. claude-code')
1284
+ .option('--runtime-profile <name>', 'named configuration within the runtime')
1285
+ .action(async (agent, options, command) => {
1286
+ const global = globals(command);
1287
+ const binding = await withManagement(command, (service) => service.agents.bindRuntime({
1288
+ agentId: agent,
1289
+ runtime: options.runtime,
1290
+ ...(options.runtimeProfile ? { profile: options.runtimeProfile } : {}),
1291
+ }));
1292
+ output(io, global.json, binding, `Bound ${binding.agentId} to ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} (${binding.id})`);
1293
+ });
1294
+ runtimeCommand
1295
+ .command('list [agent]')
1296
+ .description('List runtime bindings for one agent, or for every agent')
1297
+ .action(async (agent, _options, command) => {
1298
+ const global = globals(command);
1299
+ const result = await withManagement(command, async (service) => {
1300
+ if (agent) {
1301
+ const profile = await service.agents.get(agent);
1302
+ return [{ profile, runtimeBindings: await service.agents.bindings(agent) }];
1303
+ }
1304
+ return (await service.agents.directory()).filter((entry) => entry.runtimeBindings.length > 0);
1305
+ });
1306
+ const describe = (binding) => ` ${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`;
1307
+ const human = result.length
1308
+ ? result
1309
+ .map((entry) => [
1310
+ `${entry.profile.handle} (${entry.profile.id})`,
1311
+ ...entry.runtimeBindings.map(describe),
1312
+ ].join('\n'))
1313
+ .join('\n')
1314
+ : agent
1315
+ ? 'No runtime bindings.'
1316
+ : 'No agent in this workspace has a runtime binding.';
1317
+ output(io, global.json, { agents: result }, human);
1318
+ });
1319
+ runtimeCommand
1320
+ .command('unbind <binding-id>')
1321
+ .description('Remove a runtime binding')
1322
+ .action(async (bindingId, _options, command) => {
1323
+ const global = globals(command);
1324
+ const removed = await withManagement(command, (service) => service.agents.unbindRuntime(bindingId));
1325
+ output(io, global.json, { removed }, removed ? `Removed binding ${bindingId}.` : `No binding ${bindingId}.`);
1326
+ });
1327
+ /* ------------------------------------------------------------ topics */
1087
1328
  const topicCommand = program
1088
1329
  .command('topic')
1089
1330
  .description('Create and manage topics — a stable, reusable subject any record can carry');
@@ -1093,7 +1334,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1093
1334
  .option('--alias <name>', 'alias (repeatable)', collect, [])
1094
1335
  .action(async (displayName, options, command) => {
1095
1336
  const global = globals(command);
1096
- const topic = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.topics.create({
1337
+ const topic = await withProfile(command, (service) => service.topics.create({
1097
1338
  displayName,
1098
1339
  ...(options.alias.length ? { aliases: options.alias } : {}),
1099
1340
  }));
@@ -1105,7 +1346,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1105
1346
  .option('--status <status>', 'active or archived')
1106
1347
  .action(async (options, command) => {
1107
1348
  const global = globals(command);
1108
- const topics = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.topics.list(options.status ? { status: options.status } : {}));
1349
+ const topics = await withProfile(command, (service) => service.topics.list(options.status ? { status: options.status } : {}));
1109
1350
  const human = topics.length
1110
1351
  ? topics
1111
1352
  .map((topic) => `${topic.displayName}${topic.status === 'archived' ? ' [archived]' : ''}${topic.aliases?.length ? ` aliases: ${topic.aliases.join(', ')}` : ''}\n ${topic.id}`)
@@ -1118,7 +1359,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1118
1359
  .description('Show one topic, resolving aliases')
1119
1360
  .action(async (id, _options, command) => {
1120
1361
  const global = globals(command);
1121
- const topic = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.topics.get(id));
1362
+ const topic = await withProfile(command, (service) => service.topics.get(id));
1122
1363
  output(io, global.json, topic, `${topic.displayName}\n\nTopic ID: ${topic.id}\nStatus: ${topic.status}\nAliases: ${topic.aliases?.join(', ') ?? 'none'}`);
1123
1364
  });
1124
1365
  topicCommand
@@ -1126,7 +1367,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1126
1367
  .description('Resolve a name or alias to one topic, or list the candidates')
1127
1368
  .action(async (name, _options, command) => {
1128
1369
  const global = globals(command);
1129
- const resolution = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.topics.resolve(name));
1370
+ const resolution = await withProfile(command, (service) => service.topics.resolve(name));
1130
1371
  const human = resolution.match
1131
1372
  ? `${resolution.match.displayName} (${resolution.match.id})`
1132
1373
  : resolution.candidates.length
@@ -1141,7 +1382,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1141
1382
  .description("Change a topic's display name. Its ID never changes.")
1142
1383
  .action(async (id, displayName, _options, command) => {
1143
1384
  const global = globals(command);
1144
- const topic = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.topics.update(id, { displayName }));
1385
+ const topic = await withProfile(command, (service) => service.topics.update(id, { displayName }));
1145
1386
  output(io, global.json, topic, `Renamed to ${topic.displayName}\nTopic ID: ${topic.id} (unchanged)`);
1146
1387
  });
1147
1388
  topicCommand
@@ -1149,7 +1390,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1149
1390
  .description('Stop a topic being attached to new records, keeping the ones it already has')
1150
1391
  .action(async (id, _options, command) => {
1151
1392
  const global = globals(command);
1152
- const topic = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.topics.archive(id));
1393
+ const topic = await withProfile(command, (service) => service.topics.archive(id));
1153
1394
  output(io, global.json, topic, `Archived ${topic.displayName} (${topic.id})`);
1154
1395
  });
1155
1396
  topicCommand
@@ -1157,9 +1398,10 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1157
1398
  .description('Let an archived topic be attached to new records again')
1158
1399
  .action(async (id, _options, command) => {
1159
1400
  const global = globals(command);
1160
- const topic = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.topics.restore(id));
1401
+ const topic = await withProfile(command, (service) => service.topics.restore(id));
1161
1402
  output(io, global.json, topic, `Restored ${topic.displayName} (${topic.id})`);
1162
1403
  });
1404
+ /* ------------------------------------------------------------ skills */
1163
1405
  const skillCommand = program
1164
1406
  .command('skill')
1165
1407
  .description('Install and maintain the packaged agent skill');
@@ -1170,233 +1412,78 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1170
1412
  .option('--yes', 'apply the displayed plan', false)
1171
1413
  .option('--force', 'replace a conflicting synomem directory', false)
1172
1414
  .option('--link', 'symlink to the packaged skill instead of copying it', false)
1173
- .option('--agent <id-or-alias>', 'bind this installation to an agent')
1174
1415
  .action(async (options, command) => {
1175
1416
  const global = globals(command);
1176
- const runtimes = skillRuntimes(options.runtime);
1177
- /*
1178
- * The agent is resolved BEFORE anything is written. An ambiguous or
1179
- * unknown name then stops the command with a name to fix, rather than
1180
- * leaving a skill installed and pointed at an agent that does not
1181
- * exist.
1182
- */
1183
- let agentId;
1184
- if (options.agent) {
1185
- agentId = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), async (client) => {
1186
- const resolution = await client.agents.resolve(options.agent);
1187
- if (!resolution.match) {
1188
- throw new SynomemError('AGENT_NOT_FOUND', resolution.candidates.length
1189
- ? `"${options.agent}" matches ${resolution.candidates.length} agents: ${resolution.candidates
1190
- .map((candidate) => candidate.id)
1191
- .join(', ')}. Name one of them.`
1192
- : `Unknown agent: ${options.agent}`);
1193
- }
1194
- return resolution.match.id;
1195
- });
1196
- }
1197
- const result = installSkill({
1198
- ...(runtimes ? { runtimes } : {}),
1199
- apply: options.yes,
1200
- force: options.force,
1201
- link: options.link,
1202
- ...(agentId ? { agentId } : {}),
1203
- });
1204
- // Bindings follow what was actually installed, and only on a real run:
1205
- // a dry run must not claim a binding it did not make, and a runtime
1206
- // whose harness is not present here is not somewhere this agent runs.
1207
- if (agentId && options.yes) {
1208
- const installed = result.locations
1209
- .filter((location) => location.state !== 'unavailable')
1210
- .map((location) => location.runtime);
1211
- if (installed.length) {
1212
- /*
1213
- * Recording where you yourself run is self-service (§ the
1214
- * server's own `isSelf` check), but only when the request
1215
- * actually asserts that agent's identity — recording it as the
1216
- * generic `system/cli` placeholder looks like binding SOME OTHER
1217
- * agent's runtime, which is administration and a remote backend
1218
- * rightly refuses without an admin credential.
1219
- */
1220
- await withClient(global.home, actor('agent', agentId), async (client) => {
1221
- for (const runtime of installed) {
1222
- await client.agents.bindRuntime({ agentId, runtime });
1223
- }
1224
- });
1225
- }
1226
- }
1227
- output(io, global.json, result, formatSkillResult(result, 'install'));
1228
- });
1229
- skillCommand
1230
- .command('status')
1231
- .description('Show installed, stale, missing, or conflicting skill copies')
1232
- .option('--runtime <runtime>', `${skillRuntimeHelp} (repeatable)`, collect, [])
1233
- .option('--agent <id>', 'print the registration command for this agent')
1234
- .action((options, command) => {
1235
- const global = globals(command);
1236
- const result = skillStatus({
1237
- runtimes: skillRuntimes(options.runtime),
1238
- ...(options.agent ? { agentId: options.agent } : {}),
1239
- });
1240
- output(io, global.json, result, formatSkillResult(result, 'status'));
1241
- });
1242
- skillCommand
1243
- .command('uninstall')
1244
- .description('Plan or remove Synomem-owned skill installations')
1245
- .option('--runtime <runtime>', `${skillRuntimeHelp} (repeatable)`, collect, [])
1246
- .option('--yes', 'apply the displayed plan', false)
1247
- .option('--force', 'remove a conflicting synomem directory', false)
1248
- .action((options, command) => {
1249
- const global = globals(command);
1250
- const result = uninstallSkill({
1251
- runtimes: skillRuntimes(options.runtime),
1252
- apply: options.yes,
1253
- force: options.force,
1254
- });
1255
- output(io, global.json, result, formatSkillResult(result, 'uninstall'));
1256
- });
1257
- agentCommand
1258
- .command('list')
1259
- .description('List known agent identities')
1260
- .action(async (_options, command) => {
1261
- const global = globals(command);
1262
- const agents = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.list());
1263
- const human = agents.length
1264
- ? agents
1265
- .map((profile) =>
1266
- // Handle first: it is what people type. The canonical ID
1267
- // follows because MCP registration needs it.
1268
- `${profile.handle} ${profile.displayName}${profile.status === 'archived' ? ' [archived]' : ''}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}\n ${profile.id}`)
1269
- .join('\n')
1270
- : 'No agents configured.';
1271
- output(io, global.json, { agents }, human);
1272
- });
1273
- agentCommand
1274
- .command('show <id>')
1275
- .description('Show one agent profile, resolving aliases')
1276
- .action(async (id, _options, command) => {
1277
- const global = globals(command);
1278
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.get(id));
1279
- output(io, global.json, profile, `${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}\nStatus: ${profile.status}\n\n${profile.description ?? 'No description.'}`);
1280
- });
1281
- agentCommand
1282
- .command('update <id>')
1283
- .description('Update an agent profile without rewriting history')
1284
- .option('--name <display-name>')
1285
- .option('--alias <id>', 'replace aliases (repeatable)', collect, [])
1286
- .option('--clear-aliases', 'remove every alias', false)
1287
- .option('--description <text>')
1288
- .action(async (id, options, command) => {
1289
- const global = globals(command);
1290
- const hasAliases = options.clearAliases || options.alias.length > 0;
1291
- const profile = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.update(id, {
1292
- ...(options.name ? { displayName: options.name } : {}),
1293
- ...(hasAliases ? { aliases: options.clearAliases ? [] : options.alias } : {}),
1294
- ...(options.description !== undefined ? { description: options.description } : {}),
1295
- }));
1296
- output(io, global.json, profile, `Updated ${profile.displayName} (${profile.id})`);
1297
- });
1298
- agentCommand
1299
- .command('resolve <name>')
1300
- .description('Resolve a name or alias to one agent, or list the candidates')
1301
- .action(async (name, _options, command) => {
1302
- const global = globals(command);
1303
- const resolution = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.resolve(name));
1304
- // An ambiguous name is a question, not a failure: exit zero and show the
1305
- // candidates so the caller can pick one.
1306
- const human = resolution.match
1307
- ? `${resolution.match.displayName} (${resolution.match.id})`
1308
- : resolution.candidates.length
1309
- ? `"${resolution.query}" is ambiguous. Candidates:\n${resolution.candidates
1310
- .map((profile) => ` ${profile.id} ${profile.displayName}`)
1311
- .join('\n')}`
1312
- : `No agent answers to "${resolution.query}".`;
1313
- output(io, global.json, resolution, human);
1314
- });
1315
- agentCommand
1316
- .command('directory')
1317
- .description('List agents with their runtime bindings')
1318
- .action(async (_options, command) => {
1319
- const global = globals(command);
1320
- const entries = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.directory());
1321
- const human = entries.length
1322
- ? entries
1323
- .map((entry) => {
1324
- const runtimes = entry.runtimeBindings.length
1325
- ? entry.runtimeBindings
1326
- .map((binding) => ` ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''}` +
1327
- // Last-seen is advisory, so it is labelled as an
1328
- // observation rather than a status.
1329
- `${binding.lastSeenAt ? ` last seen ${binding.lastSeenAt}` : ' not yet seen'}`)
1330
- .join('\n')
1331
- : ' no runtime bindings';
1332
- return `${entry.profile.id} ${entry.profile.displayName}\n${runtimes}`;
1333
- })
1334
- .join('\n')
1335
- : 'No agents configured.';
1336
- output(io, global.json, { entries }, human);
1337
- });
1338
- const runtimeCommand = agentCommand.command('runtime').description('Record where an agent runs');
1339
- runtimeCommand
1340
- .command('bind <agent>')
1341
- .description('Bind an agent to a runtime')
1342
- .requiredOption('--runtime <name>', 'runtime family, e.g. claude-code')
1343
- .option('--profile <name>', 'named configuration within the runtime')
1344
- .option('--installation <id>', 'hosted installation this binding belongs to')
1345
- .action(async (agent, options, command) => {
1346
- const global = globals(command);
1347
- const binding = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.bindRuntime({
1348
- agentId: agent,
1349
- runtime: options.runtime,
1350
- ...(options.profile ? { profile: options.profile } : {}),
1351
- ...(options.installation ? { installationId: options.installation } : {}),
1352
- }));
1353
- output(io, global.json, binding, `Bound ${binding.agentId} to ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} (${binding.id})`);
1417
+ const runtimes = skillRuntimes(options.runtime);
1418
+ // Only an explicitly named profile is written into registration
1419
+ // commands; an inherited default is not something to bake into a harness.
1420
+ const profile = global.profile;
1421
+ if (profile && !profileStoreFor(global.home).read().profiles[profile]) {
1422
+ throw new SynomemError('CONFIG_INVALID', `Unknown profile "${profile}".`);
1423
+ }
1424
+ const result = installSkill({
1425
+ ...(runtimes ? { runtimes } : {}),
1426
+ apply: options.yes,
1427
+ force: options.force,
1428
+ link: options.link,
1429
+ ...(profile ? { profile } : {}),
1430
+ });
1431
+ let bindingWarning;
1432
+ if (profile && options.yes) {
1433
+ const installed = result.locations
1434
+ .filter((location) => location.state !== 'unavailable')
1435
+ .map((location) => location.runtime);
1436
+ if (installed.length) {
1437
+ // Recording where you yourself run is self-service, as the
1438
+ // profile's own agent — never on another agent's behalf.
1439
+ try {
1440
+ await withProfile(command, async (service, context) => {
1441
+ if (context.actor.kind !== 'agent')
1442
+ return;
1443
+ for (const runtime of installed) {
1444
+ await service.agents.bindRuntime({ agentId: context.actor.id, runtime, profile });
1445
+ }
1446
+ });
1447
+ }
1448
+ catch (error) {
1449
+ bindingWarning = `Skill installed, but recording the runtime binding failed: ${asSynomemError(error).message}`;
1450
+ }
1451
+ }
1452
+ }
1453
+ output(io, global.json, { ...result, ...(bindingWarning ? { warning: bindingWarning } : {}) }, `${formatSkillResult(result, 'install')}${bindingWarning ? `\n\n${bindingWarning}` : ''}`);
1354
1454
  });
1355
- /*
1356
- * With no agent named this answers the question people actually arrive with:
1357
- * "where is any of my stuff running?". Naming an agent narrows it. Requiring
1358
- * the agent, as this once did, means you must already know the answer to the
1359
- * question you came to ask.
1360
- */
1361
- runtimeCommand
1362
- .command('list [agent]')
1363
- .description('List runtime bindings for one agent, or for every agent')
1364
- .action(async (agent, _options, command) => {
1455
+ skillCommand
1456
+ .command('status')
1457
+ .description('Show installed, stale, missing, or conflicting skill copies')
1458
+ .option('--runtime <runtime>', `${skillRuntimeHelp} (repeatable)`, collect, [])
1459
+ .action((options, command) => {
1365
1460
  const global = globals(command);
1366
- const result = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), async (client) => {
1367
- if (agent) {
1368
- const profile = await client.agents.get(agent);
1369
- return [{ profile, runtimeBindings: await client.agents.bindings(agent) }];
1370
- }
1371
- return (await client.agents.directory()).filter((entry) => entry.runtimeBindings.length > 0);
1461
+ const result = skillStatus({
1462
+ runtimes: skillRuntimes(options.runtime),
1463
+ ...(global.profile ? { profile: global.profile } : {}),
1372
1464
  });
1373
- const describe = (binding) => ` ${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`;
1374
- const human = result.length
1375
- ? result
1376
- .map((entry) => [
1377
- `${entry.profile.handle} (${entry.profile.id})`,
1378
- ...entry.runtimeBindings.map(describe),
1379
- ].join('\n'))
1380
- .join('\n')
1381
- : agent
1382
- ? 'No runtime bindings.'
1383
- : 'No agent in this workspace has a runtime binding.';
1384
- output(io, global.json, { agents: result }, human);
1465
+ output(io, global.json, result, formatSkillResult(result, 'status'));
1385
1466
  });
1386
- runtimeCommand
1387
- .command('unbind <binding-id>')
1388
- .description('Remove a runtime binding')
1389
- .action(async (bindingId, _options, command) => {
1467
+ skillCommand
1468
+ .command('uninstall')
1469
+ .description('Plan or remove Synomem-owned skill installations')
1470
+ .option('--runtime <runtime>', `${skillRuntimeHelp} (repeatable)`, collect, [])
1471
+ .option('--yes', 'apply the displayed plan', false)
1472
+ .option('--force', 'remove a conflicting synomem directory', false)
1473
+ .action((options, command) => {
1390
1474
  const global = globals(command);
1391
- const removed = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.agents.unbindRuntime(bindingId));
1392
- output(io, global.json, { removed }, removed ? `Removed binding ${bindingId}.` : `No binding ${bindingId}.`);
1475
+ const result = uninstallSkill({
1476
+ runtimes: skillRuntimes(options.runtime),
1477
+ apply: options.yes,
1478
+ force: options.force,
1479
+ });
1480
+ output(io, global.json, result, formatSkillResult(result, 'uninstall'));
1393
1481
  });
1482
+ /* ------------------------------------------------------------ posts */
1394
1483
  const postCommand = program.command('post').description('Publish to everyone in the workspace');
1395
1484
  postCommand
1396
1485
  .command('create')
1397
1486
  .description('Publish a post the whole workspace can read')
1398
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1399
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1400
1487
  .requiredOption('--title <title>')
1401
1488
  .requiredOption('--body <body>')
1402
1489
  .option('--tag <tag>', 'repeatable', collect, [])
@@ -1404,7 +1491,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1404
1491
  .option('--reply-to <post-id>')
1405
1492
  .action(async (options, command) => {
1406
1493
  const global = globals(command);
1407
- const result = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.posts.create({
1494
+ const result = await withProfile(command, (service) => service.posts.create({
1408
1495
  title: options.title,
1409
1496
  body: options.body,
1410
1497
  ...(options.tag.length ? { tags: options.tag } : {}),
@@ -1416,12 +1503,10 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1416
1503
  postCommand
1417
1504
  .command('list')
1418
1505
  .description('List posts in this workspace')
1419
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1420
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1421
1506
  .option('--limit <n>', 'default 10, maximum 50')
1422
1507
  .action(async (options, command) => {
1423
1508
  const global = globals(command);
1424
- const page = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.posts.list(options.limit ? { limit: Number(options.limit) } : {}));
1509
+ const page = await withProfile(command, (service) => service.posts.list(options.limit ? { limit: Number(options.limit) } : {}));
1425
1510
  const human = page.items.length
1426
1511
  ? page.items.map((item) => `${item.id} ${item.title}`).join('\n')
1427
1512
  : 'No posts yet.';
@@ -1430,11 +1515,9 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1430
1515
  postCommand
1431
1516
  .command('show <post-id>')
1432
1517
  .description('Show one post with its acknowledgements')
1433
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1434
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1435
- .action(async (postId, options, command) => {
1518
+ .action(async (postId, _options, command) => {
1436
1519
  const global = globals(command);
1437
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.posts.get(postId));
1520
+ const record = await withProfile(command, (service) => service.posts.get(postId));
1438
1521
  const acks = record.acknowledgments.length
1439
1522
  ? record.acknowledgments
1440
1523
  .map((entry) => ` ${entry.actor.id}${entry.note ? ` — ${entry.note}` : ''}`)
@@ -1445,27 +1528,18 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1445
1528
  postCommand
1446
1529
  .command('acknowledge <post-id>')
1447
1530
  .description('Say you have seen a post')
1448
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1449
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1450
1531
  .option('--note <text>', 'optional context for the author')
1451
1532
  .action(async (postId, options, command) => {
1452
1533
  const global = globals(command);
1453
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.posts.acknowledge({
1454
- postId,
1455
- ...(options.note ? { note: options.note } : {}),
1456
- }));
1534
+ const record = await withProfile(command, (service) => service.posts.acknowledge({ postId, ...(options.note ? { note: options.note } : {}) }));
1457
1535
  output(io, global.json, record, `Acknowledged ${postId}`);
1458
1536
  });
1459
1537
  postCommand
1460
1538
  .command('roster <post-id>')
1461
1539
  .description('Who has acknowledged a post, and who has not')
1462
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1463
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1464
- .action(async (postId, options, command) => {
1540
+ .action(async (postId, _options, command) => {
1465
1541
  const global = globals(command);
1466
- const roster = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.posts.roster(postId));
1467
- // "Outstanding" means no acknowledgement recorded — never that somebody
1468
- // has not read it, which this cannot know.
1542
+ const roster = await withProfile(command, (service) => service.posts.roster(postId));
1469
1543
  const lines = [
1470
1544
  `Acknowledged (${roster.acknowledged.length}):`,
1471
1545
  ...(roster.acknowledged.length
@@ -1484,31 +1558,27 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1484
1558
  postCommand
1485
1559
  .command('archive <post-id>')
1486
1560
  .description('Archive a post you wrote')
1487
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1488
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1489
1561
  .option('--reason <text>')
1490
1562
  .action(async (postId, options, command) => {
1491
1563
  const global = globals(command);
1492
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.posts.archive({ postId, ...(options.reason ? { reason: options.reason } : {}) }));
1564
+ const record = await withProfile(command, (service) => service.posts.archive({ postId, ...(options.reason ? { reason: options.reason } : {}) }));
1493
1565
  output(io, global.json, record, `Archived ${postId}`);
1494
1566
  });
1567
+ /* ------------------------------------------------------------ kudos */
1495
1568
  const kudosCommand = program.command('kudos').description('Give and manage agent recognition');
1496
1569
  kudosCommand
1497
1570
  .command('give <recipient>')
1498
1571
  .description('Give specific, evidence-based kudos to an agent')
1499
- .requiredOption('--from <actor-id>', 'stable ID of the giver (defaults to --actor)', actingDefault)
1500
- .requiredOption('--actor-kind <kind>', 'human, agent, or system')
1501
- .option('--actor-name <display-name>')
1502
1572
  .requiredOption('--title <title>')
1503
1573
  .requiredOption('--reason <reason>')
1504
1574
  .option('--tag <tag>', 'tag (repeatable)', collect, [])
1505
1575
  .option('--topic <id>', 'topic ID (repeatable)', collect, [])
1506
1576
  .option('--evidence <kind:value>', 'sanitized evidence (repeatable)', collect, [])
1507
- .option('--visibility <visibility>', 'private, local, or public', 'workspace')
1577
+ .option('--visibility <visibility>', 'private, workspace, or public', 'workspace')
1508
1578
  .option('--idempotency-key <key>')
1509
1579
  .action(async (recipient, options, command) => {
1510
1580
  const global = globals(command);
1511
- const result = await withClient(global.home, actor(options.actorKind, options.from, options.actorName), (client) => client.kudos.give({
1581
+ const result = await withProfile(command, (service) => service.kudos.give({
1512
1582
  recipientAgentId: recipient,
1513
1583
  title: options.title,
1514
1584
  reason: options.reason,
@@ -1521,40 +1591,118 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1521
1591
  const event = result.record.event;
1522
1592
  output(io, global.json, result, `${result.deduplicated ? 'Found existing' : 'Created'} kudos for ${event.recipientDisplayName}\nTitle: ${event.title}\nDate: ${event.createdAt}\nID: ${event.id}`);
1523
1593
  });
1594
+ addListOptions(kudosCommand.command('list').description('List and filter kudos')).action(async (options, command) => {
1595
+ const global = globals(command);
1596
+ const page = await withProfile(command, (service) => service.kudos.list(listInput(options)));
1597
+ output(io, global.json, page, page.items.length
1598
+ ? `${page.items.map(lineForSummary).join('\n')}${page.hasMore ? `\nNext cursor: ${page.nextCursor}` : ''}`
1599
+ : 'No kudos found.');
1600
+ });
1601
+ kudosCommand
1602
+ .command('show <kudos-id>')
1603
+ .description('Show one kudos item and its current state')
1604
+ .action(async (id, _options, command) => {
1605
+ const global = globals(command);
1606
+ const record = await withProfile(command, (service) => service.kudos.get(id));
1607
+ output(io, global.json, record, showRecord(record));
1608
+ });
1609
+ kudosCommand
1610
+ .command('acknowledge <kudos-id>')
1611
+ .description('Record that you (the recipient) reviewed kudos')
1612
+ .option('--note <text>')
1613
+ .action(async (id, options, command) => {
1614
+ const global = globals(command);
1615
+ const record = await withProfile(command, (service) => service.kudos.acknowledge({ kudosId: id, ...(options.note ? { note: options.note } : {}) }));
1616
+ output(io, global.json, record, `Acknowledged ${id}.`);
1617
+ });
1618
+ kudosCommand
1619
+ .command('revoke <kudos-id>')
1620
+ .description('Record a revocation while preserving history')
1621
+ .requiredOption('--reason <reason>')
1622
+ .option('--administrative', 'mark as an administrative revocation', false)
1623
+ .action(async (id, options, command) => {
1624
+ const global = globals(command);
1625
+ const record = await withProfile(command, (service) => service.kudos.revoke({
1626
+ kudosId: id,
1627
+ reason: options.reason,
1628
+ administrative: options.administrative,
1629
+ }));
1630
+ output(io, global.json, record, `Revoked ${id}; the audit trail was preserved.`);
1631
+ });
1632
+ kudosCommand
1633
+ .command('wins [agent]')
1634
+ .description('Print the generated WINS.md path or content (local stores)')
1635
+ .option('--open', 'open WINS.md in the system GUI', false)
1636
+ .option('--print', 'print Markdown content', false)
1637
+ .action(async (agentId, options, command) => {
1638
+ const global = globals(command);
1639
+ const details = await withManagement(command, async (service, context) => {
1640
+ if (context) {
1641
+ throw new SynomemError('INVALID_INPUT', 'Generated WINS.md files are available only for local stores.');
1642
+ }
1643
+ if (!agentId)
1644
+ throw new SynomemError('INVALID_INPUT', 'Specify an agent.');
1645
+ const profile = await service.agents.get(agentId);
1646
+ const info = await service.info();
1647
+ if (info.backend !== 'local') {
1648
+ throw new SynomemError('INVALID_INPUT', 'Generated WINS.md files are available only for local stores.');
1649
+ }
1650
+ const capabilities = await service.capabilities();
1651
+ const path = join(info.home, profile.handle, 'WINS.md');
1652
+ if (!existsSync(path)) {
1653
+ const hint = capabilities.projections.writeWinsMarkdown
1654
+ ? 'Run `synomem rebuild` to generate it.'
1655
+ : 'Enable projection.writeWinsMarkdown and run `synomem rebuild`.';
1656
+ throw new SynomemError('INVALID_INPUT', `No generated WINS.md exists for ${profile.handle}. ${hint}`);
1657
+ }
1658
+ return { profile, path, content: readFileSync(path, 'utf8') };
1659
+ });
1660
+ if (options.open) {
1661
+ const commandName = process.platform === 'darwin'
1662
+ ? 'open'
1663
+ : process.platform === 'win32'
1664
+ ? 'cmd'
1665
+ : 'xdg-open';
1666
+ const args = process.platform === 'win32' ? ['/c', 'start', '', details.path] : [details.path];
1667
+ spawn(commandName, args, { detached: true, stdio: 'ignore' }).unref();
1668
+ }
1669
+ output(io, global.json, details, options.print ? details.content.trimEnd() : details.path);
1670
+ });
1671
+ addListOptions(kudosCommand.command('stats').description('Show aggregate kudos statistics')).action(async (options, command) => {
1672
+ const global = globals(command);
1673
+ const stats = await withProfile(command, (service) => service.stats(listInput(options)));
1674
+ output(io, global.json, stats, `Total: ${stats.total}\nActive: ${stats.active}\nAcknowledged: ${stats.acknowledged}\nRevoked: ${stats.revoked}`);
1675
+ });
1676
+ /* ------------------------------------------------------------ cross-kind */
1524
1677
  program
1525
1678
  .command('inbox [agent]')
1526
- .description('Show pending kudos, memos, and tasks for an agent')
1527
- .option('--as <agent-id>', 'defaults to the positional agent')
1679
+ .description("Show pending kudos, memos, and tasks (the profile's own agent by default)")
1528
1680
  .option('--limit <number>', 'maximum results (default 10, maximum 50)', '10')
1529
1681
  .option('--cursor <cursor>', 'opaque cursor returned by the previous page')
1530
1682
  .action(async (agentId, options, command) => {
1531
1683
  const global = globals(command);
1532
- const recipient = agentId ?? options.as;
1533
- if (!recipient)
1534
- throw new SynomemError('INVALID_INPUT', 'Specify an agent inbox.');
1535
- const page = await withClient(global.home, actor('agent', options.as ?? recipient), (client) => client.items.list({
1536
- participantAgentId: recipient,
1537
- pending: true,
1538
- limit: Number(options.limit),
1539
- ...(options.cursor ? { cursor: options.cursor } : {}),
1540
- }));
1684
+ const page = await withProfile(command, (service, context) => {
1685
+ const recipient = agentId ?? (context.actor.kind === 'agent' ? context.actor.id : undefined);
1686
+ if (!recipient) {
1687
+ throw new SynomemError('INVALID_INPUT', 'This profile is not an agent; name the agent whose inbox to show.');
1688
+ }
1689
+ return service.items.list({
1690
+ participantAgentId: recipient,
1691
+ pending: true,
1692
+ limit: Number(options.limit),
1693
+ ...(options.cursor ? { cursor: options.cursor } : {}),
1694
+ });
1695
+ });
1541
1696
  output(io, global.json, page, page.items.length
1542
1697
  ? `${page.items.map(lineForItem).join('\n')}${page.hasMore ? `\nNext cursor: ${page.nextCursor}` : ''}`
1543
1698
  : 'Inbox is clear.');
1544
1699
  });
1545
- addListOptions(kudosCommand.command('list').description('List and filter kudos')).action(async (options, command) => {
1546
- const global = globals(command);
1547
- const page = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.kudos.list(listInput(options)), Boolean(global.actor));
1548
- output(io, global.json, page, page.items.length
1549
- ? `${page.items.map(lineForSummary).join('\n')}${page.hasMore ? `\nNext cursor: ${page.nextCursor}` : ''}`
1550
- : 'No kudos found.');
1551
- });
1552
1700
  program
1553
1701
  .command('list')
1554
1702
  .description('List compact summaries across all record types')
1555
- .option('--kind <kind>', 'kudos, memo, note, or task (repeatable)', collect, [])
1703
+ .option('--kind <kind>', 'kudos, memo, note, task, todo, or post (repeatable)', collect, [])
1556
1704
  .option('--participant <agent>')
1557
- .option('--actor <id>')
1705
+ .option('--author <id>', 'only records written by this actor')
1558
1706
  .option('--tag <tag>')
1559
1707
  .option('--topic <id>', 'only records carrying this topic')
1560
1708
  .option('--status <status>')
@@ -1564,7 +1712,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1564
1712
  .option('--offset <number>', 'deprecated offset', '0')
1565
1713
  .action(async (options, command) => {
1566
1714
  const global = globals(command);
1567
- const page = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.items.list(itemListInput(options)), Boolean(global.actor));
1715
+ const page = await withProfile(command, (service) => service.items.list(itemListInput(options)));
1568
1716
  output(io, global.json, page, page.items.length
1569
1717
  ? `${page.items.map(lineForItem).join('\n')}${page.hasMore ? `\nNext cursor: ${page.nextCursor}` : ''}`
1570
1718
  : 'No items found.');
@@ -1577,11 +1725,11 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1577
1725
  .option('--limit <number>', 'maximum changes (default 20, maximum 100)', '20')
1578
1726
  .action(async (options, command) => {
1579
1727
  const global = globals(command);
1580
- const page = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.items.changes({
1728
+ const page = await withProfile(command, (service) => service.items.changes({
1581
1729
  limit: Number(options.limit),
1582
1730
  ...(options.kind.length ? { kinds: options.kind } : {}),
1583
1731
  ...(options.after ? { after: options.after } : {}),
1584
- }), Boolean(global.actor));
1732
+ }));
1585
1733
  const human = page.items.length
1586
1734
  ? `${page.items
1587
1735
  .map((change) => `${change.sequence} ${change.createdAt} ${change.type} ${change.itemId ?? '-'}`)
@@ -1589,12 +1737,11 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1589
1737
  : `No new changes. Watermark: ${page.watermark}`;
1590
1738
  output(io, global.json, page, human);
1591
1739
  });
1740
+ /* ------------------------------------------------------------ memos */
1592
1741
  const memoCommand = program.command('memo').description('Send and manage durable messages');
1593
1742
  memoCommand
1594
1743
  .command('send <recipient>')
1595
- .requiredOption('--from <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1596
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1597
- .option('--actor-name <name>')
1744
+ .description('Send a durable one-to-one memo')
1598
1745
  .requiredOption('--subject <subject>')
1599
1746
  .requiredOption('--body <body>')
1600
1747
  .option('--tag <tag>', 'tag (repeatable)', collect, [])
@@ -1603,7 +1750,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1603
1750
  .option('--idempotency-key <key>')
1604
1751
  .action(async (recipient, options, command) => {
1605
1752
  const global = globals(command);
1606
- const result = await withClient(global.home, actor(options.actorKind, options.from, options.actorName), (client) => client.memos.send({
1753
+ const result = await withProfile(command, (service) => service.memos.send({
1607
1754
  recipientAgentId: recipient,
1608
1755
  subject: options.subject,
1609
1756
  body: options.body,
@@ -1616,47 +1763,52 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1616
1763
  });
1617
1764
  memoCommand
1618
1765
  .command('list')
1766
+ .description('List memos')
1619
1767
  .option('--participant <agent>')
1620
1768
  .option('--status <status>')
1621
1769
  .option('--limit <number>', 'maximum results', '10')
1622
1770
  .option('--cursor <cursor>')
1623
1771
  .action(async (options, command) => {
1624
1772
  const global = globals(command);
1625
- const page = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.memos.list({
1773
+ const page = await withProfile(command, (service) => service.memos.list({
1626
1774
  ...(options.participant ? { participantAgentId: options.participant } : {}),
1627
1775
  ...(options.status ? { status: options.status } : {}),
1628
1776
  limit: Number(options.limit),
1629
1777
  ...(options.cursor ? { cursor: options.cursor } : {}),
1630
- }), Boolean(global.actor));
1778
+ }));
1631
1779
  output(io, global.json, page, page.items.map(lineForItem).join('\n') || 'No memos found.');
1632
1780
  });
1633
- memoCommand.command('show <memo-id>').action(async (id, _options, command) => {
1781
+ memoCommand
1782
+ .command('show <memo-id>')
1783
+ .description('Show one memo')
1784
+ .action(async (id, _options, command) => {
1634
1785
  const global = globals(command);
1635
- const record = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.memos.get(id), Boolean(global.actor));
1786
+ const record = await withProfile(command, (service) => service.memos.get(id));
1636
1787
  output(io, global.json, record, `${record.event.subject}\nID: ${record.event.id}\nStatus: ${record.status}\n\n${record.event.body}`);
1637
1788
  });
1638
1789
  for (const operation of ['read', 'archive']) {
1639
1790
  memoCommand
1640
1791
  .command(`${operation} <memo-id>`)
1641
- .requiredOption('--as <agent-id>')
1642
- .option('--actor-kind <kind>', 'agent or human', 'agent')
1792
+ .description(operation === 'read'
1793
+ ? 'Mark a memo addressed to you as read'
1794
+ : 'Archive a memo addressed to you')
1643
1795
  .option('--idempotency-key <key>')
1644
1796
  .action(async (id, options, command) => {
1645
1797
  const global = globals(command);
1646
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.memos[operation]({
1798
+ const record = await withProfile(command, (service) => service.memos[operation]({
1647
1799
  memoId: id,
1648
1800
  ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1649
1801
  }));
1650
1802
  output(io, global.json, record, `Memo ${id} is ${record.status}.`);
1651
1803
  });
1652
1804
  }
1805
+ /* ------------------------------------------------------------ notes */
1653
1806
  const noteCommand = program
1654
1807
  .command('note')
1655
1808
  .description('Retain and revise agent-owned knowledge');
1656
1809
  noteCommand
1657
1810
  .command('create')
1658
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1659
- .option('--actor-kind <kind>', 'agent or human', 'agent')
1811
+ .description('Create an owner-private note')
1660
1812
  .option('--owner <agent-id>')
1661
1813
  .requiredOption('--title <title>')
1662
1814
  .requiredOption('--body <body>')
@@ -1665,7 +1817,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1665
1817
  .option('--idempotency-key <key>')
1666
1818
  .action(async (options, command) => {
1667
1819
  const global = globals(command);
1668
- const result = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.notes.create({
1820
+ const result = await withProfile(command, (service) => service.notes.create({
1669
1821
  ...(options.owner ? { ownerAgentId: options.owner } : {}),
1670
1822
  title: options.title,
1671
1823
  body: options.body,
@@ -1677,27 +1829,30 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1677
1829
  });
1678
1830
  noteCommand
1679
1831
  .command('list')
1832
+ .description('List notes')
1680
1833
  .option('--owner <agent>')
1681
1834
  .option('--status <status>')
1682
1835
  .option('--limit <number>', 'maximum results', '10')
1683
1836
  .action(async (options, command) => {
1684
1837
  const global = globals(command);
1685
- const page = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.notes.list({
1838
+ const page = await withProfile(command, (service) => service.notes.list({
1686
1839
  ...(options.owner ? { participantAgentId: options.owner } : {}),
1687
1840
  ...(options.status ? { status: options.status } : {}),
1688
1841
  limit: Number(options.limit),
1689
- }), Boolean(global.actor));
1842
+ }));
1690
1843
  output(io, global.json, page, page.items.map(lineForItem).join('\n') || 'No notes found.');
1691
1844
  });
1692
- noteCommand.command('show <note-id>').action(async (id, _options, command) => {
1845
+ noteCommand
1846
+ .command('show <note-id>')
1847
+ .description('Show one note')
1848
+ .action(async (id, _options, command) => {
1693
1849
  const global = globals(command);
1694
- const record = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.notes.get(id), Boolean(global.actor));
1850
+ const record = await withProfile(command, (service) => service.notes.get(id));
1695
1851
  output(io, global.json, record, `${record.current.title}\nID: ${record.event.id}\nVersion: ${record.current.version}\n\n${record.current.body}`);
1696
1852
  });
1697
1853
  noteCommand
1698
1854
  .command('revise <note-id>')
1699
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1700
- .option('--actor-kind <kind>', 'agent or human', 'agent')
1855
+ .description('Revise a note (optimistic concurrency)')
1701
1856
  .requiredOption('--expected-version <number>')
1702
1857
  .option('--title <title>')
1703
1858
  .option('--body <body>')
@@ -1705,7 +1860,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1705
1860
  .option('--idempotency-key <key>')
1706
1861
  .action(async (id, options, command) => {
1707
1862
  const global = globals(command);
1708
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.notes.revise({
1863
+ const record = await withProfile(command, (service) => service.notes.revise({
1709
1864
  noteId: id,
1710
1865
  expectedVersion: Number(options.expectedVersion),
1711
1866
  ...(options.title ? { title: options.title } : {}),
@@ -1717,24 +1872,23 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1717
1872
  });
1718
1873
  noteCommand
1719
1874
  .command('archive <note-id>')
1720
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1721
- .option('--actor-kind <kind>', 'agent or human', 'agent')
1875
+ .description('Archive a note')
1722
1876
  .option('--idempotency-key <key>')
1723
1877
  .action(async (id, options, command) => {
1724
1878
  const global = globals(command);
1725
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.notes.archive({
1879
+ const record = await withProfile(command, (service) => service.notes.archive({
1726
1880
  noteId: id,
1727
1881
  ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1728
1882
  }));
1729
1883
  output(io, global.json, record, `Archived note ${id}.`);
1730
1884
  });
1885
+ /* ------------------------------------------------------------ todos */
1731
1886
  const todoCommand = program
1732
1887
  .command('todo')
1733
1888
  .description('Create and manage your own private reminders');
1734
1889
  todoCommand
1735
1890
  .command('create')
1736
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1737
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1891
+ .description('Create a private todo')
1738
1892
  .requiredOption('--title <title>')
1739
1893
  .option('--details <text>', 'private working detail')
1740
1894
  .option('--priority <number>', '1 highest, 4 lowest', '3')
@@ -1746,7 +1900,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1746
1900
  .option('--idempotency-key <key>')
1747
1901
  .action(async (options, command) => {
1748
1902
  const global = globals(command);
1749
- const result = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.todos.create({
1903
+ const result = await withProfile(command, (service) => service.todos.create({
1750
1904
  title: options.title,
1751
1905
  ...(options.details ? { details: options.details } : {}),
1752
1906
  priority: Number(options.priority),
@@ -1759,13 +1913,12 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1759
1913
  });
1760
1914
  todoCommand
1761
1915
  .command('list')
1762
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1763
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1916
+ .description('List your todos')
1764
1917
  .option('--status <status>')
1765
1918
  .option('--limit <number>', 'maximum results', '10')
1766
1919
  .action(async (options, command) => {
1767
1920
  const global = globals(command);
1768
- const page = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.todos.list({
1921
+ const page = await withProfile(command, (service) => service.todos.list({
1769
1922
  ...(options.status ? { status: options.status } : {}),
1770
1923
  limit: Number(options.limit),
1771
1924
  }));
@@ -1777,56 +1930,45 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1777
1930
  });
1778
1931
  todoCommand
1779
1932
  .command('show <todo-id>')
1780
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1781
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1782
- .action(async (id, options, command) => {
1933
+ .description('Show one of your todos')
1934
+ .action(async (id, _options, command) => {
1783
1935
  const global = globals(command);
1784
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.todos.get(id));
1936
+ const record = await withProfile(command, (service) => service.todos.get(id));
1785
1937
  output(io, global.json, record, `${record.current.title}\nStatus: ${record.status}\nPriority: ${record.current.priority}\nVersion: ${record.current.version}${record.current.details ? `\n\n${record.current.details}` : ''}`);
1786
1938
  });
1787
1939
  for (const operation of ['complete', 'reopen', 'cancel', 'archive']) {
1788
1940
  todoCommand
1789
1941
  .command(`${operation} <todo-id>`)
1790
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1791
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1942
+ .description(`${operation[0].toUpperCase()}${operation.slice(1)} one of your todos`)
1792
1943
  .option('--note <text>')
1793
1944
  .option('--reason <text>')
1794
1945
  .option('--idempotency-key <key>')
1795
1946
  .action(async (id, options, command) => {
1796
1947
  const global = globals(command);
1797
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => operation === 'complete'
1798
- ? client.todos.complete({
1948
+ const key = options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {};
1949
+ const record = await withProfile(command, (service) => operation === 'complete'
1950
+ ? service.todos.complete({
1799
1951
  todoId: id,
1800
1952
  ...(options.note ? { note: options.note } : {}),
1801
- ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1953
+ ...key,
1802
1954
  })
1803
1955
  : operation === 'cancel'
1804
- ? client.todos.cancel({
1956
+ ? service.todos.cancel({
1805
1957
  todoId: id,
1806
1958
  ...(options.reason ? { reason: options.reason } : {}),
1807
- ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1959
+ ...key,
1808
1960
  })
1809
1961
  : operation === 'archive'
1810
- ? client.todos.archive({
1811
- todoId: id,
1812
- ...(options.idempotencyKey
1813
- ? { idempotencyKey: options.idempotencyKey }
1814
- : {}),
1815
- })
1816
- : client.todos.reopen({
1817
- todoId: id,
1818
- ...(options.idempotencyKey
1819
- ? { idempotencyKey: options.idempotencyKey }
1820
- : {}),
1821
- }));
1962
+ ? service.todos.archive({ todoId: id, ...key })
1963
+ : service.todos.reopen({ todoId: id, ...key }));
1822
1964
  output(io, global.json, record, `Todo ${id} is now ${record.status}.`);
1823
1965
  });
1824
1966
  }
1967
+ /* ------------------------------------------------------------ tasks */
1825
1968
  const taskCommand = program.command('task').description('Create and manage agent tasks');
1826
1969
  taskCommand
1827
1970
  .command('create <assignee>')
1828
- .requiredOption('--from <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1829
- .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1971
+ .description('Assign a task to an agent')
1830
1972
  .requiredOption('--title <title>')
1831
1973
  .option('--description <text>')
1832
1974
  .option('--priority <number>', '1 highest, 4 lowest', '3')
@@ -1839,7 +1981,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1839
1981
  .option('--idempotency-key <key>')
1840
1982
  .action(async (assignee, options, command) => {
1841
1983
  const global = globals(command);
1842
- const result = await withClient(global.home, actor(options.actorKind, options.from), (client) => client.tasks.create({
1984
+ const result = await withProfile(command, (service) => service.tasks.create({
1843
1985
  assigneeAgentId: assignee,
1844
1986
  title: options.title,
1845
1987
  ...(options.description ? { description: options.description } : {}),
@@ -1854,27 +1996,30 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1854
1996
  });
1855
1997
  taskCommand
1856
1998
  .command('list')
1999
+ .description('List tasks')
1857
2000
  .option('--assignee <agent>')
1858
2001
  .option('--status <status>')
1859
2002
  .option('--limit <number>', 'maximum results', '10')
1860
2003
  .action(async (options, command) => {
1861
2004
  const global = globals(command);
1862
- const page = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.tasks.list({
2005
+ const page = await withProfile(command, (service) => service.tasks.list({
1863
2006
  ...(options.assignee ? { participantAgentId: options.assignee } : {}),
1864
2007
  ...(options.status ? { status: options.status } : {}),
1865
2008
  limit: Number(options.limit),
1866
- }), Boolean(global.actor));
2009
+ }));
1867
2010
  output(io, global.json, page, page.items.map(lineForItem).join('\n') || 'No tasks found.');
1868
2011
  });
1869
- taskCommand.command('show <task-id>').action(async (id, _options, command) => {
2012
+ taskCommand
2013
+ .command('show <task-id>')
2014
+ .description('Show one task')
2015
+ .action(async (id, _options, command) => {
1870
2016
  const global = globals(command);
1871
- const record = await withClient(global.home, defaultActor(env, 'human', 'local-cli', global.actor), (client) => client.tasks.get(id), Boolean(global.actor));
2017
+ const record = await withProfile(command, (service) => service.tasks.get(id));
1872
2018
  output(io, global.json, record, `${record.current.title}\nID: ${record.event.id}\nStatus: ${record.status}\nVersion: ${record.current.version}`);
1873
2019
  });
1874
2020
  taskCommand
1875
2021
  .command('update <task-id>')
1876
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1877
- .option('--actor-kind <kind>', 'agent or human', 'agent')
2022
+ .description('Update a task (optimistic concurrency)')
1878
2023
  .requiredOption('--expected-version <number>')
1879
2024
  .option('--title <title>')
1880
2025
  .option('--description <text>')
@@ -1888,7 +2033,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1888
2033
  .action(async (id, options, command) => {
1889
2034
  const global = globals(command);
1890
2035
  const parsedDue = taskDue(options);
1891
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.tasks.update({
2036
+ const record = await withProfile(command, (service) => service.tasks.update({
1892
2037
  taskId: id,
1893
2038
  expectedVersion: Number(options.expectedVersion),
1894
2039
  ...(options.title ? { title: options.title } : {}),
@@ -1903,13 +2048,11 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1903
2048
  for (const operation of ['accept', 'reject', 'complete', 'reopen', 'cancel']) {
1904
2049
  const command_ = taskCommand
1905
2050
  .command(`${operation} <task-id>`)
1906
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1907
- .option('--actor-kind <kind>', 'agent or human', 'agent')
2051
+ .description(`${operation[0].toUpperCase()}${operation.slice(1)} a task`)
1908
2052
  .option('--note <text>')
1909
2053
  .option('--reason <text>')
1910
2054
  .option('--idempotency-key <key>');
1911
- // Rejecting requires saying why; accepting may. Marked required at the
1912
- // parser so the CLI refuses before touching the store.
2055
+ // Rejecting requires saying why; accepting may.
1913
2056
  if (operation === 'reject') {
1914
2057
  command_.requiredOption('--response <text>', 'why the task is being refused');
1915
2058
  }
@@ -1918,140 +2061,85 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1918
2061
  }
1919
2062
  command_.action(async (id, options, command) => {
1920
2063
  const global = globals(command);
1921
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => operation === 'accept'
1922
- ? client.tasks.accept({
2064
+ const key = options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {};
2065
+ const record = await withProfile(command, (service) => operation === 'accept'
2066
+ ? service.tasks.accept({
1923
2067
  taskId: id,
1924
2068
  ...(options.response ? { response: options.response } : {}),
1925
- ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
2069
+ ...key,
1926
2070
  })
1927
2071
  : operation === 'reject'
1928
- ? client.tasks.reject({
2072
+ ? service.tasks.reject({
1929
2073
  taskId: id,
1930
2074
  response: options.response ?? options.reason ?? '',
1931
- ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
2075
+ ...key,
1932
2076
  })
1933
2077
  : operation === 'complete'
1934
- ? client.tasks.complete({
2078
+ ? service.tasks.complete({
1935
2079
  taskId: id,
1936
2080
  ...(options.note ? { note: options.note } : {}),
1937
- ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
2081
+ ...key,
1938
2082
  })
1939
2083
  : operation === 'cancel'
1940
- ? client.tasks.cancel({
2084
+ ? service.tasks.cancel({
1941
2085
  taskId: id,
1942
2086
  ...(options.reason ? { reason: options.reason } : {}),
1943
- ...(options.idempotencyKey
1944
- ? { idempotencyKey: options.idempotencyKey }
1945
- : {}),
2087
+ ...key,
1946
2088
  })
1947
- : client.tasks.reopen({
1948
- taskId: id,
1949
- ...(options.idempotencyKey
1950
- ? { idempotencyKey: options.idempotencyKey }
1951
- : {}),
1952
- }));
2089
+ : service.tasks.reopen({ taskId: id, ...key }));
1953
2090
  output(io, global.json, record, `Task ${id} is ${record.status}.`);
1954
2091
  });
1955
2092
  }
1956
- kudosCommand
1957
- .command('show <kudos-id>')
1958
- .description('Show one kudos item and its current state')
1959
- .action(async (id, _options, command) => {
1960
- const global = globals(command);
1961
- const record = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.kudos.get(id));
1962
- output(io, global.json, record, showRecord(record));
1963
- });
1964
- kudosCommand
1965
- .command('acknowledge <kudos-id>')
1966
- .description('Record that a recipient reviewed kudos')
1967
- .requiredOption('--as <agent-id>', 'recipient agent identity')
1968
- .option('--actor-kind <kind>', 'agent, human, or system', 'agent')
1969
- .option('--name <display-name>')
1970
- .option('--note <text>')
1971
- .action(async (id, options, command) => {
1972
- const global = globals(command);
1973
- const record = await withClient(global.home, actor(options.actorKind, options.as, options.name), (client) => client.kudos.acknowledge({
1974
- kudosId: id,
1975
- ...(options.note ? { note: options.note } : {}),
1976
- }));
1977
- output(io, global.json, record, `Acknowledged ${id} as ${options.as}.`);
1978
- });
1979
- kudosCommand
1980
- .command('revoke <kudos-id>')
1981
- .description('Record a revocation while preserving history')
1982
- .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1983
- .option('--actor-kind <kind>', 'human, agent, or system', 'human')
1984
- .requiredOption('--reason <reason>')
1985
- .option('--administrative', 'mark as an administrative revocation', false)
1986
- .action(async (id, options, command) => {
1987
- const global = globals(command);
1988
- const record = await withClient(global.home, actor(options.actorKind, options.as), (client) => client.kudos.revoke({
1989
- kudosId: id,
1990
- reason: options.reason,
1991
- administrative: options.administrative,
1992
- }));
1993
- output(io, global.json, record, `Revoked ${id}; the audit trail was preserved.`);
1994
- });
1995
- kudosCommand
1996
- .command('wins [agent]')
1997
- .description('Print the generated WINS.md path or content')
1998
- .option('--open', 'open WINS.md in the system GUI', false)
1999
- .option('--print', 'print Markdown content', false)
2000
- .action(async (agentId, options, command) => {
2093
+ /* ------------------------------------------------------------ maintenance */
2094
+ const projectionCommand = program
2095
+ .command('projection')
2096
+ .description('Inspect the generated files Synomem derives from events');
2097
+ projectionCommand
2098
+ .command('status')
2099
+ .description('Report whether the generated files match the canonical events (local stores)')
2100
+ .action(async (_options, command) => {
2001
2101
  const global = globals(command);
2002
- if (!agentId)
2003
- throw new SynomemError('INVALID_INPUT', 'Specify an agent.');
2004
- const details = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), async (client) => {
2005
- const profile = await client.agents.get(agentId);
2006
- const info = await client.info();
2007
- if (info.backend !== 'local') {
2008
- throw new SynomemError('INVALID_INPUT', 'Generated WINS.md files are available only with the local backend.');
2009
- }
2010
- const capabilities = await client.capabilities();
2011
- // Projections are written under the handle, since they exist to be read.
2012
- const path = join(info.home, profile.handle, 'WINS.md');
2013
- if (!existsSync(path)) {
2014
- const hint = capabilities.projections.writeWinsMarkdown
2015
- ? 'Run `synomem rebuild` to generate it.'
2016
- : 'Enable projection.writeWinsMarkdown and run `synomem rebuild`.';
2017
- throw new SynomemError('INVALID_INPUT', `No generated WINS.md exists for ${profile.handle}. ${hint}`);
2102
+ const status = await withManagement(command, (service) => {
2103
+ if (!service.projectionStatus) {
2104
+ throw new SynomemError('INVALID_INPUT', 'The hosted API keeps no filesystem projections, so there is nothing to report.');
2018
2105
  }
2019
- return { profile, path, content: readFileSync(path, 'utf8') };
2106
+ return service.projectionStatus();
2020
2107
  });
2021
- if (options.open) {
2022
- const commandName = process.platform === 'darwin'
2023
- ? 'open'
2024
- : process.platform === 'win32'
2025
- ? 'cmd'
2026
- : 'xdg-open';
2027
- const args = process.platform === 'win32' ? ['/c', 'start', '', details.path] : [details.path];
2028
- spawn(commandName, args, { detached: true, stdio: 'ignore' }).unref();
2029
- }
2030
- output(io, global.json, details, options.print ? details.content.trimEnd() : details.path);
2031
- });
2032
- addListOptions(kudosCommand.command('stats').description('Show aggregate kudos statistics')).action(async (options, command) => {
2033
- const global = globals(command);
2034
- const stats = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.stats(listInput(options)));
2035
- output(io, global.json, stats, `Total: ${stats.total}\nActive: ${stats.active}\nAcknowledged: ${stats.acknowledged}\nRevoked: ${stats.revoked}`);
2108
+ const enabled = Object.entries(status.settings)
2109
+ .filter(([, on]) => on)
2110
+ .map(([name]) => name);
2111
+ const lines = [
2112
+ `Directory: ${status.directory ?? '(none)'}`,
2113
+ `Enabled: ${enabled.length ? enabled.join(', ') : 'none'}`,
2114
+ `Last rebuilt: ${status.lastRebuiltAt ?? 'never'}`,
2115
+ status.current
2116
+ ? `Current: ${status.counts.manifest} generated file(s) match the events.`
2117
+ : `Stale: ${status.counts.missing} missing, ${status.counts.unexpected} no longer expected. Run \`synomem rebuild\`.`,
2118
+ ];
2119
+ for (const path of status.missing)
2120
+ lines.push(` missing ${path}`);
2121
+ for (const path of status.unexpected)
2122
+ lines.push(` unexpected ${path}`);
2123
+ output(io, global.json, status, lines.join('\n'));
2036
2124
  });
2037
2125
  program
2038
2126
  .command('rebuild')
2039
2127
  .description('Regenerate current-state and filesystem projections from canonical events')
2040
2128
  .action(async (_options, command) => {
2041
2129
  const global = globals(command);
2042
- const result = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.rebuild());
2130
+ const result = await withManagement(command, (service) => service.rebuild());
2043
2131
  output(io, global.json, result, `Rebuilt ${result.generated.length} file(s); removed ${result.removed.length} stale file(s).`);
2044
2132
  });
2045
2133
  program
2046
2134
  .command('backup <destination>')
2047
- .description('Create a transactionally consistent SQLite backup')
2135
+ .description('Create a transactionally consistent SQLite backup (local stores)')
2048
2136
  .action(async (destination, _options, command) => {
2049
2137
  const global = globals(command);
2050
- const path = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => {
2051
- if (!client.backup) {
2052
- throw new SynomemError('INVALID_INPUT', 'Filesystem backup is available only with the local backend.');
2138
+ const path = await withManagement(command, (service) => {
2139
+ if (!service.backup) {
2140
+ throw new SynomemError('INVALID_INPUT', 'Filesystem backup is available only for local stores.');
2053
2141
  }
2054
- return client.backup(destination);
2142
+ return service.backup(destination);
2055
2143
  });
2056
2144
  output(io, global.json, { path }, `Created backup at ${path}`);
2057
2145
  });
@@ -2062,7 +2150,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
2062
2150
  .option('--output <path>', 'write to an explicit destination instead of stdout')
2063
2151
  .action(async (options, command) => {
2064
2152
  const global = globals(command);
2065
- const content = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.export(options.format));
2153
+ const content = await withManagement(command, (service) => service.export(options.format));
2066
2154
  if (options.output) {
2067
2155
  const destination = resolve(options.output);
2068
2156
  atomicWriteFile(destination, content, 0o600);
@@ -2077,7 +2165,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
2077
2165
  .description('Run safe diagnostics')
2078
2166
  .action(async (_options, command) => {
2079
2167
  const global = globals(command);
2080
- const result = await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), (client) => client.doctor());
2168
+ const result = await withManagement(command, (service) => service.doctor());
2081
2169
  const human = result.diagnostics
2082
2170
  .map((item) => `${item.level.toUpperCase().padEnd(7)} ${item.code}: ${item.message}`)
2083
2171
  .join('\n');
@@ -2085,43 +2173,69 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
2085
2173
  if (!result.healthy)
2086
2174
  cliExitCodes.set(program, 5);
2087
2175
  });
2176
+ /* ------------------------------------------------------------ mcp */
2088
2177
  program
2089
2178
  .command('mcp')
2090
- .description('Run the actor-bound MCP server over stdio')
2091
- .option('--agent-id <id>', 'bound agent, whose identity is read from Synomem')
2092
- .option('--actor-id <id>', 'bound non-agent actor ID')
2093
- .option('--actor-kind <kind>', 'human or system')
2094
- .option('--actor-name <display-name>', 'display name for a non-agent actor')
2179
+ .description('Run the MCP server over stdio for a profile (fixed) or a preset (explicit)')
2180
+ .addOption(new Option('--contexts <mode>', 'fixed (a profile) or explicit (a preset)').choices([
2181
+ 'fixed',
2182
+ 'explicit',
2183
+ ]))
2095
2184
  .action(async (options, command) => {
2096
2185
  const global = globals(command);
2097
- // An agent's name comes from its profile, never from the command line:
2098
- // the name is written into every event the session appends, and a
2099
- // harness must not be able to sign another agent's name to work.
2100
- const bound = options.agentId
2101
- ? await withClient(global.home, defaultActor(env, 'system', 'cli', global.actor), async (client) => {
2102
- const resolution = await client.agents.resolve(options.agentId);
2103
- if (!resolution.match) {
2104
- throw new SynomemError('AGENT_NOT_FOUND', resolution.candidates.length
2105
- ? `"${options.agentId}" matches ${resolution.candidates.length} agents: ${resolution.candidates
2106
- .map((candidate) => candidate.id)
2107
- .join(', ')}. Name one of them.`
2108
- : `Unknown agent: ${options.agentId}`);
2109
- }
2110
- return actor('agent', resolution.match.id, resolution.match.displayName);
2111
- })
2112
- : undefined;
2113
- if (!bound && !(options.actorId && options.actorKind)) {
2114
- throw new SynomemError('INVALID_INPUT', 'Specify --agent-id, or --actor-id with --actor-kind for a non-agent actor.');
2186
+ const config = profileStoreFor(global.home).read();
2187
+ const selection = selectionFor(global, config);
2188
+ if (!selection)
2189
+ throw noSelectionError();
2190
+ if (selection.kind === 'preset' && options.contexts !== 'explicit') {
2191
+ throw new SynomemError('INVALID_INPUT', `Preset ${selection.name} serves several identities; start it with --contexts explicit so every tool call names its context.`);
2115
2192
  }
2116
- await startMcpServer({
2117
- ...(global.home ? { home: global.home } : {}),
2118
- actor: bound ?? actor(options.actorKind, options.actorId, options.actorName),
2119
- });
2193
+ if (selection.kind === 'profile' && options.contexts === 'explicit') {
2194
+ throw new SynomemError('INVALID_INPUT', 'A profile is one fixed identity. Use --preset <name> --contexts explicit for several.');
2195
+ }
2196
+ const deps = resolverDeps(global.home);
2197
+ const resolver = selection.kind === 'profile'
2198
+ ? profileResolver(global.home, config, selection.name, deps)
2199
+ : presetResolver(global.home, config, selection.name, deps);
2200
+ await (dependencies.startMcpServer ?? runStdioServer)({ resolver });
2120
2201
  });
2121
2202
  return program;
2122
2203
  }
2123
- export async function runCli(argv = process.argv, io = defaultIo, serviceFactory = configuredServiceFactory, dependencies = {}) {
2124
- const program = createCli(io, serviceFactory, dependencies, argv);
2204
+ /**
2205
+ * Serves one resolver over stdio until the client disconnects.
2206
+ *
2207
+ * Deliberately not imported from `mcp-server.js`: that file is the
2208
+ * `synomem-mcp` entry point and delegates to this module at its own top level,
2209
+ * so importing it back from here is a circular top-level await that never
2210
+ * settles when `synomem-mcp` is the process entry.
2211
+ */
2212
+ async function runStdioServer(options) {
2213
+ const runtime = await serveStdio(options.resolver, {});
2214
+ await new Promise((resolveClosed) => {
2215
+ const previous = runtime.server.server.onclose;
2216
+ runtime.server.server.onclose = () => {
2217
+ previous?.();
2218
+ resolveClosed();
2219
+ };
2220
+ // The stdio transport does not close itself when the client closes stdin;
2221
+ // without this the process would exit with this promise unsettled.
2222
+ process.stdin.once('end', () => resolveClosed());
2223
+ });
2224
+ await runtime.close().catch(() => undefined);
2225
+ await options.resolver.close?.();
2226
+ }
2227
+ export async function runCli(argv = process.argv, io = defaultIo, dependencies = {}) {
2228
+ const env = dependencies.env ?? process.env;
2229
+ const json = argv.includes('--json');
2230
+ const obsolete = obsoleteIdentityInput(argv, env);
2231
+ if (obsolete) {
2232
+ const message = `${obsolete} is no longer supported: the acting identity comes from a profile. Pass --profile <name> (see \`synomem profile list\`), or create one with \`synomem profile create\`.`;
2233
+ io.stderr(json
2234
+ ? `${JSON.stringify({ ok: false, error: { code: 'INVALID_INPUT', message } })}\n`
2235
+ : `Error [INVALID_INPUT]: ${message}\n`);
2236
+ return 2;
2237
+ }
2238
+ const program = createCli(io, dependencies);
2125
2239
  program.exitOverride();
2126
2240
  try {
2127
2241
  await program.parseAsync(argv);
@@ -2135,12 +2249,11 @@ export async function runCli(argv = process.argv, io = defaultIo, serviceFactory
2135
2249
  io.stderr(`${error.message}\n`);
2136
2250
  return 2;
2137
2251
  }
2138
- const kudosError = asSynomemError(error);
2139
- const json = argv.includes('--json');
2252
+ const synomemError = asSynomemError(error);
2140
2253
  io.stderr(json
2141
- ? `${JSON.stringify({ ok: false, error: { code: kudosError.code, message: kudosError.message } })}\n`
2142
- : `Error [${kudosError.code}]: ${kudosError.message}\n`);
2143
- return exitCode(kudosError.code);
2254
+ ? `${JSON.stringify({ ok: false, error: { code: synomemError.code, message: synomemError.message } })}\n`
2255
+ : `Error [${synomemError.code}]: ${synomemError.message}\n`);
2256
+ return exitCode(synomemError.code);
2144
2257
  }
2145
2258
  }
2146
2259
  if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {