aegiscode 6.1.0 → 6.2.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/src/commands.js CHANGED
@@ -1,157 +1,1257 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Slash commands — the CLI's face on the shared tool registry, using the
5
- * command vocabulary of the sibling `aegiscodex-dev` client.
4
+ * Slash commands — the CLI's full command vocabulary, ported from the sibling
5
+ * `aegiscodex-dev` client (`src/registry.js`), on top of the AEGIS tool registry
6
+ * (`mcp/tools.js`).
6
7
  *
7
8
  * Three kinds of entry live in one table:
8
9
  *
9
- * (a) tool-backed names a tool from `mcp/tools.js` and carries a
10
+ * (a) handler — a local `async (c, args) => bool` that runs against the
11
+ * frozen command context `c` (see `cli/src/app.js`). It returns false to
12
+ * end the session. This is the reference's own contract, so a handler
13
+ * body reads the same here as it does in aegiscodex-dev — only the panels
14
+ * and support modules it calls are this client's.
15
+ * (b) tool-backed — names a tool from `mcp/tools.js` and carries a
10
16
  * `build(arg) -> object` that turns the typed argument into the tool's
11
17
  * JSON. `test/cli-tools.test.mjs` asserts both directions: no command
12
18
  * points at a tool that does not exist, and no tool in the registry is
13
- * unreachable from the prompt, so a new capability added to the registry
14
- * shows up here or fails the build.
15
- * (b) local no server call; handled in `app.js` by `cmd.local`.
16
- * (c) unavailable — a `aegiscodex-dev` command whose capability this client
17
- * genuinely does not have (it needs a local agent loop, Claude Code auth,
18
- * or a repo tool). These carry an honest `why` and are *not* fakes:
19
- * `parseLine` returns `{ kind: 'unavailable' }` and `app.js` prints the
20
- * reason plus a working alternative.
19
+ * unreachable from the prompt (the `/tool` escape hatch keeps the latter
20
+ * true for tools with no dedicated command).
21
+ * (c) unavailable a command whose whole premise is the Claude Code CLI's
22
+ * own auth loop (`login`, `logout`). These carry an honest `unavailable`
23
+ * reason and a working `alt`; `parseLine` returns `{ kind: 'unavailable' }`
24
+ * and `app.js` prints the reason rather than a fake success.
21
25
  *
22
- * The names, aliases, categories and descriptions come from
23
- * `aegiscodex-dev/src/registry.js` (`COMMANDS`, `CATEGORIES`). Where our
24
- * capability differs from the reference's (e.g. `memory` here lists *cloud*
25
- * memory, not the reference's local tier store) the description says what this
26
- * client actually does rather than copying a sentence that would be untrue.
26
+ * The names, aliases, categories and descriptions come from aegiscodex-dev's
27
+ * `COMMANDS`/`CATEGORIES`. Where this client's capability genuinely differs from
28
+ * the reference's the handler says so honestly (a note, an honest panel, or a
29
+ * real command run through the ported support modules) rather than copying a
30
+ * sentence that would be untrue.
31
+ *
32
+ * Every command is defined against the FROZEN command context `c`:
33
+ * c.ctx (mutable session context), c.sessionId, c.transcript,
34
+ * c.push/note/panel, c.render, c.openOverlay, c.closeOverlay, c.askInput,
35
+ * c.withWorking, c.runPrompt, c.ask, c.runTool, c.refreshSpend, c.state,
36
+ * c.setInput, c.exit, c.client, c.TOOLS, c.saveConfig, c.showThemePicker.
27
37
  *
28
38
  * Plain text (no leading `/`) is a prompt: it goes to the pooled brain.
29
39
  */
30
40
 
