@kinqs/brainrouter-cli 0.3.6 → 0.3.7

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.
Files changed (96) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/dist/agent/agent.d.ts +12 -1
  8. package/dist/agent/agent.js +134 -18
  9. package/dist/cli/banner.d.ts +20 -0
  10. package/dist/cli/banner.js +47 -14
  11. package/dist/cli/cliPrompt.d.ts +40 -3
  12. package/dist/cli/cliPrompt.js +52 -25
  13. package/dist/cli/commands/_context.d.ts +3 -1
  14. package/dist/cli/commands/_helpers.d.ts +1 -1
  15. package/dist/cli/commands/config.d.ts +46 -0
  16. package/dist/cli/commands/config.js +1042 -0
  17. package/dist/cli/commands/init.d.ts +20 -0
  18. package/dist/cli/commands/init.js +64 -0
  19. package/dist/cli/commands/login.d.ts +13 -0
  20. package/dist/cli/commands/login.js +179 -0
  21. package/dist/cli/commands/mcp.d.ts +13 -11
  22. package/dist/cli/commands/mcp.js +239 -74
  23. package/dist/cli/commands/orchestration.js +18 -0
  24. package/dist/cli/commands/ui.js +117 -58
  25. package/dist/cli/commands/workflow.d.ts +2 -0
  26. package/dist/cli/commands/workflow.js +54 -8
  27. package/dist/cli/ink/ChatApp.d.ts +206 -0
  28. package/dist/cli/ink/ChatApp.js +493 -0
  29. package/dist/cli/ink/Frame.d.ts +26 -0
  30. package/dist/cli/ink/Frame.js +5 -0
  31. package/dist/cli/ink/Picker.d.ts +65 -0
  32. package/dist/cli/ink/Picker.js +133 -0
  33. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  34. package/dist/cli/ink/SlashPalette.js +136 -0
  35. package/dist/cli/ink/TextField.d.ts +34 -0
  36. package/dist/cli/ink/TextField.js +47 -0
  37. package/dist/cli/ink/WizardApp.d.ts +7 -0
  38. package/dist/cli/ink/WizardApp.js +422 -0
  39. package/dist/cli/ink/ambientChat.d.ts +34 -0
  40. package/dist/cli/ink/ambientChat.js +7 -0
  41. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  42. package/dist/cli/ink/consoleCapture.js +33 -0
  43. package/dist/cli/ink/markdownRender.d.ts +41 -0
  44. package/dist/cli/ink/markdownRender.js +278 -0
  45. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  46. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  47. package/dist/cli/ink/runChat.d.ts +34 -0
  48. package/dist/cli/ink/runChat.js +571 -0
  49. package/dist/cli/ink/runPicker.d.ts +31 -0
  50. package/dist/cli/ink/runPicker.js +139 -0
  51. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  52. package/dist/cli/ink/runSlashPalette.js +33 -0
  53. package/dist/cli/ink/runWizard.d.ts +22 -0
  54. package/dist/cli/ink/runWizard.js +133 -0
  55. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  56. package/dist/cli/ink/stdinHandoff.js +78 -0
  57. package/dist/cli/ink/toolFormat.d.ts +73 -0
  58. package/dist/cli/ink/toolFormat.js +180 -0
  59. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  60. package/dist/cli/ink/useTerminalSize.js +26 -0
  61. package/dist/cli/repl.d.ts +25 -3
  62. package/dist/cli/repl.js +43 -712
  63. package/dist/cli/slashSuggest.d.ts +32 -0
  64. package/dist/cli/slashSuggest.js +146 -0
  65. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  66. package/dist/cli/wizard/modelsApi.js +166 -0
  67. package/dist/cli/wizard/picker.d.ts +202 -0
  68. package/dist/cli/wizard/picker.js +547 -0
  69. package/dist/cli/wizard/providers.d.ts +86 -0
  70. package/dist/cli/wizard/providers.js +190 -0
  71. package/dist/cli/wizard/runner.d.ts +13 -0
  72. package/dist/cli/wizard/runner.js +488 -0
  73. package/dist/cli/wizard/types.d.ts +122 -0
  74. package/dist/cli/wizard/types.js +109 -0
  75. package/dist/config/config.d.ts +12 -0
  76. package/dist/config/config.js +45 -3
  77. package/dist/index.js +148 -206
  78. package/dist/memory/briefing.d.ts +1 -1
  79. package/dist/memory/consolidation.d.ts +1 -1
  80. package/dist/orchestration/agentRegistry.d.ts +36 -0
  81. package/dist/orchestration/agentRegistry.js +64 -0
  82. package/dist/orchestration/orchestrator.d.ts +7 -0
  83. package/dist/orchestration/orchestrator.js +2 -0
  84. package/dist/orchestration/tools.d.ts +10 -1
  85. package/dist/orchestration/tools.js +48 -4
  86. package/dist/prompt/skillCatalog.d.ts +11 -0
  87. package/dist/prompt/skillCatalog.js +134 -0
  88. package/dist/prompt/skillRunner.d.ts +2 -2
  89. package/dist/prompt/skillRunner.js +2 -31
  90. package/dist/prompt/systemPrompt.js +5 -1
  91. package/dist/runtime/mcpClient.js +14 -11
  92. package/dist/runtime/mcpPool.d.ts +162 -0
  93. package/dist/runtime/mcpPool.js +423 -0
  94. package/dist/runtime/mcpUtils.d.ts +3 -1
  95. package/package.json +8 -2
  96. package/.env.example +0 -116
