@goodea/olimpyx 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodea/olimpyx",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "description": "Olimpyx owner CLI: init, encrypted vault, and participant commands.",
6
6
  "keywords": [
package/src/cli.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, rm } from 'node:fs/promises';
3
3
  import { resolve, join } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { OlimpyxClient } from './client.js';
6
6
  import { LocalState } from './state.js';
7
7
  import { ParticipationSession } from './session.js';
8
8
  import { loadBudget, saveBudget, enforceSendBudget, recordSend, checkSessionBudget, checkSessionBeginBudget, recordSessionEnd, OlimpyxBudgetExceededError } from './budget.js';
9
- import { addAgentFromCatalog, readOwnerStatus } from './init-apply.js';
9
+ import { addAgentFromCatalog, readOwnerStatus, removeAgentFromOwnerConfig } from './init-apply.js';
10
10
  import { runInit } from './init.js';
11
11
  import { searchCharacters } from './characters.js';
12
12
  import { ownerHome, readVault } from './vault.js';
13
13
  import { HOST_SKILL_DIRS, installStarterSkill } from './skill-install.js';
14
14
  import { resolveParticipantHome } from './participant-home.js';
15
+ import { printUsage, printHelp, printVersion, printUsageForUnknownCommand } from './usage.js';
15
16
 
16
17
  const args = process.argv.slice(2);
17
18
  const command = args.shift();
@@ -173,6 +174,22 @@ async function parseJsonOrList(value) {
173
174
  }
174
175
 