41
+ const fs = require('node:fs');
42
+ const os = require('node:os');
43
+ const path = require('node:path');
44
+ const { execSync } = require('node:child_process');
45
+
46
+ const { span } = require('./screen.js');
47
+ const { C, BOLD, BOLD_OFF } = require('./theme.js');
48
+ const panels = require('./panels.js');
49
+ const {
50
+ updateConfig, loadConfig, loadPermissions, savePermissions, addPermissionRule,
51
+ DEFAULT_PERMISSIONS, permissionsPath, configPath,
52
+ } = require('./config.js');
53
+ const { copyToClipboard } = require('./clipboard.js');
54
+ const { snapshotCheckpoint, listCheckpoints, loadCheckpoint } = require('./checkpoint.js');
55
+ const { summarizeTranscript, recapLine } = require('./summarize.js');
56
+ const { sessionAccounting, accountingFromUsage, estimateTokens } = require('./tokens.js');
57
+ const { transcriptToMarkdown, transcriptToJSON, writeExportFile, lastAssistantText } = require('./export.js');
58
+ const { detectDevCommand, runDevServer } = require('./devrun.js');
59
+ const { openUrl, URLS } = require('./system.js');
60
+ const { sniffProject, buildAegisMd } = require('./init.js');
61
+ const {
62
+ AGENT_PRESETS, agentRoles, composeAgentPrompt, composeResearchPrompt, composeDebatePrompt,
63
+ } = require('./agents.js');
64
+ const {
65
+ aggregateSessionUsage, pruneSessionHistory, readResumeList,
66
+ } = require('./history.js');
67
+
68
+ // Guarded: a concurrent workstream owns ./markdown.js.
69
+ let markdownModule = null;
70
+ try {
71
+ // eslint-disable-next-line global-require
72
+ markdownModule = require('./markdown.js');
73
+ } catch {
74
+ markdownModule = null;
75
+ }
76
+
77
+ const VERSION = require('../package.json').version;
78
+
79
+ // ── Categories ────────────────────────────────────────────────────────────────
80
+
81
+ const CATEGORIES = [
82
+ { id: 'session', label: 'Session & context' },
83
+ { id: 'workspace', label: 'Workspace' },
84
+ { id: 'model', label: 'Model & behavior' },
85
+ { id: 'data', label: 'Data' },
86
+ { id: 'auth', label: 'Auth' },
87
+ { id: 'support', label: 'Support' },
88
+ { id: 'fun', label: 'Fun' },
89
+ { id: 'aegis', label: 'Aegis plugin' },
90
+ { id: 'custom', label: 'Custom' },
91
+ ];
92
+
93
+ const categoryLabel = (id) => (CATEGORIES.find((cat) => cat.id === id) || {}).label || id;
94
+
95
+ const EFFORT_LEVELS = ['low', 'medium', 'high'];
96
+
97
+ // ── Small handler helpers ─────────────────────────────────────────────────────
98
+
99
+ const note = (c, text) => c.push({ role: 'note', text });
100
+ const panel = (c, lines) => c.push({ role: 'panel', lines });
101
+ const tip = (c, text) => c.push({ role: 'tip', text });
102
+ const done = (c, text) => c.push({ role: 'done', text });
103
+ const shortCwd = () => process.cwd().split('/').filter(Boolean).pop() || '~';
104
+
105
+ /**
106
+ * The ÆGIS LLM routes are gated on the pooled brain being reachable — exactly
107
+ * as the reference hides its ÆGIS routes when the backend is absent. `hidden`
108
+ * is a function so visibility follows the environment at call time.
109
+ */
110
+ const cloudReady = () => !!process.env.AEGIS_API_KEY;
111
+
112
+ /** Read + merge hook config from the standard settings files. */
113
+ function readHooks() {
114
+ const sources = [
115
+ path.join(os.homedir(), '.aegis', 'settings.json'),
116
+ path.join(process.cwd(), '.aegis', 'settings.json'),
117
+ path.join(os.homedir(), '.aegiscode', 'settings.json'),
118
+ path.join(process.cwd(), '.aegiscode', 'settings.json'),
119
+ path.join(os.homedir(), '.claude', 'settings.json'),
120
+ ];
121
+ const hooks = {};
122
+ const from = [];
123
+ for (const p of sources) {
124
+ try {
125
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
126
+ if (j && j.hooks && typeof j.hooks === 'object') {
127
+ from.push(p.replace(os.homedir(), '~'));
128
+ for (const [ev, arr] of Object.entries(j.hooks)) {
129
+ hooks[ev] = [...(hooks[ev] || []), ...(Array.isArray(arr) ? arr : [])];
130
+ }
131
+ }
132
+ } catch {}
133
+ }
134
+ return { hooks, from };
135
+ }
136
+
137
+ /** {enabled,hooks,events,byEvent} from a merged hooks map. */
138
+ function hookStats(hooks) {
139
+ const events = Object.keys(hooks);
140
+ let count = 0;
141
+ const byEvent = {};
142
+ for (const [ev, arr] of Object.entries(hooks)) {
143
+ const n = Array.isArray(arr) ? arr.length : 0;
144
+ byEvent[ev] = n;
145
+ count += n;
146
+ }
147
+ return { enabled: count > 0, hooks: count, events, byEvent };
148
+ }
149
+
150
+ /** Scan the standard skill dirs for SKILL.md files. */
151
+ function scanSkills() {
152
+ const dirs = [
153
+ path.join(os.homedir(), '.aegis', 'skills'),
154
+ path.join(process.cwd(), '.aegis', 'skills'),
155
+ path.join(os.homedir(), '.aegiscode', 'skills'),
156
+ path.join(process.cwd(), '.aegiscode', 'skills'),
157
+ path.join(os.homedir(), '.claude', 'skills'),
158
+ ];
159
+ const found = [];
160
+ for (const dir of dirs) {
161
+ const source = dir.startsWith(process.cwd()) ? 'project' : 'user';
162
+ let names = [];
163
+ try {
164
+ names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
165
+ } catch { continue; }
166
+ for (const name of names) {
167
+ const skillPath = path.join(dir, name, 'SKILL.md');
168
+ try {
169
+ const raw = fs.readFileSync(skillPath, 'utf8');
170
+ const desc = (/^description:\s*(.+)$/m.exec(raw) || [])[1] || '';
171
+ found.push({ source, name, namespace: null, description: desc.trim(), path: skillPath, content: raw });
172
+ } catch {}
173
+ }
174
+ }
175
+ return found;
176
+ }
177
+
178
+ // ── Command definitions ───────────────────────────────────────────────────────
179
+ // Order here is the palette order.
180
+
31
181
  const COMMANDS = [
32
- // ── AEGIS tool-backed family (aegiscodex-dev's `aegis` category) ──────────
182
+ // ── session & context ───────────────────────────────────────────────────────
33
183
  {
34
- name: 'aegis-ask',
35
- aliases: ['ask'],
36
- args: '<question>',
37
- category: 'aegis',
38
- desc: 'Ask ÆGIS pooled inference a question (auto-routed)',
39
- tool: 'aegis_ask',
40
- build: (arg) => ({ prompt: arg }),
184
+ name: 'run', hint: '', category: 'workspace',
185
+ desc: "Launch and drive this project's app to see a change working",
186
+ handler: async (c) => {
187
+ const cmd = detectDevCommand(process.cwd());
188
+ if (!cmd) {
189
+ note(c, 'No dev command detected (no package.json scripts, go.mod, Cargo.toml or Makefile). Try /init first.');
190
+ c.render();
191
+ return true;
192
+ }
193
+ note(c, `Running ${cmd} in ${shortCwd()} — output streams below.`);
194
+ c.push({ role: 'tool', label: 'Bash', args: cmd });
195
+ const job = runDevServer(cmd, {
196
+ cwd: process.cwd(),
197
+ onLine: (line) => c.push({ role: 'note', text: ` ${line}` }),
198
+ });
199
+ job.done.then(({ code, stopped }) => {
200
+ done(c, stopped ? `${cmd} stopped` : `${cmd} exited (code ${code})`);
201
+ c.render();
202
+ });
203
+ c.render();
204
+ return true;
205
+ },
206
+ },
207
+ {
208
+ name: 'schedule', hint: '', category: 'session',
209
+ desc: 'Create, update, list, or run scheduled cloud agents (routines)',
210
+ handler: async (c) => {
211
+ note(c, 'Scheduled routines run on the AEGIS cloud. Sign in with `aegis login`, then manage routines at aegiscloud.org.');
212
+ c.render();
213
+ return true;
214
+ },
41
215
  },
42
216
  {
43
- // aegiscodex-dev splits this into `aegis-status` (local memory stats) and
44
- // `status` (session status); this client has one account-status tool, so
45
- // both spellings route to it — `status`/`st` as aliases.
46
- name: 'aegis-status',
47
- aliases: ['status', 'st'],
48
- args: '',
49
- category: 'aegis',
217
+ name: 'build', aliases: ['forge'], args: ['task'], hint: '<description>', category: 'workspace',
218
+ desc: 'Build an app with the agent loop /build <what to build>',
219
+ handler: async (c, args) => {
220
+ const task = (args._rest || args.task || '').trim();
221
+ if (!task) {
222
+ note(c, 'Usage: /build <what to build>');
223
+ note(c, 'Example: /build a REST API for a todo app');
224
+ c.render();
225
+ return true;
226
+ }
227
+ note(c, '⬡ AEGIS BUILD');
228
+ note(c, `Task: ${task}`);
229
+ c.render();
230
+ await c.runPrompt(`Build the following, creating every file it needs and running the build to verify it works: ${task}`);
231
+ return true;
232
+ },
233
+ },
234
+ {
235
+ name: 'cd', args: ['path'], hint: '<path>', category: 'workspace',
236
+ desc: 'Move this session to a new working directory',
237
+ handler: async (c, args) => {
238
+ const dir = args.path || (args._rest || '').trim();
239
+ if (!dir) { note(c, 'Usage: /cd <path>'); c.render(); return true; }
240
+ try {
241
+ process.chdir(dir);
242
+ c.ctx.cwd = process.cwd();
243
+ updateConfig({ lastCwd: process.cwd() });
244
+ note(c, `Changed directory to ${process.cwd()}`);
245
+ } catch (e) {
246
+ note(c, `/cd: ${e.message}`);
247
+ }
248
+ c.render();
249
+ return true;
250
+ },
251
+ },
252
+ {
253
+ name: 'copy', aliases: ['cp'], args: ['n'], hint: '[N]', category: 'data',
254
+ desc: "Copy the last response to the clipboard",
255
+ handler: async (c, args) => {
256
+ const n = parseInt(args.n || '1', 10) || 1;
257
+ const text = lastAssistantText(c.transcript, n);
258
+ if (!text) { note(c, 'Nothing to copy yet.'); c.render(); return true; }
259
+ const r = copyToClipboard(text);
260
+ if (r.ok) {
261
+ note(c, r.via.startsWith('tool:')
262
+ ? `Copied the last response to the clipboard (${r.via.split(':')[1]}).`
263
+ : `No clipboard tool found — response saved to ${r.path}`);
264
+ } else {
265
+ note(c, 'Clipboard unavailable; nothing copied.');
266
+ }
267
+ c.render();
268
+ return true;
269
+ },
270
+ },
271
+ {
272
+ name: 'clear', aliases: ['cls'], hint: '', category: 'session',
273
+ desc: 'Start a new session with empty context',
274
+ handler: async (c) => {
275
+ c.transcript.length = 0;
276
+ // Prune the session's history rows so /cost resets with /clear.
277
+ pruneSessionHistory(c.sessionId);
278
+ note(c, 'Session cleared — transcript empty.');
279
+ c.render();
280
+ return true;
281
+ },
282
+ },
283
+ {
284
+ name: 'compact', hint: '', category: 'session',
285
+ desc: 'Compact the conversation history',
286
+ handler: async (c) => {
287
+ const has = c.transcript.some((m) => m.role === 'user' || m.role === 'assistant');
288
+ if (!has) { note(c, 'Nothing to compact yet.'); c.render(); return true; }
289
+ note(c, 'Compacting conversation history…');
290
+ c.render();
291
+ const summary = await c.withWorking((signal) =>
292
+ summarizeTranscript(c.transcript, {
293
+ callModel: (p) => c.ask(p).then((r) => r.text),
294
+ model: c.ctx.model,
295
+ signal,
296
+ }));
297
+ if (summary === null) { note(c, 'Compaction cancelled.'); c.render(); return true; }
298
+ c.transcript.length = 0;
299
+ note(c, 'Compacted. Earlier context summarized into the message below.');
300
+ c.transcript.push({ role: 'user', text: summary });
301
+ c.render();
302
+ return true;
303
+ },
304
+ },
305
+ {
306
+ name: 'cost', hint: '', category: 'data',
307
+ desc: 'Show the cost of the current session',
308
+ handler: async (c) => {
309
+ const agg = aggregateSessionUsage(c.sessionId);
310
+ const acct = agg.entries
311
+ ? accountingFromUsage(agg.usage, c.ctx.model, {
312
+ exchanges: agg.entries,
313
+ real: agg.real,
314
+ costUsd: agg.real && agg.costUsd ? agg.costUsd : undefined,
315
+ })
316
+ : sessionAccounting(c.transcript, c.ctx.model);
317
+ panel(c, panels.buildCost({ ...c.state(), contextWindow: acct.contextWindow, costEur: acct.cost }, c.ctx));
318
+ c.render();
319
+ return true;
320
+ },
321
+ },
322
+ {
323
+ name: 'context', hint: '', category: 'session',
324
+ desc: 'Show context usage for the current session',
325
+ handler: async (c) => {
326
+ const acct = sessionAccounting(c.transcript, c.ctx.model);
327
+ panel(c, panels.buildContext({ ...c.state(), contextWindow: acct.contextWindow }, c.ctx));
328
+ c.render();
329
+ return true;
330
+ },
331
+ },
332
+ {
333
+ name: 'effort', args: ['level'], hint: '[low|medium|high]', category: 'model',
334
+ desc: 'Set effort level for model usage',
335
+ handler: async (c, args) => {
336
+ const level = (args.level || '').toLowerCase();
337
+ if (level && !EFFORT_LEVELS.includes(level)) {
338
+ note(c, `Unknown effort level "${args.level}". Use ${EFFORT_LEVELS.join(', ')}.`);
339
+ c.render();
340
+ return true;
341
+ }
342
+ if (!level) {
343
+ c.openOverlay({ type: 'effort', sel: Math.max(0, EFFORT_LEVELS.indexOf(c.ctx.effort)) });
344
+ return true;
345
+ }
346
+ c.ctx.effort = level;
347
+ c.saveConfig({ effort: level });
348
+ note(c, `Effort level: ${level}`);
349
+ c.render();
350
+ return true;
351
+ },
352
+ },
353
+ {
354
+ name: 'exit', aliases: ['quit'], hint: '', category: 'session',
355
+ desc: 'Exit the CLI',
356
+ handler: async () => false,
357
+ },
358
+ {
359
+ name: 'export', args: ['format', 'target'], hint: '[markdown|json] [file|clipboard]', category: 'data',
360
+ desc: 'Export the current conversation to a file or clipboard',
361
+ handler: async (c, args) => {
362
+ const fmt = (args.format || 'markdown').toLowerCase();
363
+ const target = (args.target || 'file').toLowerCase();
364
+ if (!['markdown', 'md', 'json'].includes(fmt) || !['file', 'clipboard'].includes(target)) {
365
+ note(c, 'Usage: /export [markdown|json] [file|clipboard]');
366
+ c.render();
367
+ return true;
368
+ }
369
+ const isJson = fmt === 'json';
370
+ const body = isJson ? transcriptToJSON(c.transcript) : transcriptToMarkdown(c.transcript);
371
+ if (target === 'clipboard') {
372
+ const r = copyToClipboard(body);
373
+ note(c, r.ok
374
+ ? `Exported conversation (${isJson ? 'json' : 'markdown'}) to the clipboard.`
375
+ : 'Clipboard unavailable; nothing copied.');
376
+ } else {
377
+ try {
378
+ const p = writeExportFile(body, isJson ? 'json' : 'md');
379
+ note(c, `Exported conversation to ${p}`);
380
+ } catch (e) {
381
+ note(c, `/export: ${e.message}`);
382
+ }
383
+ }
384
+ c.render();
385
+ return true;
386
+ },
387
+ },
388
+ {
389
+ name: 'help', aliases: ['?', 'h'], hint: '', category: 'support',
390
+ desc: 'Show help',
391
+ handler: async (c) => {
392
+ panel(c, panels.buildHelp(visibleCommands(), c.ctx));
393
+ c.render();
394
+ return true;
395
+ },
396
+ },
397
+ {
398
+ name: 'init', args: ['file'], hint: '[file]', category: 'workspace',
399
+ desc: 'Create an AEGIS.md file in the project',
400
+ handler: async (c, args) => {
401
+ const file = (args.file || 'AEGIS.md').trim();
402
+ const p = path.join(process.cwd(), file);
403
+ if (fs.existsSync(p)) { note(c, `${file} already exists — not overwriting.`); c.render(); return true; }
404
+ const sniff = sniffProject(process.cwd());
405
+ try {
406
+ fs.writeFileSync(p, buildAegisMd(sniff) + '\n');
407
+ } catch (e) {
408
+ note(c, `/init: ${e.message}`);
409
+ c.render();
410
+ return true;
411
+ }
412
+ note(c, `Created ${p} (${sniff.lang})`);
413
+ if (fs.existsSync(path.join(process.cwd(), 'node_modules')) && !fs.existsSync(path.join(process.cwd(), '.gitignore'))) {
414
+ tip(c, 'Add "node_modules/" to a .gitignore before committing.');
415
+ }
416
+ c.render();
417
+ return true;
418
+ },
419
+ },
420
+ {
421
+ name: 'login', hint: '', category: 'auth',
422
+ desc: 'Sign in to Claude Code',
423
+ unavailable: 'sign-in uses Claude Code auth, which this client does not have — it authenticates with your AEGIS key',
424
+ alt: '/byok-set',
425
+ },
426
+ {
427
+ name: 'logout', hint: '', category: 'auth',
428
+ desc: 'Sign out',
429
+ unavailable: 'there is no Claude Code sign-in here to end — aegiscode holds no session token of its own',
430
+ },
431
+ {
432
+ name: 'model', aliases: ['m'], args: ['sub'], hint: '[id|-]', category: 'model',
433
+ desc: 'Switch AI model — pin an id ("-" clears it)',
434
+ handler: async (c, args) => {
435
+ const id = (args.sub || '').trim();
436
+ if (!id) {
437
+ note(c, `model: ${c.ctx.model || 'server default'}`);
438
+ c.openOverlay({ type: 'model', items: c.state().models || [], sel: 0, current: c.ctx.model });
439
+ return true;
440
+ }
441
+ if (id === 'list') {
442
+ const models = c.state().models || [];
443
+ if (!models.length) note(c, 'No pinnable models advertised — /models lists the server\'s ids.');
444
+ else panel(c, panels.buildModelList(c.ctx.model, models, c.ctx));
445
+ c.render();
446
+ return true;
447
+ }
448
+ if (id === 'add' || id === 'remove' || id === 'rm') {
449
+ note(c, 'Model add/remove isn\'t supported in this build — pin an existing server id with /model <id>.');
450
+ c.render();
451
+ return true;
452
+ }
453
+ if (id === '-') {
454
+ c.ctx.model = null;
455
+ // Session-scoped pin only (matches the plugin's existing /model): this
456
+ // never writes ~/.aegiscode/config.json, so a test run stays hermetic.
457
+ note(c, 'Model pin cleared — the server will choose.');
458
+ c.render();
459
+ return true;
460
+ }
461
+ c.ctx.model = id;
462
+ note(c, `Pinned model: ${id}`);
463
+ c.render();
464
+ return true;
465
+ },
466
+ },
467
+ {
468
+ name: 'radio', hint: '', category: 'fun',
469
+ desc: 'Listen to AEGIS FM lo-fi radio',
470
+ handler: async (c) => {
471
+ note(c, 'Opening AEGIS FM lo-fi radio in your browser…');
472
+ if (!openUrl(URLS.radio)) note(c, `AEGIS FM: ${URLS.radio}`);
473
+ c.render();
474
+ return true;
475
+ },
476
+ },
477
+ {
478
+ name: 'recap', hint: '', category: 'session',
479
+ desc: 'Generate a one-line session recap now',
480
+ handler: async (c) => {
481
+ if (!c.transcript.some((m) => m.role === 'user' || m.role === 'assistant')) {
482
+ note(c, 'No exchanges yet — send a prompt first.');
483
+ c.render();
484
+ return true;
485
+ }
486
+ note(c, 'Recapping session…');
487
+ c.render();
488
+ const line = await c.withWorking((signal) =>
489
+ recapLine(c.transcript, {
490
+ callModel: (p) => c.ask(p).then((r) => r.text),
491
+ model: c.ctx.model,
492
+ signal,
493
+ }));
494
+ if (line === null) { note(c, 'Recap cancelled.'); c.render(); return true; }
495
+ c.ctx.lastRecap = { sessionId: c.sessionId, ts: new Date().toISOString(), text: line };
496
+ c.saveConfig({ lastRecap: c.ctx.lastRecap });
497
+ note(c, `Session recap: ${line}`);
498
+ c.render();
499
+ return true;
500
+ },
501
+ },
502
+ {
503
+ name: 'resume', hint: '', category: 'session',
504
+ desc: 'Switch to a previous session',
505
+ handler: async (c) => {
506
+ const items = readResumeList();
507
+ if (!items.length) { note(c, 'No previous sessions found in ~/.aegiscode/history.jsonl'); c.render(); return true; }
508
+ c.openOverlay({ type: 'resume', items, sel: 0 });
509
+ return true;
510
+ },
511
+ },
512
+ {
513
+ name: 'rewind', args: ['n'], hint: '[N]', category: 'session',
514
+ desc: 'Revert the conversation to a checkpoint',
515
+ handler: async (c, args) => {
516
+ const items = listCheckpoints(c.sessionId);
517
+ if (!args.n) {
518
+ if (!items.length) { note(c, 'No checkpoints yet — snapshots are taken after each exchange.'); c.render(); return true; }
519
+ panel(c, panels.buildRewindList(items, c.ctx));
520
+ c.render();
521
+ return true;
522
+ }
523
+ const n = parseInt(args.n, 10);
524
+ if (Number.isNaN(n) || n < 0 || n >= items.length) {
525
+ note(c, `Unknown checkpoint ${args.n}. /rewind shows the list (indices 0..${Math.max(0, items.length - 1)}).`);
526
+ c.render();
527
+ return true;
528
+ }
529
+ snapshotCheckpoint(c.sessionId, c.transcript); // make the rewind undoable
530
+ const restored = loadCheckpoint(c.sessionId, n);
531
+ if (!restored) { note(c, 'Checkpoint unreadable — nothing restored.'); c.render(); return true; }
532
+ c.transcript.length = 0;
533
+ for (const m of restored) c.transcript.push(m);
534
+ note(c, `Rewound to checkpoint ${n} (${restored.length} messages). /rewind N again to undo.`);
535
+ c.render();
536
+ return true;
537
+ },
538
+ },
539
+ {
540
+ name: 'agents', aliases: ['sessions'], args: ['role', 'task'], hint: '[role] [task]', category: 'session',
541
+ desc: 'Show the agents panel, or run a sub-agent role preset — /agents <role> <task>',
542
+ handler: async (c, args) => {
543
+ const role = (args.role || '').trim().toLowerCase();
544
+ if (role) {
545
+ const task = (args._rest || '').trim().replace(/^\S+\s*/, '').trim();
546
+ if (!task) { note(c, `Usage: /agents <role> <task> — roles: ${agentRoles().join(', ')}`); c.render(); return true; }
547
+ if (!AGENT_PRESETS[role]) { note(c, `Unknown agent role "${role}". Roles: ${agentRoles().join(', ')}`); c.render(); return true; }
548
+ await c.runPrompt(composeAgentPrompt(role, task));
549
+ return true;
550
+ }
551
+ panel(c, panels.buildAgents(c.state(), c.ctx));
552
+ c.render();
553
+ return true;
554
+ },
555
+ },
556
+ {
557
+ name: 'status', aliases: ['st'], hint: '', category: 'session',
558
+ desc: 'Show account and session status',
559
+ handler: async (c) => {
560
+ // This client's /status surface is the account-status tool (the same
561
+ // tool /aegis-status names) plus the local session panel below it.
562
+ await c.runTool('aegis_status', {});
563
+ panel(c, panels.buildStatus(c.state(), c.ctx));
564
+ c.render();
565
+ return true;
566
+ },
567
+ },
568
+ {
569
+ name: 'teleport', hint: '', category: 'session',
570
+ desc: 'Resume a session from aegiscloud.org',
571
+ handler: async (c) => {
572
+ note(c, 'Teleport requires an AEGIS cloud account with sessions. Sign in with `aegis login`, then run aegiscloud.org/teleport.');
573
+ c.render();
574
+ return true;
575
+ },
576
+ },
577
+ {
578
+ name: 'theme', aliases: ['t'], args: ['mode'], hint: '[dark|light]', category: 'model',
579
+ desc: 'Change the color theme',
580
+ handler: async (c, args) => {
581
+ const m = (args.mode || '').toLowerCase();
582
+ if (m === 'dark' || m === 'light') {
583
+ c.ctx.light = m === 'light';
584
+ c.ctx.themeIndex = c.ctx.light ? 0 : 1;
585
+ } else {
586
+ await c.showThemePicker();
587
+ }
588
+ c.saveConfig({ themeIndex: c.ctx.themeIndex });
589
+ note(c, `Theme: ${c.ctx.light ? 'light' : 'dark'}`);
590
+ c.render();
591
+ return true;
592
+ },
593
+ },
594
+ {
595
+ name: 'version', aliases: ['v'], hint: '', category: 'session',
596
+ desc: 'Show version',
597
+ handler: async (c) => {
598
+ note(c, `aegiscode v${VERSION}`);
599
+ c.render();
600
+ return true;
601
+ },
602
+ },
603
+ {
604
+ name: 'doctor', hint: '', category: 'support',
605
+ desc: 'Run diagnostic checks on this environment',
606
+ handler: async (c) => {
607
+ const checks = [];
608
+ const ok = (label, good, detail) => checks.push([good, label, detail]);
609
+ ok('Node', true, process.version);
610
+ ok('CWD', fs.existsSync(process.cwd()), process.cwd());
611
+ let git = false;
612
+ try { execSync('git rev-parse --git-dir', { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'ignore'], timeout: 4000 }); git = true; } catch {}
613
+ ok('git repo', git, git ? 'inside a work tree' : 'not a git repository');
614
+ const key = process.env.AEGIS_API_KEY || '';
615
+ ok('AEGIS_API_KEY', !!key, key ? 'set' : 'not set — export one (https://aegiscloud.org)');
616
+ ok('config path', true, configPath());
617
+ const lines = [[span(C.gold + BOLD, 'Doctor'), span(BOLD_OFF, '')], [span(C.gray, '─'.repeat(30))]];
618
+ for (const [good, label, detail] of checks) {
619
+ lines.push([
620
+ span(good ? C.green : C.coral, ` ${good ? '✔' : '⚠'}`),
621
+ span(C.white, ` ${label}: `),
622
+ span(C.gray, String(detail)),
623
+ ]);
624
+ }
625
+ panel(c, lines);
626
+ c.render();
627
+ return true;
628
+ },
629
+ },
630
+ {
631
+ name: 'vim', args: ['mode'], hint: '[on|off]', category: 'model',
632
+ desc: 'Toggle vim keymap',
633
+ handler: async (c, args) => {
634
+ const mode = (args.mode || '').toLowerCase();
635
+ if (mode && !['on', 'off'].includes(mode)) { note(c, 'Usage: /vim [on|off]'); c.render(); return true; }
636
+ const want = mode === 'on' ? true : mode === 'off' ? false : !c.ctx.vim;
637
+ c.ctx.vim = want;
638
+ c.saveConfig({ vim: want });
639
+ note(c, `Vim keymap: ${want ? 'on' : 'off'}`);
640
+ c.render();
641
+ return true;
642
+ },
643
+ },
644
+ {
645
+ name: 'permissions', args: ['mode', 'pattern'],
646
+ hint: '[allow|deny|ask "<pattern>" | default <ask|allow|deny> | clear]', category: 'model',
647
+ desc: 'Set permissions for tool use',
648
+ handler: async (c, args) => {
649
+ const rules = loadPermissions();
650
+ const mode = (args.mode || '').toLowerCase();
651
+ const pattern = args.pattern;
652
+ if (!mode) {
653
+ panel(c, panels.buildPermissions({ ...rules, _path: permissionsPath() }, null, c.ctx));
654
+ c.render();
655
+ return true;
656
+ }
657
+ if (mode === 'clear') {
658
+ savePermissions({ ...DEFAULT_PERMISSIONS, allow: [], deny: [], ask: [] });
659
+ note(c, 'Permissions reset to defaults (no rules).');
660
+ c.render();
661
+ return true;
662
+ }
663
+ if (mode === 'default' && ['ask', 'allow', 'deny'].includes(pattern)) {
664
+ savePermissions({ ...rules, defaultMode: pattern });
665
+ note(c, `Default permission mode: ${pattern}`);
666
+ c.render();
667
+ return true;
668
+ }
669
+ if (['allow', 'deny', 'ask'].includes(mode) && pattern) {
670
+ const { added, rules: next } = addPermissionRule(mode, pattern, rules);
671
+ savePermissions(next);
672
+ note(c, added ? `Added ${mode} rule: ${pattern}` : `Rule already present: ${pattern}`);
673
+ c.render();
674
+ return true;
675
+ }
676
+ note(c, 'Usage: /permissions [allow|deny|ask "<pattern>" | default <ask|allow|deny> | clear]');
677
+ c.render();
678
+ return true;
679
+ },
680
+ },
681
+ {
682
+ name: 'hooks', args: ['sub'], hint: '[status|list]', category: 'model',
683
+ desc: 'View hooks configuration status and configured hook list',
684
+ handler: async (c, args) => {
685
+ const sub = (args.sub || '').toLowerCase();
686
+ const { hooks, from } = readHooks();
687
+ if (sub === 'list') {
688
+ panel(c, panels.buildHooksList({ hooks }, c.ctx));
689
+ c.render();
690
+ return true;
691
+ }
692
+ if (sub === '' || sub === 'status') {
693
+ panel(c, panels.buildHooksStatus({ ...hookStats(hooks), fromPaths: from }, c.ctx));
694
+ c.render();
695
+ return true;
696
+ }
697
+ note(c, `unknown subcommand: ${args.sub}`);
698
+ note(c, 'available: status, list');
699
+ c.render();
700
+ return true;
701
+ },
702
+ },
703
+ {
704
+ name: 'credentials', hint: '', category: 'model',
705
+ desc: 'Show where credentials are stored',
706
+ handler: async (c) => {
707
+ const p = path.join(os.homedir(), '.claude', '.credentials.json');
708
+ const loc = p.replace(os.homedir(), '~');
709
+ if (!fs.existsSync(p)) {
710
+ note(c, 'No credential file found. The Claude Code CLI manages auth (OS keychain or ~/.claude/.credentials.json).');
711
+ } else {
712
+ note(c, `Credentials live in ${loc} (managed by the Claude Code CLI). aegiscode reads them but never writes them.`);
713
+ }
714
+ c.render();
715
+ return true;
716
+ },
717
+ },
718
+ {
719
+ name: 'install-github-app', hint: '', category: 'model',
720
+ desc: 'Install the GitHub App for PR workflows',
721
+ handler: async (c) => {
722
+ note(c, 'The GitHub App is installed through the Claude Code CLI: run `claude --install-github-app` once.');
723
+ c.render();
724
+ return true;
725
+ },
726
+ },
727
+ {
728
+ name: 'troubleshooting', hint: '', category: 'support',
729
+ desc: 'Troubleshoot common issues',
730
+ handler: async (c) => {
731
+ panel(c, panels.buildTroubleshooting(c.state(), c.ctx));
732
+ c.render();
733
+ return true;
734
+ },
735
+ },
736
+ {
737
+ name: 'feedback', hint: '', category: 'support',
738
+ desc: 'Send feedback to the AEGIS team',
739
+ handler: async (c) => {
740
+ note(c, 'Opening the feedback form…');
741
+ if (!openUrl(URLS.feedback)) note(c, `Feedback: ${URLS.feedback}`);
742
+ c.render();
743
+ return true;
744
+ },
745
+ },
746
+ {
747
+ name: 'bug', hint: '', category: 'support',
748
+ desc: 'Report a bug (opens a GitHub issue)',
749
+ handler: async (c) => {
750
+ note(c, `Opening a GitHub issue… (aegiscode v${VERSION}, ${process.version}, ${os.platform()})`);
751
+ if (!openUrl(URLS.issues)) note(c, `Issues: ${URLS.issues}`);
752
+ c.render();
753
+ return true;
754
+ },
755
+ },
756
+ {
757
+ name: 'issue', aliases: ['bugs'], hint: '', category: 'support',
758
+ desc: 'Report an issue',
759
+ handler: async (c) => {
760
+ note(c, 'Opening a GitHub issue…');
761
+ if (!openUrl(URLS.issues)) note(c, `Issues: ${URLS.issues}`);
762
+ c.render();
763
+ return true;
764
+ },
765
+ },
766
+ {
767
+ name: 'onboarding', hint: '', category: 'support',
768
+ desc: 'Show getting-started tips',
769
+ handler: async (c) => {
770
+ panel(c, panels.buildOnboarding(c.state(), c.ctx));
771
+ c.render();
772
+ return true;
773
+ },
774
+ },
775
+ {
776
+ name: 'shell-completion', hint: '', category: 'support',
777
+ desc: 'Set up shell completion',
778
+ handler: async (c) => {
779
+ panel(c, panels.buildShellCompletion(c.state(), c.ctx));
780
+ c.render();
781
+ return true;
782
+ },
783
+ },
784
+ {
785
+ name: 'terminal-setup', hint: '', category: 'support',
786
+ desc: 'Check and fix terminal setup',
787
+ handler: async (c) => {
788
+ panel(c, panels.buildTerminalSetup(c.state(), c.ctx));
789
+ c.render();
790
+ return true;
791
+ },
792
+ },
793
+ {
794
+ name: 'pr-comments', hint: '', category: 'support',
795
+ desc: 'Review and reply to pull request comments',
796
+ handler: async (c) => {
797
+ note(c, 'PR comments need GitHub auth. Run /prs to check connectivity, then use the `gh` CLI or the GitHub web UI.');
798
+ c.render();
799
+ return true;
800
+ },
801
+ },
802
+ {
803
+ name: 'prs', hint: '', category: 'support',
804
+ desc: 'List pull requests in this repo',
805
+ handler: async (c) => {
806
+ try {
807
+ const out = execSync('gh pr list --limit 8', { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] });
808
+ panel(c, panels.buildPRs(out.toString(), c.ctx));
809
+ } catch {
810
+ note(c, 'gh not authenticated (or not installed) — run `gh auth login` first.');
811
+ }
812
+ c.render();
813
+ return true;
814
+ },
815
+ },
816
+ {
817
+ name: 'review', hint: '', category: 'support',
818
+ desc: 'Review a pull request',
819
+ handler: async (c) => {
820
+ note(c, '/review needs GitHub auth via gh. Run /prs to list open PRs, then open one in the browser.');
821
+ c.render();
822
+ return true;
823
+ },
824
+ },
825
+ {
826
+ name: 'benchmark', hint: '', category: 'support',
827
+ desc: 'Run in-app micro-benchmarks',
828
+ handler: async (c) => {
829
+ const t = (fn) => {
830
+ const s = process.hrtime.bigint();
831
+ fn();
832
+ return (Number(process.hrtime.bigint() - s) / 1e6).toFixed(2);
833
+ };
834
+ const sample = '# Heading\n\nSome **bold** and `code` with a [link](https://x.test).\n\n- a\n- b\n- c\n';
835
+ const results = [
836
+ ['2,000 span() calls', t(() => { for (let i = 0; i < 2000; i++) span(C.white, 'x'); })],
837
+ ['estimateTokens × 20 (500 chars)', t(() => { for (let i = 0; i < 20; i++) estimateTokens('x'.repeat(500)); })],
838
+ ];
839
+ if (markdownModule && typeof markdownModule.renderMarkdown === 'function') {
840
+ results.push(['renderMarkdown × 200', t(() => { for (let i = 0; i < 200; i++) markdownModule.renderMarkdown(sample, 80); })]);
841
+ }
842
+ panel(c, panels.buildBenchmark(results, c.ctx));
843
+ c.render();
844
+ return true;
845
+ },
846
+ },
847
+ {
848
+ name: 'waifu', hint: '', category: 'fun',
849
+ desc: 'Summon a waifu',
850
+ handler: async (c) => {
851
+ panel(c, panels.buildWaifu(c.ctx));
852
+ c.render();
853
+ return true;
854
+ },
855
+ },
856
+
857
+ // ── Phase 6 (part 2) families ─────────────────────────────────────────────
858
+ {
859
+ name: 'new', aliases: ['start'], hint: '', category: 'session',
860
+ desc: 'Start a new session (clears the transcript, fresh session id)',
861
+ handler: async (c) => {
862
+ c.transcript.length = 0;
863
+ const id = 'aegis-' + Math.random().toString(36).slice(2, 10);
864
+ c.sessionId = id;
865
+ c.ctx.sessionId = id;
866
+ note(c, `New session started (${id}) — earlier context cleared.`);
867
+ c.render();
868
+ return true;
869
+ },
870
+ },
871
+ {
872
+ name: 'tokens', aliases: ['tok'], hint: '', category: 'data',
873
+ desc: 'Show token usage breakdown and estimated spend',
874
+ handler: async (c) => {
875
+ const acct = sessionAccounting(c.transcript, c.ctx.model);
876
+ panel(c, panels.buildTokens({ ...c.state(), contextWindow: acct.contextWindow, costEur: acct.cost }, c.ctx));
877
+ c.render();
878
+ return true;
879
+ },
880
+ },
881
+ {
882
+ name: 'skills', aliases: ['sk'], args: ['sub'], hint: '[name|refresh]', category: 'session',
883
+ desc: 'List skills (SKILL.md in the standard skill dirs)',
884
+ handler: async (c, args) => {
885
+ const sub = (args.sub || '').trim().toLowerCase();
886
+ const found = scanSkills();
887
+ const dirs = [
888
+ path.join(os.homedir(), '.aegis', 'skills'),
889
+ path.join(process.cwd(), '.aegis', 'skills'),
890
+ path.join(os.homedir(), '.aegiscode', 'skills'),
891
+ path.join(process.cwd(), '.aegiscode', 'skills'),
892
+ path.join(os.homedir(), '.claude', 'skills'),
893
+ ].map((d) => d.replace(os.homedir(), '~'));
894
+ if (sub === 'refresh' || sub === 'reload') {
895
+ note(c, `Skills refreshed — ${found.length} found (${dirs.length} dirs scanned).`);
896
+ c.render();
897
+ return true;
898
+ }
899
+ if (sub) {
900
+ const skill = found.find((s) => s.name === sub);
901
+ if (!skill) { note(c, `Unknown skill "${sub}". /skills lists what exists on disk.`); c.render(); return true; }
902
+ panel(c, panels.buildSkillDetail(skill, c.ctx));
903
+ c.render();
904
+ return true;
905
+ }
906
+ panel(c, panels.buildSkills(found, dirs, c.ctx));
907
+ c.render();
908
+ return true;
909
+ },
910
+ },
911
+ {
912
+ name: 'thinking', args: ['mode'], hint: '[on|off]', category: 'model',
913
+ desc: 'Toggle thinking blocks expanded/collapsed',
914
+ handler: async (c, args) => {
915
+ const mode = (args.mode || '').toLowerCase();
916
+ if (mode && !['on', 'off'].includes(mode)) { note(c, 'Usage: /thinking [on|off]'); c.render(); return true; }
917
+ const want = mode === 'on' ? true : mode === 'off' ? false : !(c.ctx.thinking === true);
918
+ c.ctx.thinking = want;
919
+ c.saveConfig({ thinking: want });
920
+ note(c, `Thinking blocks: ${want ? 'expanded' : 'collapsed'}`);
921
+ c.render();
922
+ return true;
923
+ },
924
+ },
925
+ {
926
+ name: 'mcp', args: ['sub', 'name', 'command'], hint: '[add <name> <command> [args…]|remove <name>|<name>]', category: 'model',
927
+ desc: 'Show MCP server configuration',
928
+ handler: async (c, args) => {
929
+ const cfg = loadConfig();
930
+ const servers = cfg.mcpServers || {};
931
+ const sub = (args.sub || '').toLowerCase();
932
+ if (sub === 'add') {
933
+ const name = (args.name || '').trim();
934
+ const command = (args.command || '').trim();
935
+ if (!name || !command) { note(c, 'Usage: /mcp add <name> "<command with args>"'); c.render(); return true; }
936
+ const parts = command.split(/\s+/).filter(Boolean);
937
+ updateConfig({ mcpServers: { ...servers, [name]: { command: parts[0], args: parts.slice(1) } } });
938
+ note(c, `Added MCP server "${name}" (${parts[0]}). Configuration only — this build has no MCP runtime.`);
939
+ c.render();
940
+ return true;
941
+ }
942
+ if (sub === 'remove' || sub === 'rm') {
943
+ const name = (args.name || '').trim();
944
+ if (!name) { note(c, 'Usage: /mcp remove <name>'); c.render(); return true; }
945
+ if (!servers[name]) { note(c, `No MCP server "${name}". /mcp lists the configured ones.`); c.render(); return true; }
946
+ const next = { ...servers };
947
+ delete next[name];
948
+ updateConfig({ mcpServers: next });
949
+ note(c, `Removed MCP server "${name}".`);
950
+ c.render();
951
+ return true;
952
+ }
953
+ if (sub && servers[sub]) {
954
+ const srv = servers[sub];
955
+ panel(c, [
956
+ [span(C.gold, `MCP server: ${sub}`)],
957
+ [span(C.gray, '─'.repeat(30))],
958
+ [span(C.white, ` command: ${`${srv.command || ''} ${(srv.args || []).join(' ')}`.trim()}`)],
959
+ [span('', '')],
960
+ [span(C.gray, 'Configuration only — this build has no MCP runtime.')],
961
+ ]);
962
+ c.render();
963
+ return true;
964
+ }
965
+ panel(c, panels.buildMcp(servers, [configPath(), permissionsPath()], c.ctx));
966
+ c.render();
967
+ return true;
968
+ },
969
+ },
970
+ {
971
+ name: 'memory', aliases: ['memories'], hint: '', category: 'model',
972
+ desc: 'List the most recent AEGIS cloud-memory entries',
973
+ tool: 'aegis_memory_list',
974
+ build: () => ({}),
975
+ },
976
+ {
977
+ name: 'confirm', aliases: ['confirmations'], args: ['mode'], hint: '[on|off]', category: 'model',
978
+ desc: 'Toggle tool-call confirmation prompts',
979
+ handler: async (c, args) => {
980
+ const rules = loadPermissions();
981
+ const mode = (args.mode || '').toLowerCase();
982
+ if (mode && !['on', 'off'].includes(mode)) { note(c, 'Usage: /confirm [on|off]'); c.render(); return true; }
983
+ const want = mode === 'on' ? true : mode === 'off' ? false : rules.defaultMode !== 'allow';
984
+ savePermissions({ ...rules, defaultMode: want ? 'ask' : 'allow' });
985
+ note(c, `Confirmation prompts: ${want ? 'on' : 'off'} (default permission mode: ${want ? 'ask' : 'allow'})`);
986
+ c.render();
987
+ return true;
988
+ },
989
+ },
990
+ {
991
+ name: 'yolo', args: ['mode'], hint: '[on|off]', category: 'model',
992
+ desc: 'Toggle YOLO mode — auto-approve all tool executions',
993
+ handler: async (c, args) => {
994
+ const rules = loadPermissions();
995
+ const mode = (args.mode || '').toLowerCase();
996
+ if (mode && !['on', 'off'].includes(mode)) { note(c, 'Usage: /yolo [on|off]'); c.render(); return true; }
997
+ const want = mode === 'on' ? true : mode === 'off' ? false : rules.defaultMode !== 'allow';
998
+ savePermissions({ ...rules, defaultMode: want ? 'allow' : 'ask' });
999
+ if (want) panel(c, panels.buildYolo(c.state(), c.ctx));
1000
+ else note(c, 'YOLO mode off — confirmations restored.');
1001
+ c.render();
1002
+ return true;
1003
+ },
1004
+ },
1005
+ {
1006
+ name: 'multiyolo', args: ['task'], hint: '<task>', category: 'session',
1007
+ desc: 'Multi-agent orchestration with YOLO mode — /multiyolo <task>',
1008
+ handler: async (c, args) => {
1009
+ const task = (args._rest || args.task || '').trim();
1010
+ if (!task) { note(c, 'Usage: /multiyolo <task>'); c.render(); return true; }
1011
+ const rules = loadPermissions();
1012
+ savePermissions({ ...rules, defaultMode: 'allow' });
1013
+ panel(c, panels.buildYolo(c.state(), c.ctx));
1014
+ note(c, 'Composing ÆGIS /multiyolo — run it in aegis-cli (the confirmation prompt works there):');
1015
+ panel(c, panels.buildAegisMulti(task, c.ctx));
1016
+ c.render();
1017
+ return true;
1018
+ },
1019
+ },
1020
+ {
1021
+ name: 'router', args: ['sub', 'tier', 'modelId'], hint: '[on|off|set <tier> <modelId>|stats]', category: 'model',
1022
+ desc: 'Show or manage the model router config',
1023
+ handler: async (c, args) => {
1024
+ const cfg = loadConfig();
1025
+ const router = cfg.autoRouter || { enabled: false, tiers: {} };
1026
+ const sub = (args.sub || '').toLowerCase();
1027
+ const persist = (next) => updateConfig({ autoRouter: next });
1028
+ if (sub === 'on') { persist({ ...router, enabled: true }); note(c, 'Auto-router: on.'); c.render(); return true; }
1029
+ if (sub === 'off') { persist({ ...router, enabled: false }); note(c, 'Auto-router: off.'); c.render(); return true; }
1030
+ if (sub === 'set') {
1031
+ const tier = (args.tier || '').toLowerCase();
1032
+ const modelId = (args.modelId || '').trim();
1033
+ if (!['simple', 'medium', 'complex'].includes(tier) || !modelId) {
1034
+ note(c, 'Usage: /router set <simple|medium|complex> <modelId>');
1035
+ c.render();
1036
+ return true;
1037
+ }
1038
+ persist({ ...router, tiers: { ...(router.tiers || {}), [tier]: modelId } });
1039
+ note(c, `Auto-router: ${tier} -> ${modelId}`);
1040
+ c.render();
1041
+ return true;
1042
+ }
1043
+ if (sub === 'stats') {
1044
+ panel(c, [
1045
+ [span(C.gold + BOLD, 'Router stats'), span(BOLD_OFF, '')],
1046
+ [span(C.gray, '─'.repeat(30))],
1047
+ [span(C.gray, 'No learned outcomes yet — this build does not auto-route.')],
1048
+ [span(C.gray, 'Tiers: simple/medium/complex via /router set.')],
1049
+ ]);
1050
+ c.render();
1051
+ return true;
1052
+ }
1053
+ panel(c, panels.buildRouter(cfg, c.ctx));
1054
+ c.render();
1055
+ return true;
1056
+ },
1057
+ },
1058
+ {
1059
+ name: 'multi', args: ['task', 'mode'], hint: '<task> [run]', category: 'session',
1060
+ desc: 'Compose a multi-agent task (add "run" to execute)',
1061
+ handler: async (c, args) => {
1062
+ let task = (args._rest || args.task || '').trim();
1063
+ let mode = (args.mode || '').toLowerCase();
1064
+ if (mode !== 'run' && /\s+run$/i.test(task)) { mode = 'run'; task = task.replace(/\s+run$/i, '').trim(); }
1065
+ if (!task) { note(c, 'Usage: /multi <task> [run]'); c.render(); return true; }
1066
+ if (mode !== 'run') { panel(c, panels.buildAegisMulti(task, c.ctx)); c.render(); return true; }
1067
+ note(c, 'Running the task through the pooled brain (single-model — the reference fans it out).');
1068
+ await c.runPrompt(task);
1069
+ return true;
1070
+ },
1071
+ },
1072
+ {
1073
+ name: 'research', args: ['question'], hint: '<question>', category: 'session',
1074
+ desc: 'Research a topic with a multi-perspective council prompt',
1075
+ handler: async (c, args) => {
1076
+ const q = (args._rest || args.question || '').trim();
1077
+ if (!q) { note(c, 'Usage: /research <question>'); c.render(); return true; }
1078
+ await c.runPrompt(composeResearchPrompt(q, process.cwd()));
1079
+ return true;
1080
+ },
1081
+ },
1082
+ {
1083
+ name: 'debate', aliases: ['db'], args: ['topic'], hint: '<topic>', category: 'session',
1084
+ desc: 'Run a structured debate on the current model',
1085
+ handler: async (c, args) => {
1086
+ const topic = (args._rest || args.topic || '').trim();
1087
+ if (!topic) { note(c, 'Usage: /debate <topic>'); c.render(); return true; }
1088
+ await c.runPrompt(composeDebatePrompt(topic, c.ctx.model || ''));
1089
+ return true;
1090
+ },
1091
+ },
1092
+ {
1093
+ name: 'billing', aliases: ['balance', 'spend'], hint: '', category: 'support',
1094
+ desc: 'Show billing info — token-bank balance and recent spend',
1095
+ handler: async (c) => {
1096
+ // The account is the source of truth for the balance and ledger; the
1097
+ // session panel below adds this session's spend.
1098
+ await c.runTool('aegis_balance', {});
1099
+ const spend = await c.refreshSpend();
1100
+ const st = c.state();
1101
+ panel(c, panels.buildBilling({
1102
+ ...st,
1103
+ balance: spend && spend.balance != null ? spend.balance : st.balance,
1104
+ }, c.ctx));
1105
+ c.render();
1106
+ return true;
1107
+ },
1108
+ },
1109
+ {
1110
+ name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate]', category: 'support',
1111
+ desc: 'Show ÆGIS cloud sync status',
1112
+ handler: async (c, args) => {
1113
+ const sub = (args.sub || '').toLowerCase();
1114
+ if (['key', 'activate', 'deactivate'].includes(sub)) {
1115
+ note(c, 'ÆGIS cloud key/sync is managed by the aegis CLI: run `aegis login` (free) — aegiscode does not store cloud keys.');
1116
+ c.render();
1117
+ return true;
1118
+ }
1119
+ const st = c.state();
1120
+ panel(c, panels.buildAegisStatus({ ...st, cloud: { key: st.online, sync: st.online } }, c.ctx));
1121
+ c.render();
1122
+ return true;
1123
+ },
1124
+ },
1125
+ {
1126
+ name: 'gmail', hint: '', category: 'support',
1127
+ desc: 'Link Gmail to conversations',
1128
+ handler: async (c) => {
1129
+ note(c, 'Gmail integration needs Google OAuth — not available in this build. Use /export to save conversations to files instead.');
1130
+ c.render();
1131
+ return true;
1132
+ },
1133
+ },
1134
+ {
1135
+ name: 'clone', aliases: ['fetch-site', 'websnap'], args: ['url'], hint: '<url>', category: 'workspace',
1136
+ desc: 'Clone a website into a local project',
1137
+ handler: async (c, args) => {
1138
+ const url = (args._rest || args.url || '').trim();
1139
+ if (!url) { note(c, 'Usage: /clone <url>'); c.render(); return true; }
1140
+ note(c, `/clone needs a web-fetch tool and a chat service — not available in this build. Use /init to scaffold a fresh project instead of ${url}.`);
1141
+ c.render();
1142
+ return true;
1143
+ },
1144
+ },
1145
+ {
1146
+ name: 'release-notes', hint: '', category: 'support',
1147
+ desc: "What's new",
1148
+ handler: async (c) => {
1149
+ panel(c, panels.buildReleaseNotes(c.state(), c.ctx));
1150
+ c.render();
1151
+ return true;
1152
+ },
1153
+ },
1154
+
1155
+ // ── /aegis-* family ───────────────────────────────────────────────────────
1156
+ {
1157
+ name: 'aegis-status', hint: '', category: 'aegis',
50
1158
  desc: 'Show account status — API key, plan, account and cloud memory',
51
1159
  tool: 'aegis_status',
52
1160
  build: () => ({}),
53
1161
  },
