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