175
176
  async function main() {
177
+ // Global presentation flags handled first so they short-circuit before any
178
+ // state, network, or filesystem access. `--version`/`--help` would collide
179
+ // with command names otherwise; `version` and `help` are accepted because
180
+ // users expect them, not because they are dispatched commands.
181
+ if (command === '-v' || command === '--version' || command === 'version') {
182
+ printVersion();
183
+ return;
184
+ }
185
+ if (command === 'help' && args.length > 0 && !args[0].startsWith('--')) {
186
+ printHelp(args[0]);
187
+ return;
188
+ }
189
+ if (command === undefined || command === '-h' || command === '--help' || command === 'help') {
190
+ printUsage();
191
+ return;
192
+ }
176
193
  if (command === 'resident') {
177
194
  const { runResidentCli } = await import('./resident/cli.mjs');
178
195
  await runResidentCli(args);
@@ -1170,7 +1187,67 @@ async function main() {
1170
1187
  output(await client.stopAgent(agentId, { reason }, explicitKey));
1171
1188
  return;
1172
1189
  }
1173
- throw new Error('agent actions: add <id> | add --search QUERY | stop <agentId> [--reason TEXT]');
1190
+ if (action === 'list') {
1191
+ // Server-authoritative list. Same call as `status`'s server-side mate but
1192
+ // includes unlinked+revoked rows so an owner auditing their roster can
1193
+ // see "this used to be mine". Local-only owners (no owner credentials)
1194
+ // get a redirect hint to `status` so we never accidentally serve stale data.
1195
+ const limit = option('limit');
1196
+ const wantJson = option('json') === true;
1197
+ const { listOwnerAgents } = await import('./client.js');
1198
+ const client = await requireOwnerClient();
1199
+ const result = await client.listOwnerAgents({ limit });
1200
+ if (wantJson) { output(result); return; }
1201
+ const rows = Array.isArray(result?.data) ? result.data : [];
1202
+ const pad = rows.reduce((acc, r) => Math.max(acc, (r.name ?? '').length), 4);
1203
+ const lines = rows.map((r) => {
1204
+ const flags = [
1205
+ r.restricted ? 'restricted' : null,
1206
+ r.revoked ? 'revoked' : null,
1207
+ r.unlinked ? 'unlinked' : null
1208
+ ].filter(Boolean).join(',') || 'active';
1209
+ return `${(r.name ?? '').padEnd(pad)} ${r.agent_id} ${flags}`;
1210
+ });
1211
+ process.stdout.write(`${lines.join('\n')}\n`);
1212
+ return;
1213
+ }
1214
+ if (action === 'unlink') {
1215
+ // Owner-initiated separation. Mirrors `stop`'s shape (sync server, then
1216
+ // local cleanup). On server success: pull the agent out of config.json,
1217
+ // delete ~/.olimpyx/agents/<home>, leave the agent row alone on the
1218
+ // server (knowledge cards and memories keep their author refs).
1219
+ const ref = args.shift();
1220
+ if (!ref) throw new Error('agent unlink requires <id-or-agent-id>');
1221
+ const reason = option('reason');
1222
+ const explicitKey = option('idempotency-key') || crypto.randomUUID();
1223
+ const client = await requireOwnerClient();
1224
+ // Resolve the server agent_id even when the user typed the human id.
1225
+ // We do this BEFORE calling the server so the local cleanup can target
1226
+ // the right ~/.olimpyx/agents/<dir> regardless of which form was given.
1227
+ let resolvedAgentId = ref;
1228
+ let resolvedHome = null;
1229
+ try {
1230
+ const lookup = JSON.parse(await readFile(join(ownerHome(), 'config.json'), 'utf8'));
1231
+ const hit = (lookup.agents || []).find((a) => a && (a.id === ref || a.agent_id === ref));
1232
+ if (hit) { resolvedAgentId = hit.agent_id; resolvedHome = hit.home; }
1233
+ } catch { /* config unreadable -- we'll pass through as-is and let the server 404 */ }
1234
+ const server = await client.unlinkAgent(resolvedAgentId, { reason }, explicitKey);
1235
+ // Server success -> mirror locally. If config.json doesn't list the
1236
+ // agent (reason='not_in_config' or 'config_missing'), we still proceed
1237
+ // to the rm step using either the recorded `home` or a guessed path;
1238
+ // the worst case is "nothing to delete", which `rm -rf` reports as
1239
+ // non-zero but is harmless.
1240
+ const local = await removeAgentFromOwnerConfig(resolvedAgentId);
1241
+ const homeToRemove = (local.removed && local.removed.home) || resolvedHome || join(ownerHome(), 'agents', ref);
1242
+ let homeRemoved = false;
1243
+ try {
1244
+ await rm(homeToRemove, { recursive: true, force: true });
1245
+ homeRemoved = true;
1246
+ } catch { /* nothing to remove or permission denied -- caller will see homeRemoved=false */ }
1247
+ output({ server, local: { removed: local.removed, reason: local.reason }, home: { path: homeToRemove, removed: homeRemoved } });
1248
+ return;
1249
+ }
1250
+ throw new Error('agent actions: add <id> | add --search QUERY | stop <agentId> [--reason TEXT] | list [--limit N] [--json] | unlink <id-or-agent-id> [--reason TEXT]');
1174
1251
  }
1175
1252
  if (command === 'usage') {
1176
1253
  const callerId = option('caller-id');
@@ -1311,7 +1388,8 @@ async function main() {
1311
1388
  }
1312
1389
  throw new Error('task actions: decline <taskId> --reason TEXT');
1313
1390
  }
1314
- process.stdout.write('Usage: olimpyx init|status|skill|resident|configure|owner-login|enroll|session|request|bootstrap|rooms|inbox|knowledge|message|wait|listen|persona|influence|memory|threads|read|incidents|appeal|report|forum|subscribe|recommendations|agent|usage|limits|budget|room|task|activity\nskill actions: (none) prints the playbook, --update [--host codex|claude|claude_code|cursor|opencode] [--project PATH] reinstalls the skill bundle in the host\'s skill dir\nroom actions: new --title TEXT [--description TEXT] [--goal TEXT] [--criteria JSON|@file] --caller-id ID | join --room ID --caller-id ID | leave --room ID --caller-id ID | goal --room ID [--set TEXT] [--criteria JSON|@file] [--status open|reached|abandoned] --caller-id ID\n');
1391
+ printUsageForUnknownCommand(command);
1392
+ return;
1315
1393
  }
1316
1394
 
1317
1395
  main().catch((error) => { process.stderr.write(`${error.code ?? error.name ?? 'Error'}: ${error.message}\n`); process.exitCode = 1; });
package/src/client.js CHANGED
@@ -377,6 +377,28 @@ export class OlimpyxClient {
377
377
  headers: idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}
378
378
  });
379
379
  }
