@kinqs/brainrouter-cli 0.3.5 → 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.
- package/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +224 -3
- package/dist/agent/agent.js +561 -55
- package/dist/cli/banner.d.ts +80 -0
- package/dist/cli/banner.js +232 -0
- package/dist/cli/cliPrompt.d.ts +106 -0
- package/dist/cli/cliPrompt.js +314 -0
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +19 -0
- package/dist/cli/commands/mcp.js +286 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/orchestration.js +18 -0
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +202 -91
- package/dist/cli/commands/workflow.d.ts +20 -0
- package/dist/cli/commands/workflow.js +368 -51
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +65 -0
- package/dist/cli/ink/Picker.js +133 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +571 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +73 -0
- package/dist/cli/ink/toolFormat.js +180 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +64 -646
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +52 -0
- package/dist/config/config.js +89 -75
- package/dist/index.js +215 -206
- package/dist/memory/briefing.d.ts +11 -1
- package/dist/memory/briefing.js +69 -1
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +10 -1
- package/dist/orchestration/tools.js +48 -4
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +128 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +104 -13
- package/dist/runtime/mcpPool.d.ts +162 -0
- package/dist/runtime/mcpPool.js +423 -0
- package/dist/runtime/mcpUtils.d.ts +3 -1
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- package/package.json +12 -5
- package/.env.example +0 -109
|
@@ -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;
|