54
1162
  {
55
- name: 'aegis-recall',
56
- aliases: ['recall'],
57
- args: '<topic>',
58
- category: 'aegis',
59
- desc: 'Recall cross-session memory about a topic',
1163
+ name: 'aegis-ask', aliases: ['ask'], args: ['question'], hint: '<question>', category: 'aegis',
1164
+ desc: 'Ask ÆGIS pooled inference a question (auto-routed)',
1165
+ tool: 'aegis_ask',
1166
+ build: (arg) => ({ prompt: arg }),
1167
+ },
1168
+ {
1169
+ name: 'aegis-recall', aliases: ['recall'], args: ['topic'], hint: '<topic>', category: 'aegis',
1170
+ desc: 'Recall cross-session memory about a topic (cloud)',
60
1171
  tool: 'aegis_memory_search',
61
1172
  build: (arg) => ({ query: arg }),
62
1173
  },
63
1174
  {
64
- name: 'aegis-remember',
65
- aliases: ['remember'],
66
- args: '<note>',
67
- category: 'aegis',
1175
+ name: 'aegis-remember', aliases: ['remember'], args: ['note'], hint: '<note>', category: 'aegis',
68
1176
  desc: 'Save a note or decision to cross-session memory',
69
1177
  tool: 'aegis_memory_save',
70
1178
  build: (arg) => ({ content: arg }),
71
1179
  },
