@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,488 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { NoTTYError } from '../cliPrompt.js';
5
+ import { writePreferences } from '../../state/preferencesStore.js';
6
+ import { loadOrInitConfig, saveConfig, } from '../../config/config.js';
7
+ import { initAgentMd } from '../../prompt/initAgentMd.js';
8
+ import { McpClientWrapper } from '../../runtime/mcpClient.js';
9
+ import { PROVIDER_CATALOG, detectProviderFromEnv, validateApiKey, maskApiKey, } from './providers.js';
10
+ import { initWizardState, reduceWizard, } from './types.js';
11
+ import { pickFromList, promptText } from './picker.js';
12
+ import { selectModel } from './modelsApi.js';
13
+ import { buildTheme } from '../theme.js';
14
+ /**
15
+ * 0.3.7 onboarding wizard — drives the Step state machine over the new
16
+ * internal picker (`./picker.ts`) which renders atomically and never
17
+ * has external stdout writes mid-step. Fixes the redraw-stacking bug
18
+ * the original `askChoice`-based wizard hit on every cursor move.
19
+ *
20
+ * Two entry modes (unchanged from the original design):
21
+ *
22
+ * 1. **First-run auto-trigger** — `index.ts` calls
23
+ * `runWizard({ ownsReadline: true })` BEFORE constructing the
24
+ * Agent / McpClient when `~/.config/brainrouter/config.json` is
25
+ * missing.
26
+ * 2. **`/init`** from inside the REPL — `runWizard({ ownsReadline:
27
+ * false })` reuses the REPL's existing readline.
28
+ */
29
+ const ONBOARDED_MARKER = path.join(os.homedir(), '.config', 'brainrouter', '.onboarded');
30
+ export function isOnboarded() {
31
+ try {
32
+ return fs.existsSync(ONBOARDED_MARKER);
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ export function markOnboarded() {
39
+ try {
40
+ fs.mkdirSync(path.dirname(ONBOARDED_MARKER), { recursive: true });
41
+ fs.writeFileSync(ONBOARDED_MARKER, '', 'utf8');
42
+ }
43
+ catch {
44
+ /* non-fatal */
45
+ }
46
+ }
47
+ const TOTAL_STEPS = 6; // theme, provider, apiKey, model, mcp, agentMd
48
+ function progressBadge(step) {
49
+ const decisionSteps = ['theme', 'provider', 'apiKey', 'model', 'mcp', 'agentMd'];
50
+ const idx = decisionSteps.indexOf(step);
51
+ if (idx < 0)
52
+ return undefined;
53
+ return `Step ${idx + 1} of ${TOTAL_STEPS}`;
54
+ }
55
+ export async function runWizard(opts) {
56
+ if (opts.ownsReadline && !process.stdin.isTTY) {
57
+ throw new NoTTYError('BrainRouter has no config and stdin is not a TTY — run `brainrouter` in an interactive terminal at least once to complete the setup wizard.');
58
+ }
59
+ let state = initWizardState();
60
+ let theme = buildTheme('dark');
61
+ while (!state.committed && !state.aborted) {
62
+ const before = state.draft.theme;
63
+ state = await runStep(state, opts, theme);
64
+ if (state.draft.theme && state.draft.theme !== before) {
65
+ theme = buildTheme(state.draft.theme);
66
+ }
67
+ }
68
+ let savedConfig;
69
+ if (state.committed) {
70
+ savedConfig = commitWizardDraft(state.draft, opts.workspaceRoot);
71
+ markOnboarded();
72
+ renderDoneSummary(state, savedConfig, theme);
73
+ }
74
+ else if (state.aborted) {
75
+ process.stdout.write(theme.warning('\n Wizard aborted — no changes saved.\n\n'));
76
+ }
77
+ return { state, config: savedConfig };
78
+ }
79
+ async function runStep(state, opts, theme) {
80
+ switch (state.currentStep) {
81
+ case 'welcome': return runWelcomeStep(state, theme);
82
+ case 'theme': return runThemeStep(state, opts.workspaceRoot);
83
+ case 'provider': return runProviderStep(state, theme);
84
+ case 'apiKey': return runApiKeyStep(state, theme);
85
+ case 'model': return runModelStep(state, theme);
86
+ case 'mcp': return runMcpStep(state, theme);
87
+ case 'agentMd': return runAgentMdStep(state, opts.workspaceRoot, theme);
88
+ case 'done': return reduceWizard(state, { kind: 'commit' });
89
+ }
90
+ }
91
+ // --- Welcome -----------------------------------------------------------
92
+ async function runWelcomeStep(state, theme) {
93
+ const result = await pickFromList({
94
+ theme,
95
+ title: '🧠 BrainRouter',
96
+ subtitle: 'A memory-native coding agent that runs in your terminal. This wizard takes ~60 seconds and writes to ~/.config/brainrouter/config.json plus <workspace>/.brainrouter/cli/preferences.json. Press ENTER to start, q to abort.',
97
+ rows: [
98
+ { id: 'start', label: 'Start setup', description: 'Theme → Provider → API key → Model → MCP → AGENT.md' },
99
+ { id: 'abort', label: 'Abort', description: 'Exit without saving anything' },
100
+ ],
101
+ badge: 'Welcome',
102
+ eraseOnClose: true,
103
+ });
104
+ if (result.kind !== 'pick' || result.id === 'abort')
105
+ return reduceWizard(state, { kind: 'abort' });
106
+ return reduceWizard(state, { kind: 'advance', patch: {} });
107
+ }
108
+ // --- Theme -------------------------------------------------------------
109
+ async function runThemeStep(state, workspaceRoot) {
110
+ const themes = [
111
+ { id: 'dark', label: 'Dark', description: 'Default · saturated accents on a black terminal' },
112
+ { id: 'light', label: 'Light', description: 'Darker accents for white terminals (solarized-light, GitHub light)' },
113
+ { id: 'mono', label: 'Mono', description: 'No color · screenshots, CI logs, pipe-to-less' },
114
+ ];
115
+ const result = await pickFromList({
116
+ theme: buildTheme('dark'),
117
+ title: 'Theme',
118
+ subtitle: 'Pick a color palette. Arrow keys live-preview the prompt accent inside this panel.',
119
+ badge: progressBadge('theme'),
120
+ rows: themes.map((t) => ({ id: t.id, label: t.label, description: t.description })),
121
+ initialCursor: 0,
122
+ onCursorChange: (id) => {
123
+ const preview = buildTheme(id);
124
+ return [
125
+ preview.muted('preview › ') + preview.primary('brainrouter>') + ' ' + preview.heading('sample prompt') + ' ' + preview.muted('with ') + preview.success('success') + preview.muted(' and ') + preview.danger('danger') + preview.muted(' accents'),
126
+ ];
127
+ },
128
+ eraseOnClose: true,
129
+ });
130
+ if (result.kind !== 'pick')
131
+ return reduceWizard(state, { kind: 'abort' });
132
+ const mode = result.id;
133
+ try {
134
+ writePreferences(workspaceRoot, { theme: mode });
135
+ }
136
+ catch { /* non-fatal */ }
137
+ return reduceWizard(state, { kind: 'advance', patch: { theme: mode } });
138
+ }
139
+ // --- Provider ----------------------------------------------------------
140
+ async function runProviderStep(state, theme) {
141
+ const detected = detectProviderFromEnv();
142
+ const rows = PROVIDER_CATALOG.map((p) => {
143
+ const envHit = !!process.env[p.envKey];
144
+ const status = envHit ? 'env detected' : p.local ? 'local · key optional' : 'needs API key';
145
+ return {
146
+ id: p.id,
147
+ label: p.label,
148
+ value: status,
149
+ description: p.hint,
150
+ };
151
+ });
152
+ const initialCursor = detected
153
+ ? Math.max(0, PROVIDER_CATALOG.findIndex((p) => p.id === detected.id))
154
+ : 0;
155
+ const result = await pickFromList({
156
+ theme,
157
+ title: 'LLM provider',
158
+ subtitle: detected
159
+ ? `Detected ${detected.envKey} in your shell — ${detected.label} is pre-selected. Pick "Other" to enter a custom OpenAI-compatible endpoint.`
160
+ : 'Pick the LLM provider for the chat agent. Pick "Other" to enter a custom OpenAI-compatible endpoint.',
161
+ badge: progressBadge('provider'),
162
+ rows,
163
+ initialCursor,
164
+ allowOther: true,
165
+ otherLabel: 'Other endpoint',
166
+ otherDescription: 'OpenAI-compatible /v1/chat/completions URL',
167
+ eraseOnClose: true,
168
+ });
169
+ if (result.kind === 'cancelled')
170
+ return reduceWizard(state, { kind: 'abort' });
171
+ if (result.kind === 'other') {
172
+ const url = result.text;
173
+ if (!url)
174
+ return state;
175
+ const ad = {
176
+ id: 'custom',
177
+ label: 'Custom endpoint',
178
+ hint: url,
179
+ endpoint: url,
180
+ envKey: 'BRAINROUTER_LLM_API_KEY',
181
+ local: /localhost|127\.0\.0\.1|::1|0\.0\.0\.0/.test(url),
182
+ models: [],
183
+ defaultModel: 'gpt-4o-mini',
184
+ };
185
+ return reduceWizard(state, {
186
+ kind: 'advance',
187
+ patch: { provider: ad, customEndpoint: url },
188
+ });
189
+ }
190
+ const provider = PROVIDER_CATALOG.find((p) => p.id === result.id);
191
+ if (!provider)
192
+ return state;
193
+ return reduceWizard(state, { kind: 'advance', patch: { provider } });
194
+ }
195
+ // --- API key -----------------------------------------------------------
196
+ async function runApiKeyStep(state, theme) {
197
+ const provider = state.draft.provider;
198
+ if (!provider)
199
+ return reduceWizard(state, { kind: 'back' });
200
+ const envValue = process.env[provider.envKey] ?? '';
201
+ const subtitle = envValue
202
+ ? `${provider.envKey} is set in your shell — press ENTER to accept, or type a different key.`
203
+ : provider.local
204
+ ? `${provider.label} is local — a blank API key is fine (just press ENTER).`
205
+ : `Paste your ${provider.label} API key. Stored at ~/.config/brainrouter/config.json.`;
206
+ const result = await promptText({
207
+ theme,
208
+ title: 'API key',
209
+ subtitle,
210
+ badge: `${progressBadge('apiKey')} · ${provider.label}`,
211
+ prefilled: envValue,
212
+ mask: false, // we mask on display in the summary; while typing the user benefits from seeing chars
213
+ placeholder: provider.local ? '(blank OK for local endpoints)' : 'paste your API key here',
214
+ validate: (raw) => {
215
+ const verdict = validateApiKey(raw, provider);
216
+ if (verdict.kind === 'reject')
217
+ return verdict.reason;
218
+ return undefined;
219
+ },
220
+ eraseOnClose: true,
221
+ });
222
+ if (result.kind === 'cancelled')
223
+ return reduceWizard(state, { kind: 'abort' });
224
+ const key = result.text;
225
+ const verdict = validateApiKey(key, provider);
226
+ let next = state;
227
+ if (verdict.kind === 'accept' && verdict.warning) {
228
+ next = reduceWizard(next, { kind: 'warn', message: verdict.warning });
229
+ }
230
+ return reduceWizard(next, { kind: 'advance', patch: { apiKey: key } });
231
+ }
232
+ // --- Model -------------------------------------------------------------
233
+ async function runModelStep(state, theme) {
234
+ const provider = state.draft.provider;
235
+ if (!provider)
236
+ return reduceWizard(state, { kind: 'back' });
237
+ // Wizard delegates to the shared `selectModel` so the in-REPL
238
+ // `/model` quick-swap and onboarding pick from the same UI. The
239
+ // wizard wraps the picker's "current model" semantic differently:
240
+ // here there's no current model yet (we're CREATING the config),
241
+ // so we pass undefined and the helper opens the cursor on the
242
+ // provider default. `eraseOnClose: true` keeps the wizard's frame
243
+ // hygiene (each step blanks itself before the next renders).
244
+ const result = await selectModel({
245
+ theme,
246
+ provider,
247
+ apiKey: state.draft.apiKey ?? '',
248
+ endpointOverride: state.draft.customEndpoint,
249
+ title: 'Model',
250
+ badge: progressBadge('model'),
251
+ eraseOnClose: true,
252
+ });
253
+ if (!result)
254
+ return reduceWizard(state, { kind: 'abort' });
255
+ return reduceWizard(state, { kind: 'advance', patch: { model: result.model || provider.defaultModel } });
256
+ }
257
+ // --- MCP ---------------------------------------------------------------
258
+ async function runMcpStep(state, theme) {
259
+ const rows = [
260
+ { id: 'local-stdio', label: 'Local stdio', value: 'spawn brainrouter-mcp', description: 'No HTTP server needed — the CLI spawns the MCP child', pick: { kind: 'local-stdio' } },
261
+ { id: 'local-http', label: 'Local HTTP', value: 'http://localhost:3747', description: 'Connect to a brainrouter-mcp HTTP server running locally', pick: { kind: 'local-http' } },
262
+ { id: 'remote-http', label: 'Remote HTTP', value: 'custom URL', description: 'Connect to a hosted BrainRouter MCP (URL + optional key)', pick: { kind: 'remote-http', url: '' } },
263
+ { id: 'skip', label: 'Skip', value: 'no MCP', description: 'Local tools only · no recall, skills, or capture', pick: { kind: 'skip' } },
264
+ ];
265
+ const result = await pickFromList({
266
+ theme,
267
+ title: 'MCP server',
268
+ subtitle: 'BrainRouter\'s memory + skills live behind an MCP server. Pick how to reach it.',
269
+ badge: progressBadge('mcp'),
270
+ rows,
271
+ initialCursor: 0,
272
+ eraseOnClose: true,
273
+ });
274
+ if (result.kind === 'cancelled')
275
+ return reduceWizard(state, { kind: 'abort' });
276
+ if (result.kind !== 'pick')
277
+ return state;
278
+ const picked = rows.find((r) => r.id === result.id)?.pick;
279
+ if (!picked)
280
+ return state;
281
+ let final = picked;
282
+ if (final.kind === 'remote-http') {
283
+ const urlResult = await promptText({
284
+ theme,
285
+ title: 'Remote MCP URL',
286
+ subtitle: 'Paste the full URL (e.g. https://brainrouter.example.com/mcp). Press Esc to back out.',
287
+ badge: 'MCP',
288
+ prefilled: '',
289
+ placeholder: 'https://...',
290
+ validate: (raw) => {
291
+ const v = raw.trim();
292
+ if (!v)
293
+ return 'URL is required';
294
+ try {
295
+ new URL(v);
296
+ }
297
+ catch {
298
+ return 'not a valid URL';
299
+ }
300
+ return undefined;
301
+ },
302
+ eraseOnClose: true,
303
+ });
304
+ if (urlResult.kind === 'cancelled')
305
+ return reduceWizard(state, { kind: 'abort' });
306
+ final = { kind: 'remote-http', url: urlResult.text.trim() };
307
+ }
308
+ // 0.3.7 — collect the BrainRouter MCP API key for any HTTP transport
309
+ // (local OR remote). Pre-fill from BRAINROUTER_API_KEY env. Blank
310
+ // submission is OK — servers without auth accept empty bearers.
311
+ if (final.kind === 'local-http' || final.kind === 'remote-http') {
312
+ const envValue = process.env.BRAINROUTER_API_KEY ?? '';
313
+ const keyResult = await promptText({
314
+ theme,
315
+ title: 'BrainRouter API key',
316
+ subtitle: envValue
317
+ ? 'BRAINROUTER_API_KEY is set — press ENTER to accept, type to override, or blank if the server is unauthenticated.'
318
+ : final.kind === 'local-http'
319
+ ? 'Optional — leave blank if your local brainrouter-mcp HTTP server runs without auth.'
320
+ : 'Optional — leave blank if the hosted MCP doesn\'t require auth. Use the key issued by the BrainRouter dashboard.',
321
+ badge: 'MCP',
322
+ prefilled: envValue,
323
+ placeholder: '(blank OK)',
324
+ eraseOnClose: true,
325
+ });
326
+ if (keyResult.kind === 'cancelled')
327
+ return reduceWizard(state, { kind: 'abort' });
328
+ const apiKey = keyResult.text.trim() || undefined;
329
+ final = final.kind === 'local-http'
330
+ ? { kind: 'local-http', apiKey }
331
+ : { kind: 'remote-http', url: final.url, apiKey };
332
+ }
333
+ const probe = await probeMcp(final, state.draft);
334
+ if (probe.warning) {
335
+ const next = reduceWizard(state, { kind: 'warn', message: probe.warning });
336
+ return reduceWizard(next, { kind: 'advance', patch: { mcp: final } });
337
+ }
338
+ return reduceWizard(state, { kind: 'advance', patch: { mcp: final } });
339
+ }
340
+ async function probeMcp(pick, draft) {
341
+ if (pick.kind === 'skip')
342
+ return { ok: true };
343
+ const wrapper = new McpClientWrapper();
344
+ const llmConfig = draft.provider && draft.model
345
+ ? { provider: 'openai', apiKey: draft.apiKey ?? '', model: draft.model, endpoint: draft.customEndpoint ?? draft.provider.endpoint }
346
+ : undefined;
347
+ const serverConfig = mcpPickToServerConfig(pick);
348
+ if (!serverConfig)
349
+ return { ok: false, warning: 'Could not build MCP server config for this pick.' };
350
+ try {
351
+ await Promise.race([
352
+ wrapper.connect(serverConfig, llmConfig, 'wizard'),
353
+ new Promise((_, reject) => setTimeout(() => reject(new Error('probe timed out after 5s')), 5_000)),
354
+ ]);
355
+ await wrapper.close();
356
+ return { ok: true };
357
+ }
358
+ catch (err) {
359
+ try {
360
+ await wrapper.close();
361
+ }
362
+ catch { /* ignore */ }
363
+ return {
364
+ ok: false,
365
+ warning: `MCP probe failed (${err?.message ?? err}). Profile saved — start the server and run /mcp reconnect later.`,
366
+ };
367
+ }
368
+ }
369
+ function mcpPickToServerConfig(pick) {
370
+ if (pick.kind === 'local-stdio') {
371
+ return { type: 'stdio', command: 'brainrouter-mcp', args: [], identity: 'brainrouter' };
372
+ }
373
+ if (pick.kind === 'local-http') {
374
+ return { type: 'http', url: 'http://localhost:3747/mcp', apiKey: pick.apiKey, identity: 'brainrouter' };
375
+ }
376
+ if (pick.kind === 'remote-http') {
377
+ return { type: 'http', url: pick.url, apiKey: pick.apiKey, identity: 'brainrouter' };
378
+ }
379
+ return undefined;
380
+ }
381
+ // --- AGENT.md ----------------------------------------------------------
382
+ async function runAgentMdStep(state, workspaceRoot, theme) {
383
+ const agentMdPath = path.join(workspaceRoot, 'AGENT.md');
384
+ const claudeMdPath = path.join(workspaceRoot, 'CLAUDE.md');
385
+ const exists = fs.existsSync(agentMdPath) || fs.existsSync(claudeMdPath);
386
+ const result = await pickFromList({
387
+ theme,
388
+ title: 'AGENT.md',
389
+ subtitle: exists
390
+ ? 'Workspace already has AGENT.md / CLAUDE.md — skipping by default. Pick "Overwrite" only if you really want to replace it.'
391
+ : 'AGENT.md gives every coding agent (Claude Code, Codex, BrainRouter, …) a single hub of repo conventions. Recommended.',
392
+ badge: progressBadge('agentMd'),
393
+ rows: exists
394
+ ? [
395
+ { id: 'skip', label: 'Skip', value: 'keep existing file', description: 'Leave the current AGENT.md / CLAUDE.md alone' },
396
+ { id: 'write', label: 'Overwrite', value: 'replace contents', description: 'Drop the starter template over the existing file' },
397
+ ]
398
+ : [
399
+ { id: 'write', label: 'Write AGENT.md', value: 'recommended', description: 'Scaffold a starter template in the workspace root' },
400
+ { id: 'skip', label: 'Skip', value: 'no file', description: 'Write AGENT.md manually later' },
401
+ ],
402
+ initialCursor: 0,
403
+ eraseOnClose: true,
404
+ });
405
+ if (result.kind === 'cancelled')
406
+ return reduceWizard(state, { kind: 'abort' });
407
+ if (result.kind !== 'pick')
408
+ return state;
409
+ return reduceWizard(state, {
410
+ kind: 'advance',
411
+ patch: { writeAgentMd: result.id === 'write' },
412
+ });
413
+ }
414
+ // --- Commit + summary --------------------------------------------------
415
+ function commitWizardDraft(draft, workspaceRoot) {
416
+ const config = loadOrInitConfig();
417
+ if (draft.provider) {
418
+ config.llm = {
419
+ provider: 'openai',
420
+ apiKey: draft.apiKey ?? '',
421
+ model: draft.model ?? draft.provider.defaultModel,
422
+ endpoint: draft.customEndpoint ?? draft.provider.endpoint,
423
+ };
424
+ }
425
+ if (draft.mcp && draft.mcp.kind !== 'skip') {
426
+ const profileName = draft.mcp.kind === 'remote-http' ? 'remote' : draft.mcp.kind === 'local-http' ? 'local-http' : 'local-stdio';
427
+ const serverConfig = mcpPickToServerConfig(draft.mcp);
428
+ if (serverConfig) {
429
+ config.servers[profileName] = serverConfig;
430
+ config.activeServer = profileName;
431
+ }
432
+ }
433
+ else if (draft.mcp?.kind === 'skip') {
434
+ // Skip means skip — clear any previously-active profile so the CLI doesn't
435
+ // silently re-spawn an MCP child from a stale config. The user can re-add
436
+ // a profile via `/login` later.
437
+ config.activeServer = '';
438
+ }
439
+ saveConfig(config);
440
+ if (draft.theme) {
441
+ try {
442
+ writePreferences(workspaceRoot, { theme: draft.theme });
443
+ }
444
+ catch { /* non-fatal */ }
445
+ }
446
+ if (draft.writeAgentMd) {
447
+ try {
448
+ initAgentMd(workspaceRoot);
449
+ }
450
+ catch { /* non-fatal */ }
451
+ }
452
+ return config;
453
+ }
454
+ function renderDoneSummary(state, _config, theme) {
455
+ const lines = [
456
+ '',
457
+ theme.heading(' ✓ Setup complete'),
458
+ '',
459
+ ` ${theme.muted('theme')} ${theme.plain(state.draft.theme ?? 'dark')}`,
460
+ ` ${theme.muted('provider')} ${theme.plain(state.draft.provider?.label ?? '(unset)')}`,
461
+ ` ${theme.muted('model')} ${theme.plain(state.draft.model ?? '(unset)')}`,
462
+ ` ${theme.muted('api key')} ${theme.plain(maskApiKey(state.draft.apiKey ?? ''))}`,
463
+ ` ${theme.muted('mcp')} ${theme.plain(formatMcpForSummary(state.draft.mcp))}`,
464
+ ` ${theme.muted('agent.md')} ${theme.plain(state.draft.writeAgentMd ? 'written' : 'skipped')}`,
465
+ '',
466
+ theme.muted(' Config saved to ~/.config/brainrouter/config.json.'),
467
+ theme.muted(' Re-run any time with /init. Tweak individual knobs with /config.'),
468
+ ];
469
+ if (state.warnings.length > 0) {
470
+ lines.push('');
471
+ lines.push(theme.warning(' Advisories:'));
472
+ for (const w of state.warnings) {
473
+ lines.push(` ${theme.warning('!')} ${w.message}`);
474
+ }
475
+ }
476
+ process.stdout.write(lines.join('\n') + '\n\n');
477
+ }
478
+ function formatMcpForSummary(pick) {
479
+ if (!pick)
480
+ return '(unset)';
481
+ if (pick.kind === 'local-stdio')
482
+ return 'local stdio (brainrouter-mcp)';
483
+ if (pick.kind === 'local-http')
484
+ return 'local http (http://localhost:3747/mcp)';
485
+ if (pick.kind === 'remote-http')
486
+ return `remote · ${pick.url}`;
487
+ return 'skipped (offline-only)';
488
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * 0.3.7 wizard — pure types + step state machine.
3
+ *
4
+ * The wizard walks the user through a small, ordered sequence of
5
+ * decisions. Each step has its own decision shape; together they fill
6
+ * in a `WizardDraft` that the Done step commits to disk.
7
+ *
8
+ * Why a typed Step enum + draft (instead of one giant async function
9
+ * with awaits in sequence)? Three reasons:
10
+ *
11
+ * 1. **Esc backs out one step at a time.** A reducer transition lets
12
+ * us model "back" cleanly (Step.Provider → Step.Theme) without
13
+ * unwinding an async stack.
14
+ * 2. **The runner is testable.** Driving the reducer with synthetic
15
+ * events (`pick`, `back`, `abort`) lets us assert the wizard ends
16
+ * in a known terminal state without simulating a real TTY.
17
+ * 3. **The shape lifts straight from peer references.**
18
+ * `openSrc/codex/codex-rs/tui/src/onboarding/onboarding_screen.rs`
19
+ * uses the same Step enum + per-step state pattern; we copy the
20
+ * pattern, not the code.
21
+ */
22
+ import type { ThemeMode } from '../theme.js';
23
+ import type { ProviderEntry } from './providers.js';
24
+ export type Step = 'welcome' | 'theme' | 'provider' | 'apiKey' | 'model' | 'mcp' | 'agentMd' | 'done';
25
+ /** Ordered list — used by the runner to compute "next" and "previous". */
26
+ export declare const STEP_ORDER: readonly Step[];
27
+ /**
28
+ * MCP transport pick. `skip` means "no MCP this session — local tools
29
+ * only". Useful for users who want to try the agent before standing up
30
+ * the brain. Matches the existing OFFLINE MODE behaviour the REPL
31
+ * already handles.
32
+ */
33
+ export type McpPick = {
34
+ kind: 'local-stdio';
35
+ } | {
36
+ kind: 'local-http';
37
+ apiKey?: string;
38
+ } | {
39
+ kind: 'remote-http';
40
+ url: string;
41
+ apiKey?: string;
42
+ } | {
43
+ kind: 'skip';
44
+ };
45
+ /**
46
+ * Accumulated wizard state. Every step writes into this draft; only
47
+ * the final commit phase touches disk. Aborting via `q` discards the
48
+ * draft so a half-finished wizard never leaves a partial config.
49
+ */
50
+ export interface WizardDraft {
51
+ theme?: ThemeMode;
52
+ provider?: ProviderEntry;
53
+ /** Endpoint override — set when the user picks "Custom…" from the picker. */
54
+ customEndpoint?: string;
55
+ apiKey?: string;
56
+ model?: string;
57
+ mcp?: McpPick;
58
+ writeAgentMd?: boolean;
59
+ }
60
+ /**
61
+ * Non-fatal advisories the runner accumulates so the Done step can
62
+ * surface them all at once ("you accepted an unusual key prefix" /
63
+ * "MCP probe failed — saved anyway"). Better than printing each
64
+ * warning at the time it's generated and losing it under the next
65
+ * picker redraw.
66
+ */
67
+ export interface WizardWarning {
68
+ step: Step;
69
+ message: string;
70
+ }
71
+ export interface WizardState {
72
+ currentStep: Step;
73
+ draft: WizardDraft;
74
+ warnings: WizardWarning[];
75
+ /** True once the Done step has committed the draft to disk. */
76
+ committed: boolean;
77
+ /** True if the user aborted via `q` / Ctrl+C — nothing was written. */
78
+ aborted: boolean;
79
+ }
80
+ /**
81
+ * Wizard events. The runner translates picker results / Esc keys into
82
+ * one of these and feeds them through `reduceWizard`. The reducer is
83
+ * pure so the test suite can drive the full state machine without
84
+ * touching the TTY.
85
+ */
86
+ export type WizardEvent = {
87
+ kind: 'advance';
88
+ patch: Partial<WizardDraft>;
89
+ } | {
90
+ kind: 'back';
91
+ } | {
92
+ kind: 'abort';
93
+ } | {
94
+ kind: 'warn';
95
+ message: string;
96
+ } | {
97
+ kind: 'commit';
98
+ };
99
+ export declare function initWizardState(): WizardState;
100
+ /**
101
+ * Compute the next step. Pure — used by `reduceWizard` and exposed for
102
+ * tests + the runner's progress indicator ("step 3 of 7").
103
+ */
104
+ export declare function nextStep(current: Step): Step | undefined;
105
+ export declare function prevStep(current: Step): Step | undefined;
106
+ /**
107
+ * Pure reducer. Every wizard transition must go through here so the
108
+ * test suite can replay the same event sequence the runner emits.
109
+ *
110
+ * Contract:
111
+ * - `advance` applies the patch into the draft and steps forward;
112
+ * a no-op when called on the Done step.
113
+ * - `back` rewinds one step; a no-op on the first step.
114
+ * - `abort` lands the wizard in a terminal state with `aborted: true`
115
+ * and the draft preserved (caller may inspect for partial intent).
116
+ * - `warn` appends an advisory; doesn't move the step pointer.
117
+ * - `commit` flips `committed: true` on the Done step only.
118
+ *
119
+ * The reducer never throws — bad inputs are silently ignored so a
120
+ * stray key event doesn't crash the wizard mid-render.
121
+ */
122
+ export declare function reduceWizard(state: WizardState, event: WizardEvent): WizardState;