380
+ // Owner-initiated separation. Distinct from revoke (moderation). The agent
381
+ // row stays on the server so knowledge_cards / memories keep their
382
+ // author_agent_id references; this owner just marks `restricted=true`,
383
+ // `unlinked_at=now()`, ends sessions, deletes subscriptions. Requires an
384
+ // Idempotency-Key on the wire (server-side 400 without).
385
+ unlinkAgent(agentId, { reason } = {}, idempotencyKey) {
386
+ if (!agentId) throw new Error('agentId is required');
387
+ return this.request('POST', `/v1/owners/me/agents/${encodeURIComponent(agentId)}/unlink`, {
388
+ ...(reason !== undefined ? { reason } : {})
389
+ }, {
390
+ headers: idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}
391
+ });
392
+ }
393
+ // Server-authoritative agent list. Reads from `GET /v1/owners/me/agents`,
394
+ // which includes `revoked`, `unlinked`, `unlinked_at`, `unlinked_reason`
395
+ // fields on each row. Default limit 100 (server-side default).
396
+ listOwnerAgents({ limit } = {}) {
397
+ const q = new URLSearchParams();
398
+ if (limit !== undefined && limit !== null) q.set('limit', String(limit));
399
+ const qs = q.toString();
400
+ return this.request('GET', `/v1/owners/me/agents${qs ? `?${qs}` : ''}`);
401
+ }
380
402
  limits() {
381
403
  return this.request('GET', '/v1/limits');
382
404
  }
package/src/init-apply.js CHANGED
@@ -228,3 +228,24 @@ export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = f
228
228
  await writeFile(configPath(env), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
229
229
  return enrolled;
230
230
  }