72
1180
  {
73
- name: 'memory',
74
- aliases: ['memories'],
75
- args: '',
76
- category: 'aegis',
77
- desc: 'List the most recent AEGIS cloud-memory entries',
78
- tool: 'aegis_memory_list',
79
- build: () => ({}),
1181
+ name: 'aegis-council', aliases: ['council'], args: ['question'], hint: '<question>', category: 'aegis',
1182
+ desc: 'Put a question to the ÆGIS council (multi-perspective deliberation)',
1183
+ hidden: () => !cloudReady(),
1184
+ handler: async (c, args) => {
1185
+ const q = (args._rest || args.question || '').trim();
1186
+ if (!q) { note(c, 'Usage: /aegis-council <question>'); c.render(); return true; }
1187
+ await c.runPrompt(composeResearchPrompt(`Deliberate as a council and vote on: ${q}`, process.cwd()));
1188
+ return true;
1189
+ },
80
1190
  },
81
1191
  {
82
- // No reference equivalent (the reference has no importer); the name follows
83
- // the `/aegis-*` family. Keeps the `--confirm` dry-run semantics: without
84
- // the flag the tool only reports what it found.
85
- name: 'aegis-import',
86
- aliases: ['import'],
87
- args: '[--confirm]',
88
- category: 'aegis',
89
- desc: 'Import memory from other AI tools on this machine (dry run unless --confirm)',
90
- tool: 'aegis_memory_import',
91
- build: (arg) => ({ confirm: /--confirm\b/.test(arg) }),
1192
+ name: 'aegis-print', args: ['question'], hint: '<question>', category: 'aegis',
1193
+ desc: 'Ask pooled inference and print the answer cleanly',
1194
+ hidden: () => !cloudReady(),
1195
+ handler: async (c, args) => {
1196
+ const q = (args._rest || args.question || '').trim();
1197
+ if (!q) { note(c, 'Usage: /aegis-print <question>'); c.render(); return true; }
1198
+ const res = await c.ask(q);
1199
+ panel(c, panels.buildAegisPrint('ÆGIS', { result: res.text, model: res.model, provider: 'aegis' }, c.ctx));
1200
+ c.render();
1201
+ return true;
1202
+ },
92
1203
  },