@@ -0,0 +1,1042 @@
1
+ import chalk from 'chalk';
2
+ import { getConfigPath, saveConfig } from '../../config/config.js';
3
+ import { readPreferences, writePreferences, resolveEffort, } from '../../state/preferencesStore.js';
4
+ import { isKnownSegment, SEGMENT_NAMES } from '../statusline.js';
5
+ import { PROVIDER_CATALOG, findProvider, maskApiKey, validateApiKey } from '../wizard/providers.js';
6
+ // 0.3.7 — picker / prompt moved to Ink. The raw-stdout pickFromList /
7
+ // promptText primitives had compounding redraw bugs (frame creep on
8
+ // every keystroke, stacking on step transitions). Ink owns the render
9
+ // loop and diffs the cell grid, so all those issues are eliminated by
10
+ // design. The thin runPicker / runTextField wrappers mount + unmount
11
+ // a single Ink app per modal.
12
+ import { runPicker, runTextField } from '../ink/runPicker.js';
13
+ const pickFromList = runPicker;
14
+ const promptText = runTextField;
15
+ import { buildTheme } from '../theme.js';
16
+ /**
17
+ * `/config` slash command — 0.3.7 redesign on the new atomic-frame picker
18
+ * (`../wizard/picker.ts`).
19
+ *
20
+ * Verb-overloaded (lifted from
21
+ * `openSrc/DeepSeek-TUI/crates/tui/src/commands/config.rs:43`):
22
+ *
23
+ * - `/config` — open the settings home panel
24
+ * - `/config <key>` — print the current value for <key>
25
+ * - `/config <key> <val>` — set <key> to <val> and persist
26
+ * - `/config raw|json` — print scrubbed JSON dump
27
+ *
28
+ * Persistence routes through `saveConfig` / `writePreferences` — never
29
+ * touches JSON files directly so future schema changes stay centralized.
30
+ */
31
+ // --- Public entrypoint -------------------------------------------------
32
+ export async function tryHandleConfigCommand(ctx) {
33
+ if (ctx.command !== '/config')
34
+ return false;
35
+ const parsed = parseConfigArgs(ctx.args);
36
+ switch (parsed.mode) {
37
+ case 'home':
38
+ await runHomePanel(ctx);
39
+ return true;
40
+ case 'raw':
41
+ printRawConfig(ctx);
42
+ return true;
43
+ case 'get':
44
+ printKey(ctx, parsed.key);
45
+ return true;
46
+ case 'set':
47
+ await setKey(ctx, parsed.key, parsed.value);
48
+ return true;
49
+ }
50
+ }
51
+ export function parseConfigArgs(args) {
52
+ if (args.length === 0)
53
+ return { mode: 'home' };
54
+ const first = args[0].toLowerCase();
55
+ if (first === 'raw' || first === '--raw' || first === 'json')
56
+ return { mode: 'raw' };
57
+ if (args.length === 1)
58
+ return { mode: 'get', key: first };
59
+ return { mode: 'set', key: first, value: args.slice(1).join(' ').trim() };
60
+ }
61
+ export function listKnownConfigKeys() {
62
+ return Object.keys(KEY_HANDLERS);
63
+ }
64
+ // --- Settings home panel -----------------------------------------------
65
+ async function runHomePanel(ctx) {
66
+ const { agent } = ctx;
67
+ let cursor = 0;
68
+ while (true) {
69
+ const theme = buildTheme(readPreferences(agent.workspaceRoot).theme === 'mono' ? 'mono' : readPreferences(agent.workspaceRoot).theme === 'light' ? 'light' : 'dark');
70
+ const rows = buildPanelRows(ctx);
71
+ const pickerRows = rows.map((r) => ({
72
+ id: r.key,
73
+ label: r.label,
74
+ value: r.current(),
75
+ disabled: r.key === '__separator__',
76
+ }));
77
+ const result = await pickFromList({
78
+ theme,
79
+ title: '⚙️ /config',
80
+ subtitle: `Workspace: ${agent.workspaceRoot}. Edit a row, or pick "View raw config" to dump the scrubbed JSON.`,
81
+ rows: pickerRows,
82
+ initialCursor: cursor,
83
+ footer: '↑/↓ navigate · ↵ edit row · esc / q close',
84
+ });
85
+ if (result.kind !== 'pick')
86
+ return;
87
+ const picked = rows.find((r) => r.key === result.id);
88
+ if (!picked)
89
+ return;
90
+ cursor = rows.indexOf(picked);
91
+ if (picked.key === '__exit')
92
+ return;
93
+ if (picked.key === '__raw') {
94
+ await showRawConfigPanel(ctx, theme);
95
+ continue;
96
+ }
97
+ try {
98
+ await picked.edit(ctx);
99
+ }
100
+ catch (err) {
101
+ console.log(chalk.red(`\n /config "${picked.label}" failed: ${err?.message ?? err}\n`));
102
+ }
103
+ }
104
+ }
105
+ function buildPanelRows(ctx) {
106
+ const { agent, config } = ctx;
107
+ const prefs = () => readPreferences(agent.workspaceRoot);
108
+ return [
109
+ {
110
+ key: 'llm',
111
+ label: 'LLM provider',
112
+ current: () => {
113
+ const llm = config.llm;
114
+ if (!llm)
115
+ return '(not configured)';
116
+ return `${llm.model} · ${shortenEndpoint(llm.endpoint)} · ${maskApiKey(llm.apiKey)}`;
117
+ },
118
+ edit: editLlm,
119
+ },
120
+ {
121
+ key: 'mcp',
122
+ label: 'MCP servers',
123
+ current: () => {
124
+ const profiles = Object.keys(config.servers);
125
+ if (profiles.length === 0)
126
+ return '(none configured)';
127
+ const active = config.activeServer && config.servers[config.activeServer] ? config.activeServer : profiles[0];
128
+ const others = profiles.filter((p) => p !== active);
129
+ const head = `★ ${active}`;
130
+ if (others.length === 0)
131
+ return head;
132
+ const tail = others.length <= 2 ? others.join(', ') : `${others.slice(0, 2).join(', ')}, +${others.length - 2}`;
133
+ return `${head} + ${tail}`;
134
+ },
135
+ edit: editMcp,
136
+ },
137
+ { key: 'theme', label: 'Theme', current: () => prefs().theme, edit: editTheme },
138
+ { key: 'statusline', label: 'Statusline', current: () => prefs().statusline, edit: editStatusline },
139
+ { key: 'effort', label: 'Reasoning effort', current: () => `${resolveEffort(agent.workspaceRoot).effort} (${resolveEffort(agent.workspaceRoot).source})`, edit: editEffort },
140
+ { key: 'mode', label: 'Execution mode', current: () => prefs().executionMode, edit: editExecutionMode },
141
+ { key: 'review-policy', label: 'Review policy', current: () => prefs().reviewPolicy, edit: editReviewPolicy },
142
+ { key: 'quiet', label: 'Quiet mode', current: () => prefs().quiet ? 'on' : 'off', edit: toggleQuiet },
143
+ { key: 'personality', label: 'Personality', current: () => prefs().personality, edit: editPersonality },
144
+ { key: 'editor', label: 'Editor mode', current: () => prefs().editorMode, edit: editEditorMode },
145
+ { key: '__raw', label: 'View raw config', current: () => 'JSON dump', edit: async () => false },
146
+ { key: '__exit', label: 'Quit (esc)', current: () => '', edit: async () => false },
147
+ ];
148
+ }
149
+ function shortenEndpoint(url) {
150
+ if (!url)
151
+ return 'default endpoint';
152
+ return url.replace(/^https?:\/\//, '').replace(/\/v1.*$/, '').replace(/\/api\/v1.*$/, '');
153
+ }
154
+ // --- Per-row editors ---------------------------------------------------
155
+ function themeFor(ctx) {
156
+ const mode = readPreferences(ctx.agent.workspaceRoot).theme;
157
+ return buildTheme(mode === 'mono' ? 'mono' : mode === 'light' ? 'light' : 'dark');
158
+ }
159
+ // Exported so `/login` can re-enter the LLM editor as a follow-on step
160
+ // after the MCP transport block. Same flow as the `/config` panel's
161
+ // "LLM" row — provider picker → API key prompt → model picker → save.
162
+ export async function editLlm(ctx) {
163
+ const theme = themeFor(ctx);
164
+ const provResult = await pickFromList({
165
+ theme,
166
+ title: 'LLM provider',
167
+ subtitle: 'Pick a provider. The next step gathers the API key.',
168
+ rows: PROVIDER_CATALOG.map((p) => ({
169
+ id: p.id,
170
+ label: p.label,
171
+ value: p.local ? 'local · key optional' : 'cloud · needs key',
172
+ description: p.hint,
173
+ })),
174
+ initialCursor: 0,
175
+ });
176
+ if (provResult.kind !== 'pick')
177
+ return false;
178
+ const provider = PROVIDER_CATALOG.find((p) => p.id === provResult.id);
179
+ if (!provider)
180
+ return false;
181
+ const envValue = process.env[provider.envKey] ?? ctx.config.llm?.apiKey ?? '';
182
+ const keyResult = await promptText({
183
+ theme,
184
+ title: 'API key',
185
+ subtitle: envValue
186
+ ? `${provider.envKey} or current key pre-filled — press ENTER to accept, type to override.`
187
+ : provider.local ? `${provider.label} is local — blank key OK.` : `Paste your ${provider.label} key.`,
188
+ badge: provider.label,
189
+ prefilled: envValue,
190
+ placeholder: provider.local ? '(blank OK)' : 'paste API key',
191
+ validate: (raw) => {
192
+ const v = validateApiKey(raw, provider);
193
+ return v.kind === 'reject' ? v.reason : undefined;
194
+ },
195
+ });
196
+ if (keyResult.kind !== 'accept')
197
+ return false;
198
+ const modelResult = await pickFromList({
199
+ theme,
200
+ title: 'Model',
201
+ subtitle: `Pick the chat model for ${provider.label}.`,
202
+ rows: provider.models.map((m) => ({ id: m, label: m, value: m === provider.defaultModel ? 'default' : '' })),
203
+ initialCursor: Math.max(0, provider.models.indexOf(provider.defaultModel)),
204
+ allowOther: true,
205
+ otherLabel: 'Other model',
206
+ otherDescription: 'Type any model name supported by this endpoint',
207
+ });
208
+ if (modelResult.kind === 'cancelled')
209
+ return false;
210
+ const model = modelResult.kind === 'other' ? modelResult.text.trim() : modelResult.id;
211
+ ctx.config.llm = {
212
+ provider: 'openai',
213
+ apiKey: keyResult.text,
214
+ model: model || provider.defaultModel,
215
+ endpoint: provider.endpoint,
216
+ };
217
+ saveConfig(ctx.config);
218
+ ctx.agent.setModel(model || provider.defaultModel);
219
+ console.log(chalk.green(`\n ✓ LLM saved: ${provider.label} · ${model || provider.defaultModel} · ${maskApiKey(keyResult.text)}`));
220
+ console.log(chalk.gray(' Endpoint changes take effect on the next CLI restart.\n'));
221
+ return true;
222
+ }
223
+ /**
224
+ * `/config` → MCP row. 0.3.7 multi-MCP redesign — now a profile
225
+ * MANAGER instead of a single-transport picker.
226
+ *
227
+ * Top-level panel lists every entry in `config.servers` (third-party MCPs
228
+ * connect concurrently; only one BrainRouter MCP is active at a time) plus
229
+ * rows for adding a new profile, choosing which one is highlighted in the
230
+ * banner, and exiting. Picking an existing profile opens a sub-panel
231
+ * (edit URL/command, update API key, probe, remove). Adding a new
232
+ * profile runs a 4-step flow (name → transport → fields → API key)
233
+ * and auto-connects via the running pool when possible — no CLI
234
+ * restart needed.
235
+ *
236
+ * Pattern lifted from Claude Code's `/mcp` interactive menu (see
237
+ * `openSrc/claude-code/CHANGELOG.md` line 2525): one screen lists all
238
+ * servers, each row drills into per-server actions.
239
+ */
240
+ async function editMcp(ctx) {
241
+ while (true) {
242
+ const theme = themeFor(ctx);
243
+ const profileIds = Object.keys(ctx.config.servers);
244
+ const ROW_ADD = '__add__';
245
+ const ROW_ACTIVE = '__active__';
246
+ const ROW_DONE = '__done__';
247
+ const rows = [
248
+ ...profileIds.map((id) => {
249
+ const s = ctx.config.servers[id];
250
+ const isActive = id === ctx.config.activeServer;
251
+ const transportLabel = s.type === 'http' ? `http · ${s.url ?? ''}` : `stdio · ${s.command ?? ''}`;
252
+ const tags = [];
253
+ if (s.identity === 'brainrouter')
254
+ tags.push('brainrouter');
255
+ if (s.apiKey)
256
+ tags.push(`key ${maskApiKey(s.apiKey)}`);
257
+ return {
258
+ id,
259
+ label: `${isActive ? '★ ' : ' '}${id}`,
260
+ value: transportLabel + (tags.length ? ` · ${tags.join(' · ')}` : ''),
261
+ description: isActive
262
+ ? 'highlighted in banner; selects active BrainRouter when this profile is BrainRouter'
263
+ : undefined,
264
+ };
265
+ }),
266
+ { id: ROW_ADD, label: '+ Add new MCP server', value: '', description: 'Register another MCP (third-party tool, additional brain instance, etc.)' },
267
+ ...(profileIds.length > 0
268
+ ? [{ id: ROW_ACTIVE, label: 'Set highlighted server', value: ctx.config.activeServer || '(none)', description: 'Banner highlight + single-server fallback for --profile' }]
269
+ : []),
270
+ { id: ROW_DONE, label: 'Done', value: '', description: 'Close this panel' },
271
+ ];
272
+ const result = await pickFromList({
273
+ theme,
274
+ title: 'MCP servers',
275
+ subtitle: `${profileIds.length} configured · third-party MCPs connect together; only one BrainRouter MCP is active. ★ = highlighted.`,
276
+ rows,
277
+ });
278
+ if (result.kind !== 'pick' || result.id === ROW_DONE)
279
+ return true;
280
+ if (result.id === ROW_ADD) {
281
+ const addedId = await addMcpProfile(ctx, theme);
282
+ if (addedId) {
283
+ // First-added profile auto-becomes the highlighted one if
284
+ // nothing was selected before — avoids a confused banner.
285
+ if (!ctx.config.activeServer || !ctx.config.servers[ctx.config.activeServer]) {
286
+ ctx.config.activeServer = addedId;
287
+ }
288
+ saveConfig(ctx.config);
289
+ await tryConnectInPool(ctx, addedId);
290
+ }
291
+ continue;
292
+ }
293
+ if (result.id === ROW_ACTIVE) {
294
+ await setActiveProfile(ctx, theme, profileIds);
295
+ continue;
296
+ }
297
+ // Picked an existing profile id.
298
+ await editExistingMcpProfile(ctx, theme, result.id);
299
+ }
300
+ }
301
+ /**
302
+ * Walk a user through adding a new MCP profile:
303
+ * 1. Name (validated unique, [a-z0-9_-])
304
+ * 2. Identity hint (BrainRouter vs third-party — drives the
305
+ * BRAINROUTER_API_KEY env pre-fill on the key step)
306
+ * 3. Transport (stdio / local-http / remote-http)
307
+ * 4. Fields (command for stdio, URL for http)
308
+ * 5. API key (env pre-fill for BrainRouter; blank OK for any
309
+ * unauthenticated transport)
310
+ * Returns the new profile id on success, undefined on cancel.
311
+ */
312
+ async function addMcpProfile(ctx, theme) {
313
+ const nameRes = await promptText({
314
+ theme,
315
+ title: 'New MCP server — name',
316
+ subtitle: 'Short identifier. Used in tool prefixes: mcp__<name>__<tool>.',
317
+ badge: 'MCP',
318
+ placeholder: 'github, filesystem, my-brain, …',
319
+ validate: (raw) => {
320
+ const v = raw.trim();
321
+ if (!v)
322
+ return 'name required';
323
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(v))
324
+ return 'use letters, digits, underscore, or dash (must start with letter or digit)';
325
+ if (ctx.config.servers[v])
326
+ return `"${v}" already exists — edit it from the list instead`;
327
+ return undefined;
328
+ },
329
+ });
330
+ if (nameRes.kind !== 'accept')
331
+ return undefined;
332
+ const name = nameRes.text.trim();
333
+ const identityRes = await pickFromList({
334
+ theme,
335
+ title: `Identity for "${name}"`,
336
+ subtitle: 'Brainrouter MCPs get BRAINROUTER_API_KEY pre-fill on the key step. Third-party MCPs do not.',
337
+ rows: [
338
+ { id: 'third-party', label: 'Third-party MCP', value: 'default', description: 'GitHub, filesystem, browser tools, anything not BrainRouter' },
339
+ { id: 'brainrouter', label: 'BrainRouter MCP', value: 'memory + skills', description: 'Another BrainRouter brain (multi-instance setup)' },
340
+ ],
341
+ });
342
+ if (identityRes.kind !== 'pick')
343
+ return undefined;
344
+ const identity = identityRes.id;
345
+ const transportRes = await pickFromList({
346
+ theme,
347
+ title: 'Transport',
348
+ subtitle: `How does the CLI reach "${name}"?`,
349
+ rows: [
350
+ { id: 'stdio', label: 'Stdio', value: 'spawn a child process', description: 'Run a local command; communicate over stdin/stdout' },
351
+ { id: 'local-http', label: 'Local HTTP', value: 'localhost', description: 'Connect to a server already running on localhost' },
352
+ { id: 'remote-http', label: 'Remote HTTP', value: 'custom URL', description: 'Connect to a hosted MCP server (URL + API key)' },
353
+ ],
354
+ });
355
+ if (transportRes.kind !== 'pick')
356
+ return undefined;
357
+ let server;
358
+ if (transportRes.id === 'stdio') {
359
+ const cmdRes = await promptText({
360
+ theme,
361
+ title: 'Command',
362
+ subtitle: 'Executable + args (space-separated). Example: npx @modelcontextprotocol/server-filesystem /tmp',
363
+ badge: 'MCP',
364
+ prefilled: identity === 'brainrouter' ? 'brainrouter-mcp' : '',
365
+ placeholder: 'command [args...]',
366
+ validate: (raw) => raw.trim() ? undefined : 'command required',
367
+ });
368
+ if (cmdRes.kind !== 'accept')
369
+ return undefined;
370
+ const parts = cmdRes.text.trim().split(/\s+/);
371
+ server = { type: 'stdio', command: parts[0], args: parts.slice(1), identity };
372
+ }
373
+ else {
374
+ const isLocal = transportRes.id === 'local-http';
375
+ const urlRes = await promptText({
376
+ theme,
377
+ title: 'URL',
378
+ subtitle: isLocal ? 'Local MCP endpoint URL (e.g. http://localhost:3747/mcp).' : 'Full URL to the hosted MCP (https://…/mcp).',
379
+ badge: 'MCP',
380
+ prefilled: isLocal ? 'http://localhost:3747/mcp' : '',
381
+ placeholder: 'https://...',
382
+ validate: (raw) => {
383
+ const v = raw.trim();
384
+ if (!v)
385
+ return 'URL required';
386
+ try {
387
+ new URL(v);
388
+ }
389
+ catch {
390
+ return 'not a valid URL';
391
+ }
392
+ return undefined;
393
+ },
394
+ });
395
+ if (urlRes.kind !== 'accept')
396
+ return undefined;
397
+ // BrainRouter MCPs go through the shared `promptBrainrouterApiKey`
398
+ // helper (BRAINROUTER_API_KEY env pre-fill + brainrouter-shaped
399
+ // subtitle). Third-party MCPs get a generic "bearer token" prompt
400
+ // so we don't suggest a wrong env var name.
401
+ let apiKey;
402
+ if (identity === 'brainrouter') {
403
+ apiKey = await promptBrainrouterApiKey(theme, isLocal ? 'local' : 'remote', undefined);
404
+ if (apiKey === undefined)
405
+ return undefined;
406
+ }
407
+ else {
408
+ const keyRes = await promptText({
409
+ theme,
410
+ title: 'API key / bearer token',
411
+ subtitle: `Authorization header for "${name}". Leave blank if the server is unauthenticated.`,
412
+ badge: 'MCP',
413
+ prefilled: '',
414
+ placeholder: '(blank OK)',
415
+ });
416
+ if (keyRes.kind !== 'accept')
417
+ return undefined;
418
+ apiKey = keyRes.text.trim();
419
+ }
420
+ server = {
421
+ type: 'http',
422
+ url: urlRes.text.trim(),
423
+ apiKey: apiKey || undefined,
424
+ identity,
425
+ };
426
+ }
427
+ ctx.config.servers[name] = server;
428
+ console.log(chalk.green(`\n ✓ "${name}" added.`));
429
+ return name;
430
+ }
431
+ /**
432
+ * Per-profile sub-panel: edit URL/command, update API key, probe,
433
+ * remove. Re-enters on every action so the user can chain edits
434
+ * before exiting back to the profile list.
435
+ */
436
+ async function editExistingMcpProfile(ctx, theme, id) {
437
+ while (true) {
438
+ const server = ctx.config.servers[id];
439
+ if (!server)
440
+ return; // got removed mid-loop
441
+ const summary = server.type === 'http'
442
+ ? `http · ${server.url ?? ''}${server.apiKey ? ` · key ${maskApiKey(server.apiKey)}` : ''}`
443
+ : `stdio · ${server.command ?? ''} ${(server.args ?? []).join(' ')}`;
444
+ const result = await pickFromList({
445
+ theme,
446
+ title: `MCP profile · ${id}`,
447
+ subtitle: `${summary} · identity: ${server.identity ?? 'unknown'}`,
448
+ rows: [
449
+ ...(server.type === 'http'
450
+ ? [{ id: 'url', label: 'Edit URL', value: server.url ?? '', description: 'Change the HTTP endpoint' }]
451
+ : [{ id: 'command', label: 'Edit command', value: `${server.command ?? ''} ${(server.args ?? []).join(' ')}`.trim(), description: 'Change the stdio command + args' }]),
452
+ { id: 'apikey', label: 'Update API key', value: server.apiKey ? maskApiKey(server.apiKey) : '(none)', description: 'Bearer token / Authorization header' },
453
+ { id: 'probe', label: 'Probe connection', value: '', description: 'Test reachability (5s timeout)' },
454
+ { id: 'remove', label: 'Remove this profile', value: '', description: 'Drops it from config and disconnects from the pool' },
455
+ { id: 'back', label: 'Back', value: '', description: 'Return to the profile list' },
456
+ ],
457
+ });
458
+ if (result.kind !== 'pick' || result.id === 'back')
459
+ return;
460
+ if (result.id === 'url') {
461
+ const r = await promptText({
462
+ theme, title: 'URL', badge: 'MCP', prefilled: server.url ?? '', placeholder: 'https://...',
463
+ validate: (raw) => {
464
+ if (!raw.trim())
465
+ return 'URL required';
466
+ try {
467
+ new URL(raw.trim());
468
+ }
469
+ catch {
470
+ return 'not a valid URL';
471
+ }
472
+ return undefined;
473
+ },
474
+ });
475
+ if (r.kind === 'accept') {
476
+ ctx.config.servers[id] = { ...server, type: 'http', url: r.text.trim() };
477
+ saveConfig(ctx.config);
478
+ // Reconnect the pool so the new URL takes effect immediately.
479
+ await tryReconnectInPool(ctx, id);
480
+ console.log(chalk.green(` ✓ URL updated → ${r.text.trim()}\n`));
481
+ }
482
+ continue;
483
+ }
484
+ if (result.id === 'command') {
485
+ const r = await promptText({
486
+ theme, title: 'Command + args', badge: 'MCP',
487
+ prefilled: `${server.command ?? ''} ${(server.args ?? []).join(' ')}`.trim(),
488
+ placeholder: 'command [args...]',
489
+ validate: (raw) => raw.trim() ? undefined : 'command required',
490
+ });
491
+ if (r.kind === 'accept') {
492
+ const parts = r.text.trim().split(/\s+/);
493
+ ctx.config.servers[id] = { ...server, type: 'stdio', command: parts[0], args: parts.slice(1) };
494
+ saveConfig(ctx.config);
495
+ await tryReconnectInPool(ctx, id);
496
+ console.log(chalk.green(` ✓ Command updated.\n`));
497
+ }
498
+ continue;
499
+ }
500
+ if (result.id === 'apikey') {
501
+ let apiKey;
502
+ if (server.identity === 'brainrouter') {
503
+ const isLocal = server.type === 'http' && (server.url ?? '').includes('localhost');
504
+ apiKey = await promptBrainrouterApiKey(theme, isLocal ? 'local' : 'remote', server.apiKey);
505
+ if (apiKey === undefined)
506
+ continue;
507
+ }
508
+ else {
509
+ const r = await promptText({
510
+ theme, title: 'API key', badge: 'MCP',
511
+ prefilled: server.apiKey ?? '',
512
+ placeholder: '(blank OK)',
513
+ subtitle: `Bearer token for "${id}". Leave blank if the server doesn't require auth.`,
514
+ });
515
+ if (r.kind !== 'accept')
516
+ continue;
517
+ apiKey = r.text.trim();
518
+ }
519
+ ctx.config.servers[id] = { ...server, apiKey: apiKey || undefined };
520
+ saveConfig(ctx.config);
521
+ await tryReconnectInPool(ctx, id);
522
+ console.log(chalk.green(` ✓ API key updated.\n`));
523
+ continue;
524
+ }
525
+ if (result.id === 'probe') {
526
+ console.log(chalk.gray(` Probing "${id}"…`));
527
+ try {
528
+ await ctx.mcpClient.reconnectOne?.(id);
529
+ const status = ctx.mcpClient.getStatus?.(id);
530
+ if (status?.status === 'connected') {
531
+ console.log(chalk.green(` ✓ "${id}" reachable (${status.toolCount ?? 0} tools).\n`));
532
+ }
533
+ else {
534
+ console.log(chalk.red(` ✗ "${id}" failed — ${status?.error ?? 'unknown'}\n`));
535
+ }
536
+ }
537
+ catch (err) {
538
+ console.log(chalk.red(` ✗ probe failed: ${err?.message ?? err}\n`));
539
+ }
540
+ continue;
541
+ }
542
+ if (result.id === 'remove') {
543
+ const confirm = await pickFromList({
544
+ theme,
545
+ title: `Remove "${id}"?`,
546
+ subtitle: 'This deletes the profile from config.json and disconnects it from the pool.',
547
+ rows: [
548
+ { id: 'cancel', label: 'Cancel', value: 'default', description: 'Keep the profile' },
549
+ { id: 'remove', label: 'Remove', value: '', description: 'Delete + disconnect' },
550
+ ],
551
+ });
552
+ if (confirm.kind === 'pick' && confirm.id === 'remove') {
553
+ try {
554
+ await ctx.mcpClient.disconnectOne?.(id);
555
+ }
556
+ catch { /* idempotent */ }
557
+ delete ctx.config.servers[id];
558
+ if (ctx.config.activeServer === id) {
559
+ // Pick the next surviving profile as the new highlight, or
560
+ // clear it if none remain.
561
+ const remaining = Object.keys(ctx.config.servers);
562
+ ctx.config.activeServer = remaining[0] ?? '';
563
+ }
564
+ saveConfig(ctx.config);
565
+ console.log(chalk.yellow(` ✓ Removed "${id}".\n`));
566
+ return;
567
+ }
568
+ continue;
569
+ }
570
+ }
571
+ }
572
+ /**
573
+ * Highlighted-server picker. The "active" profile is now just a
574
+ * banner-highlight and the fallback for `--profile`; all configured
575
+ * servers connect on boot regardless.
576
+ */
577
+ async function setActiveProfile(ctx, theme, profileIds) {
578
+ if (profileIds.length === 0) {
579
+ console.log(chalk.yellow('\n No profiles to choose from. Add one first.\n'));
580
+ return;
581
+ }
582
+ const result = await pickFromList({
583
+ theme,
584
+ title: 'Highlighted MCP server',
585
+ subtitle: 'Shows in the banner and is the default when --profile is omitted in non-interactive runs.',
586
+ rows: profileIds.map((id) => {
587
+ const s = ctx.config.servers[id];
588
+ const transport = s.type === 'http' ? `http · ${s.url ?? ''}` : `stdio · ${s.command ?? ''}`;
589
+ return {
590
+ id,
591
+ label: id,
592
+ value: transport,
593
+ description: id === ctx.config.activeServer ? '(current)' : undefined,
594
+ };
595
+ }),
596
+ initialCursor: Math.max(0, profileIds.indexOf(ctx.config.activeServer)),
597
+ });
598
+ if (result.kind !== 'pick')
599
+ return;
600
+ ctx.config.activeServer = result.id;
601
+ saveConfig(ctx.config);
602
+ console.log(chalk.green(`\n ✓ Highlighted server → ${result.id}\n`));
603
+ }
604
+ /**
605
+ * Best-effort live update: try to bring the new profile online in
606
+ * the running pool without restart. The Pool's API surface lets us
607
+ * call connectOne directly. Falls through silently if the runtime
608
+ * `mcpClient` isn't actually a Pool (probe sites, etc.).
609
+ */
610
+ async function tryConnectInPool(ctx, id) {
611
+ const pool = ctx.mcpClient;
612
+ if (typeof pool?.connectOne !== 'function')
613
+ return;
614
+ const cfg = ctx.config.servers[id];
615
+ if (!cfg)
616
+ return;
617
+ try {
618
+ await pool.connectOne(id, cfg, ctx.config.llm, 5_000);
619
+ const status = pool.getStatus?.(id);
620
+ if (status?.status === 'connected') {
621
+ console.log(chalk.gray(` → connected (${status.toolCount ?? 0} tools)`));
622
+ }
623
+ else if (status?.status === 'failed') {
624
+ console.log(chalk.yellow(` → saved but offline (${status.error ?? 'unknown'}). Try /mcp reconnect ${id} once the server is up.`));
625
+ }
626
+ }
627
+ catch (err) {
628
+ console.log(chalk.yellow(` → connect attempt failed: ${err?.message ?? err}`));
629
+ }
630
+ }
631
+ async function tryReconnectInPool(ctx, id) {
632
+ const pool = ctx.mcpClient;
633
+ if (typeof pool?.reconnectOne !== 'function')
634
+ return;
635
+ try {
636
+ await pool.reconnectOne(id);
637
+ }
638
+ catch { /* user can /mcp reconnect manually */ }
639
+ }
640
+ /**
641
+ * Shared prompt for the BrainRouter MCP HTTP API key (the
642
+ * `BRAINROUTER_API_KEY` bearer token). Pre-fills from the env var if
643
+ * set, then from the previously-saved key, then blank. Returns:
644
+ * - the trimmed key string (possibly empty when user chose "no key")
645
+ * - undefined when the user pressed Esc
646
+ *
647
+ * Exported so `/login` and any future MCP-setup surfaces share one
648
+ * prompt copy — same subtitle text, same env-var pre-fill, same
649
+ * "blank OK" semantics.
650
+ */
651
+ export async function promptBrainrouterApiKey(theme, kind, existing) {
652
+ const envValue = process.env.BRAINROUTER_API_KEY ?? '';
653
+ const prefilled = envValue || existing || '';
654
+ const subtitle = envValue
655
+ ? 'BRAINROUTER_API_KEY is set — press ENTER to accept, type to override, or blank for an unauthenticated server.'
656
+ : kind === 'local'
657
+ ? 'Optional — leave blank if your local brainrouter-mcp HTTP server runs without auth. Required when BRAINROUTER_API_KEY is set on the server side.'
658
+ : 'Optional — leave blank if the hosted MCP doesn\'t require auth. Use the key issued by the BrainRouter dashboard (Users → Profile).';
659
+ const result = await promptText({
660
+ theme,
661
+ title: 'BrainRouter API key',
662
+ subtitle,
663
+ badge: 'MCP',
664
+ prefilled,
665
+ placeholder: '(blank OK)',
666
+ });
667
+ if (result.kind !== 'accept')
668
+ return undefined;
669
+ return result.text.trim();
670
+ }
671
+ async function editTheme(ctx) {
672
+ const theme = themeFor(ctx);
673
+ const result = await pickFromList({
674
+ theme,
675
+ title: 'Theme',
676
+ subtitle: 'Pick a color palette.',
677
+ rows: [
678
+ { id: 'dark', label: 'Dark', description: 'saturated accents on black' },
679
+ { id: 'light', label: 'Light', description: 'darker accents for white terminals' },
680
+ { id: 'mono', label: 'Mono', description: 'no color' },
681
+ { id: 'auto', label: 'Auto', description: 'falls back to dark for now' },
682
+ ],
683
+ });
684
+ if (result.kind !== 'pick')
685
+ return false;
686
+ writePreferences(ctx.agent.workspaceRoot, { theme: result.id });
687
+ console.log(chalk.green(`\n ✓ Theme → ${result.id}\n`));
688
+ return true;
689
+ }
690
+ async function editStatusline(ctx) {
691
+ const theme = themeFor(ctx);
692
+ const current = readPreferences(ctx.agent.workspaceRoot).statusline;
693
+ const result = await promptText({
694
+ theme,
695
+ title: 'Statusline segments',
696
+ subtitle: `Comma-separated subset of: ${SEGMENT_NAMES.join(', ')}`,
697
+ prefilled: current,
698
+ placeholder: 'mode,branch,workflow,goal',
699
+ validate: (raw) => {
700
+ const segments = raw.split(',').map((s) => s.trim()).filter(Boolean);
701
+ const unknown = segments.filter((s) => !isKnownSegment(s));
702
+ if (unknown.length > 0)
703
+ return `unknown segment(s): ${unknown.join(', ')}`;
704
+ return undefined;
705
+ },
706
+ });
707
+ if (result.kind !== 'accept')
708
+ return false;
709
+ const segments = result.text.split(',').map((s) => s.trim()).filter(Boolean);
710
+ writePreferences(ctx.agent.workspaceRoot, { statusline: segments.join(',') });
711
+ ctx.repl.refreshPromptForMode();
712
+ console.log(chalk.green(`\n ✓ Statusline → ${segments.join(',')}\n`));
713
+ return true;
714
+ }
715
+ async function editEffort(ctx) {
716
+ const theme = themeFor(ctx);
717
+ const result = await pickFromList({
718
+ theme,
719
+ title: 'Reasoning effort',
720
+ subtitle: 'How hard should the model think? Orthogonal to /mode.',
721
+ rows: [
722
+ { id: 'low', label: 'Low', description: 'terse, one-paragraph answers' },
723
+ { id: 'medium', label: 'Medium', value: 'default', description: 'no overlay, no provider reasoning slot' },
724
+ { id: 'high', label: 'High', description: 'step-by-step audit before each tool call' },
725
+ ],
726
+ });
727
+ if (result.kind !== 'pick')
728
+ return false;
729
+ writePreferences(ctx.agent.workspaceRoot, { effort: result.id });
730
+ ctx.agent.refreshSystemPrompt();
731
+ console.log(chalk.green(`\n ✓ Effort → ${result.id}\n`));
732
+ return true;
733
+ }
734
+ async function editExecutionMode(ctx) {
735
+ const theme = themeFor(ctx);
736
+ const result = await pickFromList({
737
+ theme,
738
+ title: 'Execution mode',
739
+ rows: [
740
+ { id: 'planning', label: 'Planning', value: 'default', description: 'every run_command y/N' },
741
+ { id: 'fast', label: 'Fast', description: 'safe commands auto-run; dangerous still prompt' },
742
+ ],
743
+ });
744
+ if (result.kind !== 'pick')
745
+ return false;
746
+ writePreferences(ctx.agent.workspaceRoot, { executionMode: result.id });
747
+ console.log(chalk.green(`\n ✓ Execution mode → ${result.id}\n`));
748
+ return true;
749
+ }
750
+ async function editReviewPolicy(ctx) {
751
+ const theme = themeFor(ctx);
752
+ const result = await pickFromList({
753
+ theme,
754
+ title: 'Review policy',
755
+ rows: [
756
+ { id: 'request', label: 'Request', value: 'default', description: 'prompt for /approve at multi-file gates' },
757
+ { id: 'proceed', label: 'Proceed', description: 'apply plan and report after' },
758
+ ],
759
+ });
760
+ if (result.kind !== 'pick')
761
+ return false;
762
+ writePreferences(ctx.agent.workspaceRoot, { reviewPolicy: result.id });
763
+ console.log(chalk.green(`\n ✓ Review policy → ${result.id}\n`));
764
+ return true;
765
+ }
766
+ async function editPersonality(ctx) {
767
+ const theme = themeFor(ctx);
768
+ const result = await pickFromList({
769
+ theme,
770
+ title: 'Personality',
771
+ subtitle: 'Communication style for agent responses.',
772
+ rows: [
773
+ { id: 'concise', label: 'Concise', description: 'short responses' },
774
+ { id: 'standard', label: 'Standard', value: 'default' },
775
+ { id: 'detailed', label: 'Detailed', description: 'verbose explanations' },
776
+ { id: 'pair-programmer', label: 'Pair programmer', description: 'think-out-loud' },
777
+ ],
778
+ });
779
+ if (result.kind !== 'pick')
780
+ return false;
781
+ writePreferences(ctx.agent.workspaceRoot, { personality: result.id });
782
+ ctx.agent.refreshSystemPrompt();
783
+ console.log(chalk.green(`\n ✓ Personality → ${result.id}\n`));
784
+ return true;
785
+ }
786
+ async function editEditorMode(ctx) {
787
+ const theme = themeFor(ctx);
788
+ const result = await pickFromList({
789
+ theme,
790
+ title: 'Editor mode',
791
+ rows: [
792
+ { id: 'emacs', label: 'Emacs', value: 'default', description: 'standard readline keybindings' },
793
+ { id: 'vi', label: 'Vi', description: 'vi keybindings (terminal-dependent)' },
794
+ ],
795
+ });
796
+ if (result.kind !== 'pick')
797
+ return false;
798
+ writePreferences(ctx.agent.workspaceRoot, { editorMode: result.id });
799
+ console.log(chalk.green(`\n ✓ Editor mode → ${result.id}. Restart the CLI to apply.\n`));
800
+ return true;
801
+ }
802
+ async function toggleQuiet(ctx) {
803
+ const current = readPreferences(ctx.agent.workspaceRoot).quiet;
804
+ const next = !current;
805
+ writePreferences(ctx.agent.workspaceRoot, { quiet: next });
806
+ if (next)
807
+ process.env.BRAINROUTER_QUIET = '1';
808
+ else
809
+ delete process.env.BRAINROUTER_QUIET;
810
+ console.log(chalk.green(`\n ✓ Quiet mode → ${next ? 'on' : 'off'}\n`));
811
+ return true;
812
+ }
813
+ // --- get / set entrypoints ---------------------------------------------
814
+ async function showRawConfigPanel(ctx, theme) {
815
+ const lines = buildRawConfigLines(ctx);
816
+ await pickFromList({
817
+ theme,
818
+ title: '⚙️ Raw config',
819
+ subtitle: `Scrubbed JSON from ${getConfigPath()}`,
820
+ rows: [
821
+ { id: 'back', label: 'Back to /config', description: 'Return to the settings panel' },
822
+ ],
823
+ footer: '↵ back · esc / q back',
824
+ onCursorChange: () => lines,
825
+ });
826
+ }
827
+ function printRawConfig(ctx) {
828
+ console.log(chalk.bold('\n⚙️ Active Configuration:'));
829
+ console.log(` File Path: ${chalk.blue(getConfigPath())}\n`);
830
+ console.log(chalk.gray(buildScrubbedConfigJson(ctx.config)));
831
+ console.log();
832
+ }
833
+ export function buildScrubbedConfigJson(config) {
834
+ const scrubbed = JSON.parse(JSON.stringify(config));
835
+ scrubSecrets(scrubbed);
836
+ return JSON.stringify(scrubbed, null, 2);
837
+ }
838
+ function buildRawConfigLines(ctx) {
839
+ return buildScrubbedConfigJson(ctx.config).split('\n');
840
+ }
841
+ function scrubSecrets(scrubbed) {
842
+ if (scrubbed.llm?.apiKey)
843
+ scrubbed.llm.apiKey = maskApiKey(scrubbed.llm.apiKey);
844
+ for (const s of Object.values(scrubbed.servers ?? {})) {
845
+ const srv = s;
846
+ if (srv.apiKey)
847
+ srv.apiKey = maskApiKey(srv.apiKey);
848
+ if (srv.env?.BRAINROUTER_API_KEY)
849
+ srv.env.BRAINROUTER_API_KEY = maskApiKey(srv.env.BRAINROUTER_API_KEY);
850
+ }
851
+ }
852
+ const KEY_HANDLERS = {
853
+ theme: {
854
+ get: (ctx) => readPreferences(ctx.agent.workspaceRoot).theme,
855
+ set: (ctx, value) => {
856
+ const v = value.toLowerCase();
857
+ if (!['auto', 'light', 'dark', 'mono'].includes(v)) {
858
+ return { ok: false, reason: `theme must be auto|light|dark|mono (got "${value}")` };
859
+ }
860
+ writePreferences(ctx.agent.workspaceRoot, { theme: v });
861
+ return { ok: true, message: `theme → ${v}` };
862
+ },
863
+ },
864
+ statusline: {
865
+ get: (ctx) => readPreferences(ctx.agent.workspaceRoot).statusline,
866
+ set: (ctx, value) => {
867
+ const segments = value.split(',').map((s) => s.trim()).filter(Boolean);
868
+ const unknown = segments.filter((s) => !isKnownSegment(s));
869
+ if (unknown.length > 0)
870
+ return { ok: false, reason: `unknown segment(s): ${unknown.join(', ')}` };
871
+ writePreferences(ctx.agent.workspaceRoot, { statusline: segments.join(',') });
872
+ return { ok: true, message: `statusline → ${segments.join(',')}` };
873
+ },
874
+ },
875
+ effort: {
876
+ get: (ctx) => `${resolveEffort(ctx.agent.workspaceRoot).effort} (${resolveEffort(ctx.agent.workspaceRoot).source})`,
877
+ set: (ctx, value) => {
878
+ const v = value.toLowerCase();
879
+ if (!['low', 'medium', 'high'].includes(v))
880
+ return { ok: false, reason: `effort must be low|medium|high (got "${value}")` };
881
+ writePreferences(ctx.agent.workspaceRoot, { effort: v });
882
+ return { ok: true, message: `effort → ${v}` };
883
+ },
884
+ },
885
+ mode: {
886
+ get: (ctx) => readPreferences(ctx.agent.workspaceRoot).executionMode,
887
+ set: (ctx, value) => {
888
+ const v = value.toLowerCase();
889
+ if (!['planning', 'fast'].includes(v))
890
+ return { ok: false, reason: `mode must be planning|fast (got "${value}")` };
891
+ writePreferences(ctx.agent.workspaceRoot, { executionMode: v });
892
+ return { ok: true, message: `execution mode → ${v}` };
893
+ },
894
+ },
895
+ 'review-policy': {
896
+ get: (ctx) => readPreferences(ctx.agent.workspaceRoot).reviewPolicy,
897
+ set: (ctx, value) => {
898
+ const v = value.toLowerCase();
899
+ if (!['request', 'proceed'].includes(v))
900
+ return { ok: false, reason: `review-policy must be request|proceed (got "${value}")` };
901
+ writePreferences(ctx.agent.workspaceRoot, { reviewPolicy: v });
902
+ return { ok: true, message: `review policy → ${v}` };
903
+ },
904
+ },
905
+ quiet: {
906
+ get: (ctx) => (readPreferences(ctx.agent.workspaceRoot).quiet ? 'on' : 'off'),
907
+ set: (ctx, value) => {
908
+ const v = value.toLowerCase();
909
+ const on = ['on', 'true', '1', 'yes'].includes(v);
910
+ const off = ['off', 'false', '0', 'no'].includes(v);
911
+ if (!on && !off)
912
+ return { ok: false, reason: `quiet must be on|off (got "${value}")` };
913
+ writePreferences(ctx.agent.workspaceRoot, { quiet: on });
914
+ if (on)
915
+ process.env.BRAINROUTER_QUIET = '1';
916
+ else
917
+ delete process.env.BRAINROUTER_QUIET;
918
+ return { ok: true, message: `quiet → ${on ? 'on' : 'off'}` };
919
+ },
920
+ },
921
+ personality: {
922
+ get: (ctx) => readPreferences(ctx.agent.workspaceRoot).personality,
923
+ set: (ctx, value) => {
924
+ const v = value.toLowerCase();
925
+ if (!['concise', 'standard', 'detailed', 'pair-programmer'].includes(v)) {
926
+ return { ok: false, reason: `personality must be concise|standard|detailed|pair-programmer (got "${value}")` };
927
+ }
928
+ writePreferences(ctx.agent.workspaceRoot, { personality: v });
929
+ return { ok: true, message: `personality → ${v}` };
930
+ },
931
+ },
932
+ editor: {
933
+ get: (ctx) => readPreferences(ctx.agent.workspaceRoot).editorMode,
934
+ set: (ctx, value) => {
935
+ const v = value.toLowerCase();
936
+ if (!['emacs', 'vi'].includes(v))
937
+ return { ok: false, reason: `editor must be emacs|vi (got "${value}")` };
938
+ writePreferences(ctx.agent.workspaceRoot, { editorMode: v });
939
+ return { ok: true, message: `editor → ${v} (restart to apply)` };
940
+ },
941
+ },
942
+ model: {
943
+ get: (ctx) => ctx.config.llm?.model ?? '(unset)',
944
+ set: (ctx, value) => {
945
+ if (!value.trim())
946
+ return { ok: false, reason: 'model name cannot be empty' };
947
+ ctx.agent.setModel(value.trim());
948
+ if (ctx.config.llm) {
949
+ ctx.config.llm.model = value.trim();
950
+ saveConfig(ctx.config);
951
+ }
952
+ return { ok: true, message: `model → ${value.trim()}` };
953
+ },
954
+ },
955
+ provider: {
956
+ get: (ctx) => {
957
+ const llm = ctx.config.llm;
958
+ if (!llm)
959
+ return '(unset)';
960
+ const match = PROVIDER_CATALOG.find((p) => p.endpoint === llm.endpoint);
961
+ return match?.id ?? 'custom';
962
+ },
963
+ // Async so we can re-prompt for the API key when the provider
964
+ // changes. Pre-0.3.7 this setter silently reused the OLD provider's
965
+ // apiKey, which left users with (e.g.) OpenAI keys pointed at the
966
+ // DeepSeek endpoint — 401 on every turn with no clear message.
967
+ set: async (ctx, value) => {
968
+ const provider = findProvider(value.trim().toLowerCase());
969
+ if (!provider)
970
+ return { ok: false, reason: `unknown provider id "${value}" — open /config (bare) and pick interactively` };
971
+ const previousProviderId = (ctx.config.llm?.endpoint
972
+ ? PROVIDER_CATALOG.find((p) => p.endpoint === ctx.config.llm.endpoint)?.id
973
+ : undefined);
974
+ const sameProvider = previousProviderId === provider.id;
975
+ // Reusing the existing key is correct when the provider isn't
976
+ // actually changing (idempotent set). Re-prompt on any real
977
+ // provider change — pre-fill from the new provider's envKey or
978
+ // (last resort) the previously-stored key if the user wants to
979
+ // paste a same-vendor variant.
980
+ let apiKey = ctx.config.llm?.apiKey ?? '';
981
+ if (!sameProvider) {
982
+ const theme = themeFor(ctx);
983
+ const envValue = process.env[provider.envKey] ?? '';
984
+ const keyResult = await promptText({
985
+ theme,
986
+ title: `API key for ${provider.label}`,
987
+ subtitle: envValue
988
+ ? `${provider.envKey} is set — press ENTER to accept, type to override.`
989
+ : provider.local
990
+ ? `${provider.label} is local — blank key OK.`
991
+ : `${provider.label} requires an API key. Paste it now or press Esc to cancel.`,
992
+ badge: provider.label,
993
+ prefilled: envValue,
994
+ placeholder: provider.local ? '(blank OK)' : 'paste API key',
995
+ validate: (raw) => {
996
+ const v = validateApiKey(raw, provider);
997
+ return v.kind === 'reject' ? v.reason : undefined;
998
+ },
999
+ });
1000
+ if (keyResult.kind !== 'accept') {
1001
+ return { ok: false, reason: 'cancelled — provider unchanged' };
1002
+ }
1003
+ apiKey = keyResult.text;
1004
+ }
1005
+ ctx.config.llm = {
1006
+ provider: 'openai',
1007
+ apiKey,
1008
+ model: provider.defaultModel,
1009
+ endpoint: provider.endpoint,
1010
+ };
1011
+ saveConfig(ctx.config);
1012
+ ctx.agent.setModel(provider.defaultModel);
1013
+ const tail = sameProvider
1014
+ ? '(provider unchanged — reused existing key + reset model to default)'
1015
+ : `(model defaulted to ${provider.defaultModel} · key ${maskApiKey(apiKey)})`;
1016
+ return { ok: true, message: `provider → ${provider.label} ${tail}` };
1017
+ },
1018
+ },
1019
+ };
1020
+ function printKey(ctx, key) {
1021
+ const handler = KEY_HANDLERS[key];
1022
+ if (!handler) {
1023
+ console.log(chalk.red(`\n Unknown config key "${key}".`));
1024
+ console.log(chalk.gray(` Known keys: ${Object.keys(KEY_HANDLERS).join(', ')}. Run /config (bare) for the interactive panel.\n`));
1025
+ return;
1026
+ }
1027
+ console.log(`\n ${chalk.cyan(key)}: ${chalk.bold(handler.get(ctx))}\n`);
1028
+ }
1029
+ async function setKey(ctx, key, value) {
1030
+ const handler = KEY_HANDLERS[key];
1031
+ if (!handler || !handler.set) {
1032
+ console.log(chalk.red(`\n /config can't set "${key}" directly.`));
1033
+ console.log(chalk.gray(` Run /config (bare) and pick "${key}" interactively, or pick one of: ${Object.keys(KEY_HANDLERS).join(', ')}.\n`));
1034
+ return;
1035
+ }
1036
+ const result = await handler.set(ctx, value);
1037
+ if (!result.ok) {
1038
+ console.log(chalk.red(`\n ✗ ${result.reason}\n`));
1039
+ return;
1040
+ }
1041
+ console.log(chalk.green(`\n ✓ ${result.message}\n`));
1042
+ }