synomem 0.8.0 → 0.9.1

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