93
1204
  {
94
- // aegiscodex-dev's `model` switches the brain; this client pins a model id
95
- // (local) and lists the pinnable ids with a tool. `models` is the list
96
- // action aegiscodex-dev's `/model list`, surfaced as its own command.
97
- name: 'models',
98
- aliases: ['model-list'],
99
- args: '',
100
- category: 'model',
101
- desc: 'List the model ids you can pin with /model',
102
- tool: 'aegis_list_models',
103
- build: () => ({}),
1205
+ name: 'aegis-multi', args: ['task', 'mode'], hint: '<task> [run]', category: 'aegis',
1206
+ desc: 'Compose an ÆGIS /multi multi-agent task (add "run" to execute)',
1207
+ hidden: () => !cloudReady(),
1208
+ handler: async (c, args) => {
1209
+ let task = (args._rest || args.task || '').trim();
1210
+ let mode = (args.mode || '').toLowerCase();
1211
+ if (mode !== 'run' && /\s+run$/i.test(task)) { mode = 'run'; task = task.replace(/\s+run$/i, '').trim(); }
1212
+ if (!task) { note(c, 'Usage: /aegis-multi <task> [run]'); c.render(); return true; }
1213
+ if (mode !== 'run') { panel(c, panels.buildAegisMulti(task, c.ctx)); c.render(); return true; }
1214
+ note(c, 'Running /multi headless — this skips aegis-cli\'s confirmation step.');
1215
+ await c.runPrompt(task);
1216
+ return true;
1217
+ },
104
1218
  },
