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