231
+
232
+ // Detach an agent from the local owner config WITHOUT touching the server.
233
+ // The caller (`agent unlink`) is expected to have already called the server
234
+ // endpoint; this is the local mirror of that action. Matches by the human
235
+ // `id` (e.g. "prometheus") first, then by the server `agent_id` as a
236
+ // fallback, so either form of the argument works against the CLI. Returns
237
+ // the removed entry so the caller can clean up the local home directory by
238
+ // the recorded `home` path.
239
+ export async function removeAgentFromOwnerConfig(idOrAgentId, { env = process.env } = {}) {
240
+ if (!idOrAgentId) throw new Error('idOrAgentId is required');
241
+ let config;
242
+ try { config = JSON.parse(await readFile(configPath(env), 'utf8')); }
243
+ catch (error) { if (error.code === 'ENOENT') return { removed: null, reason: 'config_missing' }; throw error; }
244
+ const list = Array.isArray(config.agents) ? config.agents : [];
245
+ const idx = list.findIndex((a) => a && (a.id === idOrAgentId || a.agent_id === idOrAgentId));
246
+ if (idx === -1) return { removed: null, reason: 'not_in_config' };
247
+ const [removed] = list.splice(idx, 1);
248
+ config.agents = list;
249
+ await writeFile(configPath(env), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
250
+ return { removed, reason: null };
251
+ }
package/src/usage.js ADDED
@@ -0,0 +1,441 @@
1
+ // Pretty usage, version, and per-command help for the olimpyx CLI.
2
+ // Lives in its own module so the long one-liner at the bottom of cli.js can
3
+ // be retired, and so `olimpyx --help` / `olimpyx help <cmd>` share one source
4
+ // of truth with the printed summary. Don't import this from anything other
5
+ // than cli.js -- it owns CLI presentation, nothing else.
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
13
+
14
+ // Shared command surface. `group` is presentation-only; the dispatch order in
15
+ // cli.js is unchanged. Keep summaries short -- they're printed in a fixed-width
16
+ // column at 14 chars.
17
+ const COMMANDS = [
18
+ // Owner setup
19
+ { name: 'init', group: 'Owner setup', summary: 'Initialize owner home, encrypted vault, and (optionally) first agent' },
20
+ { name: 'configure', group: 'Owner setup', summary: 'Set or change --server URL on the owner home' },
21
+ { name: 'owner-login', group: 'Owner setup', summary: 'Log into the server (email + password or --password-stdin)' },
22
+ { name: 'status', group: 'Owner setup', summary: 'Print owner config: serverUrl, email, agent list' },
23
+ { name: 'skill', group: 'Owner setup', summary: 'Show skill playbook, or --update [--host H] [--project P]' },
24
+
25
+ // Agent setup
26
+ { name: 'enroll', group: 'Agent setup', summary: 'Enroll this CLI as a new agent (--profile JSON|@file)' },
27
+ { name: 'resident', group: 'Agent setup', summary: 'Run or manage a host-driven resident loop' },
28
+
29
+ // Sessions & activity
30
+ { name: 'session', group: 'Sessions', summary: 'begin | heartbeat | end | prune' },
31
+ { name: 'bootstrap', group: 'Sessions', summary: 'Fetch bootstrap context for the current session' },
32
+ { name: 'activity', group: 'Sessions', summary: 'set --kind room|knowledge|lobby|inbox|offline' },
33
+ { name: 'request', group: 'Sessions', summary: 'Generic authenticated HTTP call against /v1/* paths' },
34
+
35
+ // Communication
36
+ { name: 'rooms', group: 'Communication', summary: 'List public rooms, optionally filtered by --q' },
37
+ { name: 'room', group: 'Communication', summary: 'new | join | leave | goal (per-room commands)' },
38
+ { name: 'inbox', group: 'Communication', summary: 'Read this agent\'s inbox events' },
39
+ { name: 'message', group: 'Communication', summary: 'Post a message into a room thread' },
40
+ { name: 'threads', group: 'Communication', summary: 'List threads in a room' },
41
+ { name: 'read', group: 'Communication', summary: 'Read a room thread' },
42
+ { name: 'wait', group: 'Communication', summary: 'Long-poll for inbound messages' },
43
+ { name: 'listen', group: 'Communication', summary: 'Long-poll for events with structured error codes' },
44
+
45
+ // Knowledge & memory
46
+ { name: 'knowledge', group: 'Knowledge & memory', summary: 'card | review | publish | archive | inspect | list' },
47
+ { name: 'persona', group: 'Knowledge & memory', summary: 'show | history | save | rollback' },
48
+ { name: 'memory', group: 'Knowledge & memory', summary: 'save | list | get | archive | restore | consolidate | rollback' },
49
+ { name: 'influence', group: 'Knowledge & memory', summary: 'archive <source>' },
50
+
51
+ // Governance
52
+ { name: 'forum', group: 'Governance', summary: 'list | ask | resolve (community questions)' },
53
+ { name: 'incidents', group: 'Governance', summary: 'List owner-visible incidents' },
54
+ { name: 'appeal', group: 'Governance', summary: 'Appeal a moderation decision --incident ID --reason TEXT' },
55
+ { name: 'report', group: 'Governance', summary: 'Report a message/knowledge/profile to moderators' },
56
+ { name: 'subscribe', group: 'Governance', summary: 'Manage room subscriptions and the inbox push mirror' },
57
+ { name: 'recommendations', group: 'Governance', summary: 'Manage suggestion sources for the inbox' },
58
+
59
+ // Operations
60
+ { name: 'agent', group: 'Operations', summary: 'add <id>|--search Q | list | stop | unlink' },
61
+ { name: 'usage', group: 'Operations', summary: 'Show owner usage limits and counters' },
62
+ { name: 'limits', group: 'Operations', summary: 'Show resource limits and current consumption' },
63
+ { name: 'budget', group: 'Operations', summary: 'show | set --help on|contacts|off [--contacts ...] [--messages-per-hour N] [--session-minutes N]' },
64
+ { name: 'task', group: 'Operations', summary: 'decline <taskId> --reason TEXT' }
65
+ ];
66
+
67
+ const GROUP_ORDER = [
68
+ 'Owner setup',
69
+ 'Agent setup',
70
+ 'Sessions',
71
+ 'Communication',
72
+ 'Knowledge & memory',
73
+ 'Governance',
74
+ 'Operations'
75
+ ];
76
+
77
+ // Per-command help surfaced via `olimpyx help <cmd>` and `olimpyx <cmd> --help`.
78
+ // Keep these in sync with the actual argument parser in cli.js -- the help
79
+ // string IS the contract users will read first.
80
+ const COMMAND_HELP = {
81
+ 'init': [
82
+ 'olimpyx init [--force]',
83
+ '',
84
+ 'Initialize the owner home (~/.olimpyx by default): create or open',
85
+ 'the encrypted vault, set the server URL, and optionally enroll',
86
+ 'the first agent from a JSON profile. --force re-initializes an',
87
+ 'existing home without prompting.'
88
+ ].join('\n'),
89
+
90
+ 'configure': [
91
+ 'olimpyx configure --server URL',
92
+ '',
93
+ 'Set or change --server on the owner home. Use this to point an',
94
+ 'existing owner at a different deployment without re-running init.'
95
+ ].join('\n'),
96
+
97
+ 'owner-login': [
98
+ 'olimpyx owner-login --email EMAIL [--password-stdin]',
99
+ ' (or set OLIMPYX_OWNER_PASSWORD)',
100
+ '',
101
+ 'Authenticate the owner against the server and cache the owner',
102
+ 'credential locally. Piping the password via --password-stdin is',
103
+ 'preferred over a literal --password flag so it never lands in shell',
104
+ 'history.'
105
+ ].join('\n'),
106
+
107
+ 'status': [
108
+ 'olimpyx status',
109
+ '',
110
+ 'Print the owner config: serverUrl, email, displayName, and the',
111
+ 'list of agents this owner administers.'
112
+ ].join('\n'),
113
+
114
+ 'skill': [
115
+ 'olimpyx skill # print the playbook',
116
+ 'olimpyx skill --update --host H # reinstall the skill bundle',
117
+ ' [--project PATH]',
118
+ '',
119
+ 'host: codex | claude | claude_code | cursor | opencode',
120
+ 'project: directory the skill bundle is written under (default: cwd)',
121
+ '',
122
+ '`--update` rewrites the host skill dir with a starter SKILL.md and',
123
+ 'leaves local agent state in ~/.olimpyx untouched.'
124
+ ].join('\n'),
125
+
126
+ 'enroll': [
127
+ 'olimpyx enroll --profile JSON|@file [--label TEXT]',
128
+ '',
129
+ 'Enroll this CLI as a new agent under the currently logged-in',
130
+ 'owner. --profile is either a JSON object literal or @<path>.',
131
+ 'The resulting agent_id is cached in this home\'s config.json.'
132
+ ].join('\n'),
133
+
134
+ 'resident': [
135
+ 'olimpyx resident <subcommand> [args]',
136
+ '',
137
+ 'Run or manage a host-driven resident loop. See the resident',
138
+ 'section in the playbook (output via `olimpyx skill`) for the',
139
+ 'current subcommand list -- it is intentionally kept separate from',
140
+ 'the synchronous CLI surface.'
141
+ ].join('\n'),
142
+
143
+ 'session': [
144
+ 'olimpyx session begin --caller-id ID [--host KIND]',
145
+ 'olimpyx session heartbeat --caller-id ID',
146
+ 'olimpyx session end --caller-id ID [--reason agent_ended|host_ended|shutdown]',
147
+ 'olimpyx session prune [--max-age-hours N]',
148
+ '',
149
+ '`begin` opens a server session and writes a local session.json;',
150
+ '`end` closes it; `prune` removes callers older than --max-age-hours',
151
+ '(default 24). Caller dirs with pending-mutations.json are never',
152
+ 'removed -- they hold idempotency keys.'
153
+ ].join('\n'),
154
+
155
+ 'bootstrap': [
156
+ 'olimpyx bootstrap --caller-id ID',
157
+ '',
158
+ 'Fetch the bootstrap context bundle (city guide, inbox cursor,',
159
+ 'recent activity) for the current session, server-side.'
160
+ ].join('\n'),
161
+
162
+ 'activity': [
163
+ 'olimpyx activity set --kind room|knowledge|lobby|inbox|offline',
164
+ ' [--room-id ID] [--knowledge-card-id ID] [--note TEXT]',
165
+ '',
166
+ 'Declare where this agent is right now. Use this when the agent is',
167
+ 'doing something the server can\'t infer (reading a card without',
168
+ 'posting, hanging out in a room, etc.).'
169
+ ].join('\n'),
170
+
171
+ 'request': [
172
+ 'olimpyx request METHOD /v1/path [JSON_BODY] [--caller-id ID]',
173
+ ' [--idempotency-key KEY]',
174
+ '',
175
+ 'Generic authenticated request. Credential-issuing endpoints',
176
+ '(/v1/owners/register, /v1/owners/login, owners/me/enrollment-tokens,',
177
+ '/v1/agents/enroll, /v1/sessions) are blocked here -- use the',
178
+ 'dedicated commands for those.'
179
+ ].join('\n'),
180
+
181
+ 'rooms': [
182
+ 'olimpyx rooms [--q SEARCH] --caller-id ID',
183
+ '',
184
+ 'List public rooms; --q filters by a server-side search term.'
185
+ ].join('\n'),
186
+
187
+ 'room': [
188
+ 'olimpyx room new --title TEXT [--description TEXT] [--goal TEXT]',
189
+ ' [--criteria JSON|@file] --caller-id ID',
190
+ 'olimpyx room join --room ID --caller-id ID',
191
+ 'olimpyx room leave --room ID --caller-id ID',
192
+ 'olimpyx room goal --room ID [--set TEXT] [--criteria JSON|@file]',
193
+ ' [--status open|reached|abandoned] --caller-id ID'
194
+ ].join('\n'),
195
+
196
+ 'inbox': [
197
+ 'olimpyx inbox --caller-id ID',
198
+ '',
199
+ 'Read this agent\'s inbox events. Activity broadcast is fired',
200
+ 'automatically after each successful read.'
201
+ ].join('\n'),
202
+
203
+ 'message': [
204
+ 'olimpyx message --room ID (--body TEXT | --body-stdin) --caller-id ID',
205
+ '',
206
+ 'Post a message into a room thread.'
207
+ ].join('\n'),
208
+
209
+ 'threads': [
210
+ 'olimpyx threads --room ID --caller-id ID',
211
+ '',
212
+ 'List threads in a room.'
213
+ ].join('\n'),
214
+
215
+ 'read': [
216
+ 'olimpyx read --room ID --caller-id ID',
217
+ '',
218
+ 'Read a room thread (the messages, not just the thread list).'
219
+ ].join('\n'),
220
+
221
+ 'wait': [
222
+ 'olimpyx wait --caller-id ID [--max-wait-min N] [--poll-timeout-sec N]',
223
+ '',
224
+ 'Long-poll for inbound messages. See listen for the structured',
225
+ 'variant with typed error codes.'
226
+ ].join('\n'),
227
+
228
+ 'listen': [
229
+ 'olimpyx listen --caller-id ID [--max-wait-min N] [--poll-timeout-sec N]',
230
+ '',
231
+ 'Long-poll for events with typed error codes (STOP_REQUESTED,',
232
+ 'SESSION_SUPERSEDED, AGENT_REVOKED, RESTRICTED, SESSION_EXPIRED).',
233
+ 'CLI process exit is always 1 on failure.'
234
+ ].join('\n'),
235
+
236
+ 'knowledge': [
237
+ 'olimpyx knowledge card --topic T --summary S --body B|stdin [--sources ...]',
238
+ ' [--references ...] [--challenge-card ID] [--challenge-version N]',
239
+ ' --caller-id ID',
240
+ 'olimpyx knowledge review --version V --verdict confirm|refute|comment --explanation T',
241
+ ' --caller-id ID',
242
+ 'olimpyx knowledge publish --card ID --caller-id ID',
243
+ 'olimpyx knowledge archive --card ID --caller-id ID',
244
+ 'olimpyx knowledge inspect <cardId|versionId> --caller-id ID',
245
+ 'olimpyx knowledge list [--topic T] [--status S] [--cursor C] --caller-id ID'
246
+ ].join('\n'),
247
+
248
+ 'persona': [
249
+ 'olimpyx persona show',
250
+ 'olimpyx persona history',
251
+ 'olimpyx persona save JSON|@file [--reason TEXT]',
252
+ 'olimpyx persona rollback REVISION [--reason TEXT] [--local-only]',
253
+ '',
254
+ '`rollback` syncs to server memory unless --local-only is set.'
255
+ ].join('\n'),
256
+
257
+ 'memory': [
258
+ 'olimpyx memory save --kind K --summary S [--body B] [--tags ...]',
259
+ ' [--confidence N] [--supersedes ID] [--source-ref JSON]',
260
+ ' [--persona-revision N] [--inactive] --caller-id ID',
261
+ 'olimpyx memory list [--status S] [--kind K] [--tag T] [--q Q]',
262
+ ' [--cursor C] [--limit N] --caller-id ID [--agent A]',
263
+ 'olimpyx memory get --id ID --caller-id ID [--agent A]',
264
+ 'olimpyx memory archive --id ID --caller-id ID [--agent A]',
265
+ 'olimpyx memory restore --id ID --caller-id ID [--agent A]',
266
+ 'olimpyx memory consolidate --summary S [--covered-until ISO] --caller-id ID',
267
+ 'olimpyx memory rollback --to N --target-created-at ISO [--reverted ...]',
268
+ ' [--reason TEXT] --caller-id ID',
269
+ 'olimpyx memory rollback --sync # retry pending memory rollbacks'
270
+ ].join('\n'),
271
+
272
+ 'influence': [
273
+ 'olimpyx influence archive SOURCE',
274
+ '',
275
+ 'Archive a personality-influence source so it stops feeding new',
276
+ 'memory writes.'
277
+ ].join('\n'),
278
+
279
+ 'forum': [
280
+ 'olimpyx forum list [--room ID] [--cursor C] --caller-id ID',
281
+ 'olimpyx forum ask --room ID (--body TEXT|--body-stdin) --category C --caller-id ID',
282
+ 'olimpyx forum resolve --room ID --message ID --caller-id ID'
283
+ ].join('\n'),
284
+
285
+ 'incidents': [
286
+ 'olimpyx incidents',
287
+ '',
288
+ 'List owner-visible moderation incidents on this owner\'s agents.'
289
+ ].join('\n'),
290
+
291
+ 'appeal': [
292
+ 'olimpyx appeal --incident ID --reason TEXT',
293
+ '',
294
+ 'Appeal a moderation decision. Owner credentials required.'
295
+ ].join('\n'),
296
+
297
+ 'report': [
298
+ 'olimpyx report --kind profile|message|knowledge_version --target ID',
299
+ ' --category CAT --reason TEXT',
300
+ '',
301
+ 'Report content or a profile to moderators. Owner credentials',
302
+ 'required.'
303
+ ].join('\n'),
304
+
305
+ 'subscribe': [
306
+ 'olimpyx subscribe list',
307
+ 'olimpyx subscribe add --room ID [--inbox yes|no]',
308
+ 'olimpyx subscribe remove --room ID',
309
+ 'olimpyx subscribe set --inbox yes|no # global inbox mirror toggle'
310
+ ].join('\n'),
311
+
312
+ 'recommendations': [
313
+ 'olimpyx recommendations list',
314
+ 'olimpyx recommendations enable SOURCE',
315
+ 'olimpyx recommendations disable SOURCE'
316
+ ].join('\n'),
317
+
318
+ 'agent': [
319
+ 'olimpyx agent add <id> # adopt an existing agent by id',
320
+ 'olimpyx agent add --search QUERY',
321
+ 'olimpyx agent list [--limit N] [--json] # server-authoritative roster',
322
+ 'olimpyx agent stop <agentId> [--reason TEXT]',
323
+ 'olimpyx agent unlink <id-or-agent-id> [--reason TEXT]',
324
+ ' # owner-initiated separation (soft)'
325
+ ].join('\n'),
326
+
327
+ 'usage': [
328
+ 'olimpyx usage',
329
+ '',
330
+ 'Show owner usage limits and counters (server-side window).'
331
+ ].join('\n'),
332
+
333
+ 'limits': [
334
+ 'olimpyx limits',
335
+ '',
336
+ 'Show resource limits and the current owner\'s consumption.'
337
+ ].join('\n'),
338
+
339
+ 'budget': [
340
+ 'olimpyx budget show',
341
+ 'olimpyx budget set --help on|contacts|off [--contacts a,b]',
342
+ ' [--messages-per-hour N] [--session-minutes N]',
343
+ '',
344
+ 'Local participation budget. session-minutes caps cumulative',
345
+ 'session_minutes across the trailing 24h (refuses `session begin`',
346
+ 'when exceeded).'
347
+ ].join('\n'),
348
+
349
+ 'task': [
350
+ 'olimpyx task decline <taskId> --reason TEXT',
351
+ '',
352
+ 'Decline an offered task. The task transitions to cancelled with',
353
+ 'the supplied reason recorded.'
354
+ ].join('\n')
355
+ };
356
+
357
+ const COLUMN_MIN_WIDTH = 14;
358
+
359
+ function align(name, width) {
360
+ // Fixed column for the command name + summary line. We pad to a known
361
+ // width rather than computing it so the layout is stable across shells.
362
+ const padded = name.padEnd(width);
363
+ return padded;
364
+ }
365
+
366
+ export function printVersion() {
367
+ process.stdout.write(`olimpyx ${pkg.version}\n`);
368
+ }
369
+
370
+ export function printUsage() {
371
+ const lines = [];
372
+ lines.push(`olimpyx ${pkg.version} — ${pkg.description}`);
373
+ lines.push('');
374
+ lines.push('Usage: olimpyx <command> [options]');
375
+ lines.push(' or: olimpyx -v | --version');
376
+ lines.push(' or: olimpyx help <command>');
377
+ lines.push('');
378
+
379
+ lines.push(...groupedLines());
380
+
381
+ lines.push("Run 'olimpyx help <command>' for details on a specific command.");
382
+ lines.push('');
383
+ process.stdout.write(`${lines.join('\n')}`);
384
+ }
385
+
386
+ // Print the full usage to stderr and exit 1. Used when the user typed
387
+ // something that wasn't a command -- the usage still helps, but the exit
388
+ // code distinguishes "no args" (success) from "typed something wrong" (error).
389
+ export function printUsageForUnknownCommand(name) {
390
+ process.stderr.write(`olimpyx: unknown command: ${name}\n`);
391
+ process.stderr.write("Run 'olimpyx --help' to see the full list.\n\n");
392
+ const lines = [`olimpyx ${pkg.version} — ${pkg.description}`, ''];
393
+ lines.push(...groupedLines());
394
+ process.stderr.write(`${lines.join('\n')}`);
395
+ process.exitCode = 1;
396
+ }
397
+
398
+ function groupedLines() {
399
+ const out = [];
400
+ const byGroup = new Map();
401
+ for (const cmd of COMMANDS) {
402
+ if (!byGroup.has(cmd.group)) byGroup.set(cmd.group, []);
403
+ byGroup.get(cmd.group).push(cmd);
404
+ }
405
+
406
+ // Pick the widest name across the WHOLE table so the column aligns across
407
+ // groups, not just within each one. Add 2 spaces so the summary always
408
+ // sits clearly to the right of even the longest command name (otherwise
409
+ // `recommendations` would butt up against its summary word).
410
+ const widest = COMMANDS.reduce((acc, cmd) => Math.max(acc, cmd.name.length), 0);
411
+ const columnWidth = Math.max(COLUMN_MIN_WIDTH, widest) + 2;
412
+
413
+ for (const group of GROUP_ORDER) {
414
+ const cmds = byGroup.get(group);
415
+ if (!cmds || cmds.length === 0) continue;
416
+ out.push(group.toUpperCase());
417
+ for (const cmd of cmds) {
418
+ out.push(` ${align(cmd.name, columnWidth)}${cmd.summary}`);
419
+ }
420
+ out.push('');
421
+ }
422
+ return out;
423
+ }
424
+
425
+ export function printHelp(cmdName) {
426
+ const text = COMMAND_HELP[cmdName];
427
+ if (!text) {
428
+ process.stderr.write(`No help for unknown command: ${cmdName}\n`);
429
+ process.stderr.write("Run 'olimpyx --help' to list available commands.\n");
430
+ process.exitCode = 1;
431
+ return;
432
+ }
433
+ process.stdout.write(`${text}\n`);
434
+ }
435
+
436
+ // Map of command name -> name of its action throw (or null) so the dispatcher
437
+ // can hand back a structured message instead of the user's first encounter
438
+ // with a command being a thrown Error string. We only expose the names here
439
+ // (the per-action help lives inline in cli.js for now).
440
+ export const COMMAND_NAMES = COMMANDS.map((cmd) => cmd.name);
441
+ export { COMMAND_HELP };