1219
+
1220
+ // ── cloud-only additions (not in the reference vocabulary) ────────────────
105
1221
  {
106
- // aegiscodex-dev has no BYOK surface; these are carried over from the
107
- // registry's byok tools. `billing` below is the reference's name for the
108
- // balance read the tool performs.
109
- name: 'byok',
110
- args: '',
111
- category: 'auth',
1222
+ name: 'byok', hint: '', category: 'auth',
112
1223
  desc: 'Show which providers have your own key configured',
113
1224
  tool: 'aegis_byok_status',
114
1225
  build: () => ({}),
115
1226
  },
116
1227
  {
117
- name: 'byok-set',
118
- args: '<provider>',
119
- category: 'auth',
1228
+ name: 'byok-set', args: ['provider'], hint: '<provider>', category: 'auth',
120
1229
  desc: 'Set YOUR provider key (prompted, never echoed, never in history)',
121
1230
  tool: 'aegis_byok_set',
122
1231
  secret: 'key',
123
- build: (arg) => ({ provider: arg.trim() }),
1232
+ build: (arg) => ({ provider: String(arg || '').trim() }),
124
1233
  },
125
1234
  {
126
- name: 'byok-rm',
127
- args: '<provider>',
128
- category: 'auth',
1235
+ name: 'byok-rm', args: ['provider'], hint: '<provider>', category: 'auth',
129
1236
  desc: 'Remove a stored provider key',
130
1237
  tool: 'aegis_byok_set',
131
- build: (arg) => ({ provider: arg.trim() }),
1238
+ build: (arg) => ({ provider: String(arg || '').trim() }),
132
1239
  },
