synomem 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -1
- package/README.md +54 -14
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +8 -2
- package/dist/backend.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +451 -24
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +34 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +162 -11
- package/dist/client.js.map +1 -1
- package/dist/cloud.d.ts +16 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +19 -0
- package/dist/cloud.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +55 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +193 -0
- package/dist/configure.js.map +1 -0
- package/dist/credentials.d.ts +15 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +9 -1
- package/dist/credentials.js.map +1 -1
- package/dist/discover.d.ts +48 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +106 -0
- package/dist/discover.js.map +1 -0
- package/dist/import.d.ts +58 -43
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +15 -6
- package/dist/mcp/index.js.map +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/oauth.js.map +1 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +19 -11
- package/dist/projections.js.map +1 -1
- package/dist/prompt.d.ts +28 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +72 -0
- package/dist/prompt.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +4 -0
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +101 -60
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +57 -5
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +6 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +19 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +61 -14
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +50 -2
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +68 -4
- package/docs/examples.md +1 -1
- package/docs/mcp.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/storage-format.md +1 -1
- package/package.json +8 -8
- package/src/backend.ts +8 -2
- package/src/cli.ts +597 -31
- package/src/client.ts +176 -12
- package/src/cloud.ts +19 -0
- package/src/config.ts +8 -1
- package/src/configure.ts +249 -0
- package/src/credentials.ts +26 -3
- package/src/discover.ts +155 -0
- package/src/index.ts +9 -1
- package/src/mcp/index.ts +19 -5
- package/src/oauth.ts +8 -1
- package/src/projections.ts +21 -11
- package/src/prompt.ts +88 -0
- package/src/remote.ts +24 -0
- package/src/schemas.ts +60 -5
- package/src/service.ts +12 -0
- package/src/storage.ts +79 -13
- package/src/types.ts +45 -2
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
3
3
|
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
7
|
import { configuredServiceFactory, readSynomemConfig, writeSynomemBackend } from './backend.js';
|
|
8
|
+
import { cloudApiUrl } from './cloud.js';
|
|
9
|
+
import { resolveHome } from './config.js';
|
|
10
|
+
import { assertInteractive, confirmPlan, credentialFingerprint, credentialStoreChoices, environmentInstructions, readAccessToken, runConfigWizard, writeCredentialFile, } from './configure.js';
|
|
11
|
+
import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
|
|
12
|
+
import { defaultPromptIo } from './prompt.js';
|
|
8
13
|
import { credentialReference, OsCredentialStore } from './credentials.js';
|
|
9
14
|
import { asSynomemError, SynomemError } from './errors.js';
|
|
10
15
|
import { atomicWriteFile } from './fs-utils.js';
|
|
@@ -194,6 +199,8 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
194
199
|
const env = dependencies.env ?? process.env;
|
|
195
200
|
const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
|
|
196
201
|
const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
|
|
202
|
+
const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
|
|
203
|
+
const promptIo = dependencies.promptIo ?? defaultPromptIo();
|
|
197
204
|
const verifyRemoteCredential = dependencies.verifyRemoteCredential ??
|
|
198
205
|
(async (options) => {
|
|
199
206
|
const remote = new RemoteSynomemService({
|
|
@@ -216,11 +223,46 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
216
223
|
.name('synomem')
|
|
217
224
|
.description('Local-first communication, memory, recognition, and task infrastructure for agents')
|
|
218
225
|
.version(packageVersion())
|
|
219
|
-
.option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.
|
|
226
|
+
.option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.synomem)')
|
|
220
227
|
.option('--json', 'emit stable machine-readable JSON', false)
|
|
221
228
|
.showSuggestionAfterError()
|
|
222
229
|
.configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
|
|
223
230
|
const remoteCommand = program.command('remote').description('Administer a remote workspace');
|
|
231
|
+
/*
|
|
232
|
+
* The browser counterpart to an access key naming its own workspace.
|
|
233
|
+
*
|
|
234
|
+
* A signed-in account may reach several organizations, each with several
|
|
235
|
+
* workspaces, so there is a genuine choice to make -- and no way to make it
|
|
236
|
+
* without seeing the list. Printing the IDs alongside the names is the point:
|
|
237
|
+
* the ID is what `backend use remote --workspace` takes.
|
|
238
|
+
*/
|
|
239
|
+
remoteCommand
|
|
240
|
+
.command('workspaces')
|
|
241
|
+
.description('List the organizations and workspaces this credential can reach')
|
|
242
|
+
.option('--url <url>', 'internal: alternate HTTPS origin')
|
|
243
|
+
.action(async (options, command) => {
|
|
244
|
+
const global = globals(command);
|
|
245
|
+
const config = readSynomemConfig(global.home, env);
|
|
246
|
+
const baseUrl = options.url ??
|
|
247
|
+
(config?.backend.kind === 'remote' ? config.backend.baseUrl : cloudApiUrl(env));
|
|
248
|
+
const accessToken = env.SYNOMEM_ACCESS_TOKEN;
|
|
249
|
+
if (!accessToken) {
|
|
250
|
+
throw new SynomemError('AUTH_REQUIRED', 'Set SYNOMEM_ACCESS_TOKEN, or run `synomem auth login` first.');
|
|
251
|
+
}
|
|
252
|
+
const organizations = await discoverOrganizations({ baseUrl, accessToken });
|
|
253
|
+
const choices = workspaceChoices(organizations);
|
|
254
|
+
const human = organizations.length
|
|
255
|
+
? organizations
|
|
256
|
+
.map((organization) => [
|
|
257
|
+
`${organization.displayName} (${organization.slug}) — ${organization.role}`,
|
|
258
|
+
...(organization.workspaces.length
|
|
259
|
+
? organization.workspaces.map((workspace) => ` ${workspace.id} ${workspace.displayName}`)
|
|
260
|
+
: [' (no workspaces yet)']),
|
|
261
|
+
].join('\n'))
|
|
262
|
+
.join('\n')
|
|
263
|
+
: 'This account belongs to no organizations yet.';
|
|
264
|
+
output(io, global.json, { organizations, choices }, human);
|
|
265
|
+
});
|
|
224
266
|
remoteCommand
|
|
225
267
|
.command('import')
|
|
226
268
|
.description('Preview or confirm a one-way import from a local Synomem home')
|
|
@@ -292,6 +334,242 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
292
334
|
output(io, options.json, { home: info.home, database: info.databasePath }, `Initialized Synomem at ${info.home}`);
|
|
293
335
|
});
|
|
294
336
|
});
|
|
337
|
+
/*
|
|
338
|
+
* `synomem config` is the canonical entry point. `configure` and `setup` are
|
|
339
|
+
* accepted because people reach for them, and a setup program that rejects
|
|
340
|
+
* the word somebody guessed is needlessly unhelpful.
|
|
341
|
+
*/
|
|
342
|
+
const configCommand = program
|
|
343
|
+
.command('config')
|
|
344
|
+
.aliases(['configure', 'setup'])
|
|
345
|
+
.description('Set up Synomem, interactively or deterministically');
|
|
346
|
+
const applyPlan = async (plan, token, global) => {
|
|
347
|
+
const home = plan.home;
|
|
348
|
+
const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
|
|
349
|
+
/*
|
|
350
|
+
* The workspace is discovered, not typed.
|
|
351
|
+
*
|
|
352
|
+
* An installation access key is bound to exactly one workspace, so the
|
|
353
|
+
* service can be asked which one rather than the person. An explicit
|
|
354
|
+
* --workspace still wins, because automation should not depend on a
|
|
355
|
+
* network round trip to configure a machine.
|
|
356
|
+
*/
|
|
357
|
+
let workspaceId = plan.workspaceId;
|
|
358
|
+
if (plan.backend === 'remote' && !workspaceId && token) {
|
|
359
|
+
const bound = await discoverWorkspace({ baseUrl: serviceUrl, accessToken: token });
|
|
360
|
+
workspaceId = bound.workspaceId;
|
|
361
|
+
io.stdout(`Access key is bound to workspace ${workspaceId}.\n`);
|
|
362
|
+
}
|
|
363
|
+
if (plan.backend === 'remote' && !workspaceId) {
|
|
364
|
+
/*
|
|
365
|
+
* Signing in through a browser needs an actor identity and a client ID,
|
|
366
|
+
* which is `synomem auth login`'s job. Rather than write a remote
|
|
367
|
+
* backend with no workspace -- a configuration that fails on its first
|
|
368
|
+
* real use -- say exactly what remains.
|
|
369
|
+
*/
|
|
370
|
+
output(io, global.json, { applied: false, pending: 'sign-in', home, serviceUrl }, [
|
|
371
|
+
'',
|
|
372
|
+
'Nothing was configured yet: signing in through a browser is a separate step.',
|
|
373
|
+
'',
|
|
374
|
+
'Run, with the actor this machine acts as:',
|
|
375
|
+
'',
|
|
376
|
+
' synomem auth login --actor-id <agent> --client-id <client>',
|
|
377
|
+
'',
|
|
378
|
+
'Then select the workspace it reports:',
|
|
379
|
+
'',
|
|
380
|
+
' synomem backend use remote --workspace <workspace-id>',
|
|
381
|
+
].join('\n'));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const config = writeSynomemBackend(plan.backend === 'local'
|
|
385
|
+
? { kind: 'local' }
|
|
386
|
+
: { kind: 'remote', baseUrl: serviceUrl, workspaceId: workspaceId }, home);
|
|
387
|
+
let credentialLocation;
|
|
388
|
+
if (token) {
|
|
389
|
+
if (plan.credentialStore === 'environment') {
|
|
390
|
+
io.stdout(`${environmentInstructions(token)}\n`);
|
|
391
|
+
credentialLocation = 'environment';
|
|
392
|
+
}
|
|
393
|
+
else if (plan.credentialStore === 'file') {
|
|
394
|
+
credentialLocation = writeCredentialFile(home, token);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
// The platform store is the default, and a failure falls back to the
|
|
398
|
+
// restricted file rather than leaving the credential nowhere.
|
|
399
|
+
try {
|
|
400
|
+
await credentialStore.set(`synomem:${workspaceId}`, {
|
|
401
|
+
kind: 'installation-key',
|
|
402
|
+
accessToken: token,
|
|
403
|
+
});
|
|
404
|
+
credentialLocation = 'platform credential store';
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
credentialLocation = writeCredentialFile(home, token);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Diagnostics run before success is claimed: a configuration that cannot
|
|
412
|
+
// open its own database is not a finished setup.
|
|
413
|
+
const diagnostics = await withClient(home, defaultActor(env, 'system', 'cli'), (client) => client.doctor());
|
|
414
|
+
output(io, global.json, {
|
|
415
|
+
backend: config.backend,
|
|
416
|
+
home,
|
|
417
|
+
...(credentialLocation ? { credentialSource: credentialLocation } : {}),
|
|
418
|
+
...(token ? { credential: credentialFingerprint(token) } : {}),
|
|
419
|
+
healthy: diagnostics.healthy,
|
|
420
|
+
}, [
|
|
421
|
+
'',
|
|
422
|
+
'Synomem is ready.',
|
|
423
|
+
'',
|
|
424
|
+
` Backend: ${config.backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
|
|
425
|
+
` Home: ${home}`,
|
|
426
|
+
...(config.backend.kind === 'remote'
|
|
427
|
+
? [` Service: ${config.backend.baseUrl}`, ` Workspace: ${config.backend.workspaceId}`]
|
|
428
|
+
: []),
|
|
429
|
+
...(credentialLocation ? [` Credential: ${credentialLocation}`] : []),
|
|
430
|
+
` Database: ${diagnostics.healthy ? 'Healthy' : 'Needs attention — run synomem doctor'}`,
|
|
431
|
+
].join('\n'));
|
|
432
|
+
};
|
|
433
|
+
configCommand.action(async (_options, command) => {
|
|
434
|
+
const global = globals(command);
|
|
435
|
+
assertInteractive(promptIo);
|
|
436
|
+
const plan = await runConfigWizard(promptIo, {
|
|
437
|
+
...(global.home ? { home: global.home } : {}),
|
|
438
|
+
env,
|
|
439
|
+
});
|
|
440
|
+
const token = plan.auth === 'access-key' ? await readAccessToken(promptIo) : undefined;
|
|
441
|
+
if (!(await confirmPlan(promptIo, plan))) {
|
|
442
|
+
output(io, global.json, { applied: false }, 'Nothing was changed.');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
await applyPlan(plan, token, global);
|
|
446
|
+
});
|
|
447
|
+
configCommand
|
|
448
|
+
.command('init')
|
|
449
|
+
.description('Configure Synomem without prompting')
|
|
450
|
+
.option('--backend <kind>', 'local or remote')
|
|
451
|
+
.option('--auth <method>', 'browser or access-key')
|
|
452
|
+
.option('--workspace <id>', 'remote workspace ID')
|
|
453
|
+
.option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
|
|
454
|
+
// The token is read from stdin, never taken as an argument: an argument is
|
|
455
|
+
// kept by the shell history and visible in the process list.
|
|
456
|
+
.option('--access-token-stdin', 'read the installation access key from stdin', false)
|
|
457
|
+
.option('--yes', 'apply without confirming', false)
|
|
458
|
+
.action(async (options, command) => {
|
|
459
|
+
const global = globals(command);
|
|
460
|
+
if (options.backend !== 'local' && options.backend !== 'remote') {
|
|
461
|
+
throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
|
|
462
|
+
}
|
|
463
|
+
const backend = options.backend;
|
|
464
|
+
// An access key names its own workspace, so --workspace is only
|
|
465
|
+
// required when there is no key to ask.
|
|
466
|
+
if (backend === 'remote' && !options.workspace && !options.accessTokenStdin) {
|
|
467
|
+
throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.');
|
|
468
|
+
}
|
|
469
|
+
const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
|
|
470
|
+
if (backend === 'remote' && options.auth === 'access-key' && !token) {
|
|
471
|
+
throw new SynomemError('INVALID_INPUT', 'Access-key setup requires --access-token-stdin so the key is not passed as an argument.');
|
|
472
|
+
}
|
|
473
|
+
const plan = {
|
|
474
|
+
backend,
|
|
475
|
+
home: resolveHome(global.home),
|
|
476
|
+
...(backend === 'remote'
|
|
477
|
+
? {
|
|
478
|
+
serviceUrl: cloudApiUrl(env),
|
|
479
|
+
auth: options.auth ?? 'access-key',
|
|
480
|
+
workspaceId: options.workspace,
|
|
481
|
+
credentialStore: options.credentialStore,
|
|
482
|
+
}
|
|
483
|
+
: {}),
|
|
484
|
+
};
|
|
485
|
+
if (!options.yes) {
|
|
486
|
+
throw new SynomemError('INVALID_INPUT', 'Re-run with --yes to apply this configuration.');
|
|
487
|
+
}
|
|
488
|
+
await applyPlan(plan, token, global);
|
|
489
|
+
});
|
|
490
|
+
configCommand
|
|
491
|
+
.command('show')
|
|
492
|
+
.description('Show the current configuration without revealing secrets')
|
|
493
|
+
.action(async (_options, command) => {
|
|
494
|
+
const global = globals(command);
|
|
495
|
+
const home = resolveHome(global.home);
|
|
496
|
+
const config = readSynomemConfig(global.home, env);
|
|
497
|
+
const backend = config?.backend ?? { kind: 'local' };
|
|
498
|
+
const credentialSource = env.SYNOMEM_ACCESS_TOKEN
|
|
499
|
+
? 'environment (SYNOMEM_ACCESS_TOKEN)'
|
|
500
|
+
: existsSync(join(home, 'credentials', 'installation.json'))
|
|
501
|
+
? 'restricted file'
|
|
502
|
+
: 'platform credential store or none';
|
|
503
|
+
output(io, global.json,
|
|
504
|
+
// Never the secret itself, only where it comes from.
|
|
505
|
+
{ backend, home, credentialSource, stores: credentialStoreChoices().map((c) => c.value) }, [
|
|
506
|
+
`Backend: ${backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
|
|
507
|
+
`Home: ${home}`,
|
|
508
|
+
...(backend.kind === 'remote'
|
|
509
|
+
? [`Service: ${backend.baseUrl}`, `Workspace: ${backend.workspaceId}`]
|
|
510
|
+
: []),
|
|
511
|
+
`Credential: ${credentialSource}`,
|
|
512
|
+
].join('\n'));
|
|
513
|
+
});
|
|
514
|
+
program
|
|
515
|
+
.command('reset')
|
|
516
|
+
.description('Remove Synomem configuration, database and credentials')
|
|
517
|
+
// Integrations are opt-in because they live in other tools' directories.
|
|
518
|
+
// Removing somebody's harness configuration as a side effect of resetting
|
|
519
|
+
// Synomem would be a surprise with no undo.
|
|
520
|
+
.option('--integrations', 'also remove installed skills and MCP registrations', false)
|
|
521
|
+
.option('--yes', 'apply the displayed plan', false)
|
|
522
|
+
.action(async (options, command) => {
|
|
523
|
+
const global = globals(command);
|
|
524
|
+
const home = resolveHome(global.home);
|
|
525
|
+
/*
|
|
526
|
+
* Every target is an exact path, listed before anything is touched. No
|
|
527
|
+
* recursive delete is ever derived from a variable that might be empty:
|
|
528
|
+
* a reset that computes `rm -rf $HOME/` from an unset home is the
|
|
529
|
+
* failure this shape exists to make impossible.
|
|
530
|
+
*/
|
|
531
|
+
const targets = [
|
|
532
|
+
join(home, 'config.json'),
|
|
533
|
+
join(home, 'synomem.sqlite3'),
|
|
534
|
+
join(home, 'synomem.sqlite3-wal'),
|
|
535
|
+
join(home, 'synomem.sqlite3-shm'),
|
|
536
|
+
join(home, 'credentials', 'installation.json'),
|
|
537
|
+
].filter((path) => existsSync(path));
|
|
538
|
+
const skillPlan = options.integrations ? uninstallSkill({ apply: false }) : undefined;
|
|
539
|
+
const skillTargets = skillPlan?.locations
|
|
540
|
+
// Installed Synomem-owned copies only; an unowned directory at
|
|
541
|
+
// the same path is not ours to remove.
|
|
542
|
+
.filter((location) => location.state === 'current' || location.state === 'stale')
|
|
543
|
+
.map((location) => location.target) ?? [];
|
|
544
|
+
if (!options.yes) {
|
|
545
|
+
output(io, global.json, { targets, skillTargets, applied: false }, [
|
|
546
|
+
'This will remove:',
|
|
547
|
+
...(targets.length ? targets.map((path) => ` ${path}`) : [' (nothing found)']),
|
|
548
|
+
...(skillTargets.length ? ['', 'And these Synomem-owned skills:'] : []),
|
|
549
|
+
...skillTargets.map((path) => ` ${path}`),
|
|
550
|
+
'',
|
|
551
|
+
...(options.integrations
|
|
552
|
+
? []
|
|
553
|
+
: ['Installed skills and MCP registrations are left alone.', '']),
|
|
554
|
+
'Run with --yes to continue.',
|
|
555
|
+
].join('\n'));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const removed = [];
|
|
559
|
+
for (const path of targets) {
|
|
560
|
+
rmSync(path, { force: true });
|
|
561
|
+
removed.push(path);
|
|
562
|
+
}
|
|
563
|
+
// Only ownership-stamped Synomem skills are removed, which uninstall
|
|
564
|
+
// already enforces — an unowned directory at the same path is left.
|
|
565
|
+
const skillResult = options.integrations ? uninstallSkill({ apply: true }) : undefined;
|
|
566
|
+
output(io, global.json, { removed, skills: skillResult?.locations ?? [] }, [
|
|
567
|
+
`Removed ${removed.length} file(s).`,
|
|
568
|
+
...(skillResult
|
|
569
|
+
? [`Skill locations processed: ${skillResult.locations.length}.`]
|
|
570
|
+
: ['Installed skills and MCP registrations were left alone.']),
|
|
571
|
+
].join('\n'));
|
|
572
|
+
});
|
|
295
573
|
const backendCommand = program
|
|
296
574
|
.command('backend')
|
|
297
575
|
.description('Inspect or select the canonical backend');
|
|
@@ -311,21 +589,103 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
311
589
|
.command('use')
|
|
312
590
|
.description('Select local or remote canonical state')
|
|
313
591
|
.argument('<kind>', 'local or remote')
|
|
314
|
-
|
|
592
|
+
// --url is for development and private deployments. It stays out of the
|
|
593
|
+
// README, the public docs and the packaged skill: public onboarding must
|
|
594
|
+
// never ask for a service address, because a person has no way to tell a
|
|
595
|
+
// real one from a phished one.
|
|
596
|
+
.option('--url <url>', 'internal: alternate HTTPS origin')
|
|
315
597
|
.option('--workspace <id>', 'remote workspace ID')
|
|
316
598
|
.action((kind, options, command) => {
|
|
317
599
|
const global = globals(command);
|
|
318
600
|
if (kind !== 'local' && kind !== 'remote') {
|
|
319
601
|
throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
|
|
320
602
|
}
|
|
321
|
-
if (kind === 'remote' &&
|
|
322
|
-
throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --
|
|
603
|
+
if (kind === 'remote' && !options.workspace) {
|
|
604
|
+
throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --workspace.');
|
|
323
605
|
}
|
|
324
606
|
const config = writeSynomemBackend(kind === 'local'
|
|
325
607
|
? { kind: 'local' }
|
|
326
|
-
: {
|
|
608
|
+
: {
|
|
609
|
+
kind: 'remote',
|
|
610
|
+
baseUrl: options.url ?? cloudApiUrl(env),
|
|
611
|
+
workspaceId: options.workspace,
|
|
612
|
+
}, global.home);
|
|
327
613
|
output(io, global.json, { backend: config.backend }, `Selected ${config.backend.kind} Synomem backend.`);
|
|
328
614
|
});
|
|
615
|
+
/*
|
|
616
|
+
* `show` reads the config file; `status` proves the selection actually works.
|
|
617
|
+
*
|
|
618
|
+
* The two are deliberately separate. A person debugging a broken setup needs
|
|
619
|
+
* to know what is configured even when nothing can be reached, and a person
|
|
620
|
+
* checking that a setup is live needs a connection to have been made. One
|
|
621
|
+
* command doing both would make a printed workspace ID look like a reachable
|
|
622
|
+
* workspace.
|
|
623
|
+
*/
|
|
624
|
+
backendCommand
|
|
625
|
+
.command('status')
|
|
626
|
+
.description('Connect to the selected backend and report what answered')
|
|
627
|
+
.action(async (_options, command) => {
|
|
628
|
+
const global = globals(command);
|
|
629
|
+
const config = readSynomemConfig(global.home);
|
|
630
|
+
if (!config) {
|
|
631
|
+
throw new SynomemError('CONFIG_INVALID', 'No Synomem home here yet. Run `synomem config init` first.');
|
|
632
|
+
}
|
|
633
|
+
const result = await withClient(global.home, defaultActor(env, 'system', 'cli'), async (client) => ({
|
|
634
|
+
info: await client.info(),
|
|
635
|
+
capabilities: await client.capabilities(),
|
|
636
|
+
diagnostics: (await client.doctor()).diagnostics.filter((item) => item.level === 'error' || item.level === 'warning'),
|
|
637
|
+
}));
|
|
638
|
+
const { info, capabilities, diagnostics } = result;
|
|
639
|
+
const where = info.backend === 'local'
|
|
640
|
+
? `Home: ${info.home}\nDatabase: ${info.databasePath}`
|
|
641
|
+
: `URL: ${info.baseUrl}`;
|
|
642
|
+
const problems = diagnostics.length
|
|
643
|
+
? diagnostics
|
|
644
|
+
.map((item) => `${item.level.toUpperCase()} ${item.code}: ${item.message}`)
|
|
645
|
+
.join('\n')
|
|
646
|
+
: 'No warnings or errors.';
|
|
647
|
+
const human = [
|
|
648
|
+
`Backend: ${info.backend} (reachable)`,
|
|
649
|
+
where,
|
|
650
|
+
`Workspace: ${capabilities.binding.workspaceId}`,
|
|
651
|
+
`Acting as: ${capabilities.binding.actor.kind} ${capabilities.binding.actor.id}`,
|
|
652
|
+
problems,
|
|
653
|
+
].join('\n');
|
|
654
|
+
output(io, global.json, { reachable: true, info, capabilities, diagnostics }, human);
|
|
655
|
+
if (diagnostics.some((item) => item.level === 'error'))
|
|
656
|
+
cliExitCodes.set(program, 5);
|
|
657
|
+
});
|
|
658
|
+
const projectionCommand = program
|
|
659
|
+
.command('projection')
|
|
660
|
+
.description('Inspect the generated files Synomem derives from events');
|
|
661
|
+
projectionCommand
|
|
662
|
+
.command('status')
|
|
663
|
+
.description('Report whether the generated files match the canonical events')
|
|
664
|
+
.action(async (_options, command) => {
|
|
665
|
+
const global = globals(command);
|
|
666
|
+
const status = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => {
|
|
667
|
+
if (!client.projectionStatus) {
|
|
668
|
+
throw new SynomemError('INVALID_INPUT', 'The remote backend keeps no filesystem projections, so there is nothing to report.');
|
|
669
|
+
}
|
|
670
|
+
return client.projectionStatus();
|
|
671
|
+
});
|
|
672
|
+
const enabled = Object.entries(status.settings)
|
|
673
|
+
.filter(([, on]) => on)
|
|
674
|
+
.map(([name]) => name);
|
|
675
|
+
const lines = [
|
|
676
|
+
`Directory: ${status.directory ?? '(none)'}`,
|
|
677
|
+
`Enabled: ${enabled.length ? enabled.join(', ') : 'none'}`,
|
|
678
|
+
`Last rebuilt: ${status.lastRebuiltAt ?? 'never'}`,
|
|
679
|
+
status.current
|
|
680
|
+
? `Current: ${status.counts.manifest} generated file(s) match the events.`
|
|
681
|
+
: `Stale: ${status.counts.missing} missing, ${status.counts.unexpected} no longer expected. Run \`synomem rebuild\`.`,
|
|
682
|
+
];
|
|
683
|
+
for (const path of status.missing)
|
|
684
|
+
lines.push(` missing ${path}`);
|
|
685
|
+
for (const path of status.unexpected)
|
|
686
|
+
lines.push(` unexpected ${path}`);
|
|
687
|
+
output(io, global.json, status, lines.join('\n'));
|
|
688
|
+
});
|
|
329
689
|
const authCommand = program.command('auth').description('Inspect remote authentication');
|
|
330
690
|
authCommand
|
|
331
691
|
.command('status')
|
|
@@ -419,20 +779,65 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
419
779
|
.command('agent')
|
|
420
780
|
.description('Create and inspect stable agent identities');
|
|
421
781
|
agentCommand
|
|
422
|
-
.command('create <
|
|
423
|
-
.description('Create
|
|
782
|
+
.command('create <handle>')
|
|
783
|
+
.description('Create an agent. The canonical ID is generated, not chosen.')
|
|
424
784
|
.requiredOption('--name <display-name>', 'display name')
|
|
425
|
-
.option('--alias <
|
|
785
|
+
.option('--alias <name>', 'alias (repeatable)', collect, [])
|
|
426
786
|
.option('--description <text>')
|
|
427
|
-
.action(async (
|
|
787
|
+
.action(async (handle, options, command) => {
|
|
428
788
|
const global = globals(command);
|
|
429
789
|
const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.create({
|
|
430
|
-
|
|
790
|
+
handle,
|
|
431
791
|
displayName: options.name,
|
|
432
792
|
...(options.alias.length ? { aliases: options.alias } : {}),
|
|
433
793
|
...(options.description ? { description: options.description } : {}),
|
|
434
794
|
}));
|
|
435
|
-
|
|
795
|
+
// Both are printed because both matter: the handle is what people type,
|
|
796
|
+
// the ID is what every event records and what MCP registration uses.
|
|
797
|
+
output(io, global.json, profile, `Created ${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}`);
|
|
798
|
+
});
|
|
799
|
+
const aliasCommand = agentCommand
|
|
800
|
+
.command('alias')
|
|
801
|
+
.description('Add or remove discovery aliases without replacing the set');
|
|
802
|
+
aliasCommand
|
|
803
|
+
.command('add <agent> <alias...>')
|
|
804
|
+
.description('Add aliases, keeping the ones already there')
|
|
805
|
+
.action(async (agent, aliases, _options, command) => {
|
|
806
|
+
const global = globals(command);
|
|
807
|
+
const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.addAliases(agent, aliases));
|
|
808
|
+
output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
|
|
809
|
+
});
|
|
810
|
+
aliasCommand
|
|
811
|
+
.command('remove <agent> <alias...>')
|
|
812
|
+
.description('Remove aliases, keeping the rest')
|
|
813
|
+
.action(async (agent, aliases, _options, command) => {
|
|
814
|
+
const global = globals(command);
|
|
815
|
+
const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.removeAliases(agent, aliases));
|
|
816
|
+
output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
|
|
817
|
+
});
|
|
818
|
+
agentCommand
|
|
819
|
+
.command('rename <agent> <handle>')
|
|
820
|
+
.description('Change an agent handle. Its canonical ID never changes.')
|
|
821
|
+
.action(async (agent, handle, _options, command) => {
|
|
822
|
+
const global = globals(command);
|
|
823
|
+
const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.update(agent, { handle }));
|
|
824
|
+
output(io, global.json, profile, `Handle: ${profile.handle}\nAgent ID: ${profile.id} (unchanged)`);
|
|
825
|
+
});
|
|
826
|
+
agentCommand
|
|
827
|
+
.command('archive <agent>')
|
|
828
|
+
.description('Stop an agent acting, keeping its records and history')
|
|
829
|
+
.action(async (agent, _options, command) => {
|
|
830
|
+
const global = globals(command);
|
|
831
|
+
const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.archive(agent));
|
|
832
|
+
output(io, global.json, profile, `Archived ${profile.handle} (${profile.id})`);
|
|
833
|
+
});
|
|
834
|
+
agentCommand
|
|
835
|
+
.command('restore <agent>')
|
|
836
|
+
.description('Let an archived agent act again')
|
|
837
|
+
.action(async (agent, _options, command) => {
|
|
838
|
+
const global = globals(command);
|
|
839
|
+
const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.restore(agent));
|
|
840
|
+
output(io, global.json, profile, `Restored ${profile.handle} (${profile.id})`);
|
|
436
841
|
});
|
|
437
842
|
const skillCommand = program
|
|
438
843
|
.command('skill')
|
|
@@ -528,7 +933,10 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
528
933
|
const agents = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.list());
|
|
529
934
|
const human = agents.length
|
|
530
935
|
? agents
|
|
531
|
-
.map((profile) =>
|
|
936
|
+
.map((profile) =>
|
|
937
|
+
// Handle first: it is what people type. The canonical ID
|
|
938
|
+
// follows because MCP registration needs it.
|
|
939
|
+
`${profile.handle} ${profile.displayName}${profile.status === 'archived' ? ' [archived]' : ''}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}\n ${profile.id}`)
|
|
532
940
|
.join('\n')
|
|
533
941
|
: 'No agents configured.';
|
|
534
942
|
output(io, global.json, { agents }, human);
|
|
@@ -539,7 +947,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
539
947
|
.action(async (id, _options, command) => {
|
|
540
948
|
const global = globals(command);
|
|
541
949
|
const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.get(id));
|
|
542
|
-
output(io, global.json, profile, `${profile.displayName}
|
|
950
|
+
output(io, global.json, profile, `${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}\nStatus: ${profile.status}\n\n${profile.description ?? 'No description.'}`);
|
|
543
951
|
});
|
|
544
952
|
agentCommand
|
|
545
953
|
.command('update <id>')
|
|
@@ -615,18 +1023,36 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
615
1023
|
}));
|
|
616
1024
|
output(io, global.json, binding, `Bound ${binding.agentId} to ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} (${binding.id})`);
|
|
617
1025
|
});
|
|
1026
|
+
/*
|
|
1027
|
+
* With no agent named this answers the question people actually arrive with:
|
|
1028
|
+
* "where is any of my stuff running?". Naming an agent narrows it. Requiring
|
|
1029
|
+
* the agent, as this once did, means you must already know the answer to the
|
|
1030
|
+
* question you came to ask.
|
|
1031
|
+
*/
|
|
618
1032
|
runtimeCommand
|
|
619
|
-
.command('list
|
|
620
|
-
.description('List
|
|
1033
|
+
.command('list [agent]')
|
|
1034
|
+
.description('List runtime bindings for one agent, or for every agent')
|
|
621
1035
|
.action(async (agent, _options, command) => {
|
|
622
1036
|
const global = globals(command);
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1037
|
+
const result = await withClient(global.home, defaultActor(env, 'system', 'cli'), async (client) => {
|
|
1038
|
+
if (agent) {
|
|
1039
|
+
const profile = await client.agents.get(agent);
|
|
1040
|
+
return [{ profile, runtimeBindings: await client.agents.bindings(agent) }];
|
|
1041
|
+
}
|
|
1042
|
+
return (await client.agents.directory()).filter((entry) => entry.runtimeBindings.length > 0);
|
|
1043
|
+
});
|
|
1044
|
+
const describe = (binding) => ` ${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`;
|
|
1045
|
+
const human = result.length
|
|
1046
|
+
? result
|
|
1047
|
+
.map((entry) => [
|
|
1048
|
+
`${entry.profile.handle} (${entry.profile.id})`,
|
|
1049
|
+
...entry.runtimeBindings.map(describe),
|
|
1050
|
+
].join('\n'))
|
|
627
1051
|
.join('\n')
|
|
628
|
-
:
|
|
629
|
-
|
|
1052
|
+
: agent
|
|
1053
|
+
? 'No runtime bindings.'
|
|
1054
|
+
: 'No agent in this workspace has a runtime binding.';
|
|
1055
|
+
output(io, global.json, { agents: result }, human);
|
|
630
1056
|
});
|
|
631
1057
|
runtimeCommand
|
|
632
1058
|
.command('unbind <binding-id>')
|
|
@@ -1240,12 +1666,13 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
|
|
|
1240
1666
|
throw new SynomemError('INVALID_INPUT', 'Generated WINS.md files are available only with the local backend.');
|
|
1241
1667
|
}
|
|
1242
1668
|
const capabilities = await client.capabilities();
|
|
1243
|
-
|
|
1669
|
+
// Projections are written under the handle, since they exist to be read.
|
|
1670
|
+
const path = join(info.home, profile.handle, 'WINS.md');
|
|
1244
1671
|
if (!existsSync(path)) {
|
|
1245
1672
|
const hint = capabilities.projections.writeWinsMarkdown
|
|
1246
1673
|
? 'Run `synomem rebuild` to generate it.'
|
|
1247
1674
|
: 'Enable projection.writeWinsMarkdown and run `synomem rebuild`.';
|
|
1248
|
-
throw new SynomemError('INVALID_INPUT', `No generated WINS.md exists for ${profile.
|
|
1675
|
+
throw new SynomemError('INVALID_INPUT', `No generated WINS.md exists for ${profile.handle}. ${hint}`);
|
|
1249
1676
|
}
|
|
1250
1677
|
return { profile, path, content: readFileSync(path, 'utf8') };
|
|
1251
1678
|
});
|