133
1240
  {
134
- // aegiscodex-dev's `billing`; `balance` and `spend` stay routable for
135
- // muscle memory. Not `cost` that name is this client's local tally below.
136
- name: 'billing',
137
- aliases: ['balance', 'spend'],
138
- args: '',
139
- category: 'support',
140
- desc: 'Show billing info — token-bank balance and recent spend',
141
- tool: 'aegis_balance',
1241
+ name: 'models', aliases: ['model-list'], hint: '', category: 'model',
1242
+ desc: 'List the model ids you can pin with /model',
1243
+ tool: 'aegis_list_models',
142
1244
  build: () => ({}),
143
1245
  },
144
1246
  {
145
- // The escape hatch for any registry tool, including ones added later.
146
- name: 'tool',
147
- args: '<name> [json]',
148
- category: 'aegis',
1247
+ name: 'tool', args: ['name', 'json'], hint: '<name> [json]', category: 'aegis',
149
1248
  desc: 'Call any registry tool directly (escape hatch for new tools)',
150
1249
  generic: true,
151
1250
  build: (arg) => {
152
- const sp = arg.indexOf(' ');
153
- const name = (sp === -1 ? arg : arg.slice(0, sp)).trim();
154
- const rest = sp === -1 ? '' : arg.slice(sp + 1).trim();
1251
+ const s = String(arg || '');
1252
+ const sp = s.indexOf(' ');
1253
+ const name = (sp === -1 ? s : s.slice(0, sp)).trim();
1254
+ const rest = sp === -1 ? '' : s.slice(sp + 1).trim();
155
1255
  let args = {};
156
1256
  if (rest) {
157
1257
  try {
@@ -163,57 +1263,17 @@ const COMMANDS = [
163
1263
  return { tool: name, args };
164
1264
  },
165
1265
  },
166
-
167
- // ── local commands: no server call, handled by app.js ─────────────────────
168
- {
169
- name: 'model',
170
- aliases: ['m'],
171
- args: '[id]',
172
- category: 'model',
173
- desc: 'Switch AI model — pin an id (no argument shows the pin; `-` clears it)',
174
- local: 'model',
175
- },
176
- { name: 'stream', args: '[on|off]', category: 'model', desc: 'Toggle streaming output', local: 'stream' },
177
- { name: 'theme', aliases: ['t'], args: '[dark|light]', category: 'model', desc: 'Change the color theme', local: 'theme' },
178
- { name: 'version', aliases: ['v'], args: '', category: 'session', desc: 'Show version', local: 'version' },
179
- { name: 'cost', args: '', category: 'data', desc: 'Show the cost of the current session', local: 'cost' },
180
- {
181
- name: 'tokens',
182
- aliases: ['tok'],
183
- args: '',
184
- category: 'data',
185
- desc: 'Show token usage breakdown and estimated spend',
186
- local: 'tokens',
1266
+ {
1267
+ name: 'aegis-import', aliases: ['import'], hint: '[--confirm]', category: 'aegis',
1268
+ desc: 'Import memory from other AI tools on this machine (dry run unless --confirm)',
1269
+ tool: 'aegis_memory_import',
1270
+ build: (arg) => ({ confirm: /--confirm\b/.test(String(arg || '')) }),
187
1271
  },
188
- { name: 'clear', aliases: ['cls'], args: '', category: 'session', desc: 'Start a new session with empty context', local: 'clear' },
189
- { name: 'help', aliases: ['?', 'h'], args: '', category: 'support', desc: 'Show help', local: 'help' },
190
- { name: 'exit', aliases: ['quit'], args: '', category: 'session', desc: 'Exit the CLI', local: 'exit' },
191
-
192
- // ── unavailable: aegiscodex-dev vocabulary this client cannot honour ───────
193
- // Not fakes — each names why it is absent and, where one exists, the nearest
194
- // working command. `parseLine` surfaces these as `kind: 'unavailable'`.
195
- { name: 'login', args: '', category: 'auth', desc: 'Sign in to Claude Code', unavailable: true, why: 'sign-in uses Claude Code auth, which this client does not have — it authenticates with your AEGIS key', alt: '/byok-set' },
196
- { name: 'logout', args: '', category: 'auth', desc: 'Sign out', unavailable: true, why: 'there is no Claude Code sign-in here to end' },
197
- { name: 'doctor', args: '', category: 'support', desc: 'Run diagnostic checks on this environment', unavailable: true, why: 'the diagnostic suite belongs to aegiscodex-dev, not this client', alt: '/status' },
198
- { name: 'permissions', args: '[mode] [pattern]', category: 'model', desc: 'Set permissions for tool use', unavailable: true, why: 'tool-permission prompts require a local agent loop this client does not run' },
199
- { name: 'mcp', args: '[sub]', category: 'model', desc: 'Show MCP server configuration', unavailable: true, why: 'this CLI is itself an MCP host, so it has no MCP servers to configure', alt: '/tool' },
200
- { name: 'skills', aliases: ['sk'], args: '[name|refresh]', category: 'session', desc: 'List skills (SKILL.md in the standard skill dirs)', unavailable: true, why: 'skills run inside the agent loop, which this client does not host' },
201
- { name: 'hooks', args: '[status|list]', category: 'model', desc: 'View hooks configuration status and configured hook list', unavailable: true, why: 'hooks are a local agent-loop feature this client does not run' },
202
- { name: 'agents', aliases: ['sessions'], args: '[role] [task]', category: 'session', desc: 'Show the agents panel, or run a sub-agent role preset — /agents <role> <task>', unavailable: true, why: 'sub-agents need a local agent loop this client does not host' },
203
- { name: 'resume', args: '', category: 'session', desc: 'Switch to a previous session', unavailable: true, why: 'this client keeps no session history to resume' },
204
- { name: 'rewind', args: '[N]', category: 'session', desc: 'Revert the conversation to a checkpoint', unavailable: true, why: 'checkpoints belong to a session history this client does not keep' },
205
- { name: 'compact', args: '', category: 'session', desc: 'Compact the conversation history', unavailable: true, why: 'there is no local transcript to compact' },
206
- { name: 'init', args: '[file]', category: 'workspace', desc: 'Create a CLAUDE.md file in the project', unavailable: true, why: 'project scaffolding is a workspace feature this client does not have', alt: '/aegis-remember' },
207
- { name: 'export', args: '[markdown|json] [file|clipboard]', category: 'data', desc: 'Export the current conversation to a file or clipboard', unavailable: true, why: 'this client keeps no transcript to export' },
208
- { name: 'vim', args: '[on|off]', category: 'model', desc: 'Toggle vim keymap', unavailable: true, why: 'input is your terminal readline, which has no vim keymap' },
209
- { name: 'yolo', args: '[on|off]', category: 'model', desc: 'Toggle YOLO mode — auto-approve all tool executions', unavailable: true, why: 'there is no tool-approval prompt here to auto-approve' },
210
- { name: 'confirm', aliases: ['confirmations'], args: '[on|off]', category: 'model', desc: 'Toggle tool-call confirmation prompts', unavailable: true, why: 'there is no tool-approval prompt here to toggle' },
211
1272
  ];
212
1273
 
213
- // ── Index + the one invariant a table like this must hold ────────────────────
1274
+ // ── Index + the one invariant a table like this must hold ─────────────────────
214
1275
  // A name or alias registered twice would silently shadow the earlier entry, so
215
- // the collision is a module-load error rather than a runtime surprise. (The
216
- // reference keeps `builtinNames()` for exactly this; its test asserts the same.)
1276
+ // the collision is a module-load error rather than a runtime surprise.
217
1277
 
218
1278
  const BY_NAME = new Map();
219
1279
  for (const c of COMMANDS) {
@@ -225,10 +1285,27 @@ for (const c of COMMANDS) {
225
1285
  }
226
1286
  }
227
1287
 
1288
+ /** Every builtin entry, in palette order, with its category label attached. */
1289
+ function allCommands() {
1290
+ return COMMANDS.map((entry) => ({ ...entry, categoryLabel: categoryLabel(entry.category) }));
1291
+ }
1292
+
1293
+ /** Commands shown in the palette, /help and Tab completion. */
1294
+ function visibleCommands() {
1295
+ return allCommands().filter((c) => !(typeof c.hidden === 'function' ? c.hidden() : c.hidden));
1296
+ }
1297
+
1298
+ /** Find by name or alias, case-insensitive. Returns the entry or null. */
228
1299
  function findCommand(name) {
229
1300
  return BY_NAME.get(String(name || '').toLowerCase()) || null;
230
1301
  }
231
1302
 
1303
+ /** The canonical name of a command, resolving aliases. */
1304
+ function canonicalName(name) {
1305
+ const e = findCommand(name);
1306
+ return e ? e.name : name;
1307
+ }
1308
+
232
1309
  /**
233
1310
  * Classify one input line.
234
1311
  * @returns {{kind:'empty'}
@@ -246,12 +1323,8 @@ function parseLine(line) {
246
1323
  const name = (sp === -1 ? trimmed.slice(1) : trimmed.slice(1, sp)).trim();
247
1324
  const arg = sp === -1 ? '' : trimmed.slice(sp + 1).trim();
248
1325
  const command = findCommand(name);
249
- if (!command) {
250
- return { kind: 'unknown', name, text: trimmed };
251
- }
252
- if (command.unavailable) {
253
- return { kind: 'unavailable', command, arg };
254
- }
1326
+ if (!command) return { kind: 'unknown', name, text: trimmed };
1327
+ if (command.unavailable) return { kind: 'unavailable', command, arg };
255
1328
  return { kind: 'command', command, arg };
256
1329
  }
257
1330
 
@@ -260,4 +1333,15 @@ function toolBackedCommands() {
260
1333
  return COMMANDS.filter((c) => c.tool);
261
1334
  }
262
1335
 
263
- module.exports = { COMMANDS, findCommand, parseLine, toolBackedCommands };
1336
+ module.exports = {
1337
+ CATEGORIES,
1338
+ categoryLabel,
1339
+ EFFORT_LEVELS,
1340
+ COMMANDS,
1341
+ allCommands,
1342
+ visibleCommands,
1343
+ findCommand,
1344
+ canonicalName,
1345
+ parseLine,
1346
+ toolBackedCommands,
1347
+ };