@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,422 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
3
+ import { Box, Text, useApp } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { Frame } from './Frame.js';
8
+ import { Picker } from './Picker.js';
9
+ import { TextField } from './TextField.js';
10
+ import { PROVIDER_CATALOG, detectProviderFromEnv, validateApiKey, maskApiKey, } from '../wizard/providers.js';
11
+ import { initWizardState, reduceWizard, } from '../wizard/types.js';
12
+ import { fetchOpenAiCompatibleModels } from '../wizard/modelsApi.js';
13
+ import { McpClientWrapper } from '../../runtime/mcpClient.js';
14
+ /**
15
+ * Ink-based wizard. Replaces the raw-stdout `runWizard` runner
16
+ * (which had compounding redraw bugs no matter how many off-by-one
17
+ * fixes we applied — Ink owns the render loop and diffs the cell
18
+ * grid, so frames never stack or creep).
19
+ *
20
+ * Driver pattern:
21
+ * - One `<WizardApp>` mounts at the top-level (`render(<WizardApp>)`).
22
+ * - It picks ONE child to render based on `state.currentStep`.
23
+ * - Each step is its own component (`<WelcomeStep>`, `<ThemeStep>`,
24
+ * etc.) that takes a `state` + `onAdvance` / `onBack` /
25
+ * `onAbort` / `onWarn` callback.
26
+ * - On terminal step (done / abort), the wizard calls
27
+ * `useApp().exit()` and `props.onFinish(state)` so the caller
28
+ * can persist + unmount.
29
+ */
30
+ const TOTAL_STEPS = 6;
31
+ function progressBadge(step) {
32
+ const decisionSteps = ['theme', 'provider', 'apiKey', 'model', 'mcp', 'agentMd'];
33
+ const idx = decisionSteps.indexOf(step);
34
+ if (idx < 0)
35
+ return undefined;
36
+ return `Step ${idx + 1} of ${TOTAL_STEPS}`;
37
+ }
38
+ const ACCENT = {
39
+ dark: '#CC9166',
40
+ light: '#A24E1F',
41
+ mono: 'white',
42
+ };
43
+ export function WizardApp({ workspaceRoot, onFinish }) {
44
+ const [state, setState] = useState(() => initWizardState());
45
+ const { exit } = useApp();
46
+ // Use refs for the callbacks so child components receive STABLE
47
+ // references across renders. Otherwise every render gives them a new
48
+ // function identity, which (a) makes their useEffect dependency
49
+ // arrays churn (re-firing preview/probe effects) and (b) prevents
50
+ // React from skipping re-renders. The stable-callback ref pattern is
51
+ // canonical for React 18/19 component composition.
52
+ const onFinishRef = useRef(onFinish);
53
+ useEffect(() => { onFinishRef.current = onFinish; });
54
+ // Notify the caller + exit Ink when the wizard reaches terminal.
55
+ // Dep on state.aborted + state.committed only — not on `state` or
56
+ // `onFinish` — so the effect fires exactly ONCE per terminal
57
+ // transition. (Earlier code depended on `state` itself, which fired
58
+ // the effect on every state update; harmless but wasteful.)
59
+ useEffect(() => {
60
+ if (state.aborted || state.committed) {
61
+ onFinishRef.current(state);
62
+ exit();
63
+ }
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [state.aborted, state.committed]);
66
+ const theme = state.draft.theme ?? 'dark';
67
+ const accent = ACCENT[theme];
68
+ const dispatchAdvance = useCallback((patch) => setState((s) => reduceWizard(s, { kind: 'advance', patch })), []);
69
+ const dispatchWarn = useCallback((message) => setState((s) => reduceWizard(s, { kind: 'warn', message })), []);
70
+ const dispatchAbort = useCallback(() => setState((s) => reduceWizard(s, { kind: 'abort' })), []);
71
+ const dispatchCommit = useCallback(() => setState((s) => reduceWizard(s, { kind: 'commit' })), []);
72
+ switch (state.currentStep) {
73
+ case 'welcome':
74
+ return _jsx(WelcomeStep, { accent: accent, onAdvance: () => dispatchAdvance({}), onAbort: dispatchAbort });
75
+ case 'theme':
76
+ return (_jsx(ThemeStep, { accent: accent, onPick: (mode) => dispatchAdvance({ theme: mode }), onAbort: dispatchAbort }));
77
+ case 'provider':
78
+ return (_jsx(ProviderStep, { accent: accent, onPick: (provider, customEndpoint) => dispatchAdvance({ provider, customEndpoint }), onAbort: dispatchAbort }));
79
+ case 'apiKey':
80
+ return (_jsx(ApiKeyStep, { accent: accent, provider: state.draft.provider, onAccept: (apiKey, warning) => {
81
+ if (warning)
82
+ dispatchWarn(warning);
83
+ dispatchAdvance({ apiKey });
84
+ }, onAbort: dispatchAbort }));
85
+ case 'model':
86
+ return (_jsx(ModelStep, { accent: accent, provider: state.draft.provider, apiKey: state.draft.apiKey ?? '', customEndpoint: state.draft.customEndpoint, onPick: (model) => dispatchAdvance({ model }), onAbort: dispatchAbort }));
87
+ case 'mcp':
88
+ return (_jsx(McpStep, { accent: accent, draft: state.draft, onAccept: (mcp, warning) => {
89
+ if (warning)
90
+ dispatchWarn(warning);
91
+ dispatchAdvance({ mcp });
92
+ }, onAbort: dispatchAbort }));
93
+ case 'agentMd':
94
+ return (_jsx(AgentMdStep, { accent: accent, workspaceRoot: workspaceRoot, onPick: (writeAgentMd) => dispatchAdvance({ writeAgentMd }), onAbort: dispatchAbort }));
95
+ case 'done':
96
+ // Commit immediately on mount; render the summary while the caller
97
+ // persists.
98
+ return _jsx(DoneStep, { state: state, accent: accent, onCommit: dispatchCommit });
99
+ }
100
+ }
101
+ // --- Steps -------------------------------------------------------------
102
+ function WelcomeStep({ accent, onAdvance, onAbort }) {
103
+ const handleResolve = (r) => {
104
+ if (r.kind === 'pick' && r.id === 'start')
105
+ onAdvance();
106
+ else
107
+ onAbort();
108
+ };
109
+ return (_jsx(Picker, { title: '\uD83E\uDDE0 BrainRouter', 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.', badge: 'Welcome', rows: [
110
+ { id: 'start', label: 'Start setup', description: 'Theme → Provider → API key → Model → MCP → AGENT.md' },
111
+ { id: 'abort', label: 'Abort', description: 'Exit without saving anything' },
112
+ ], accentColor: accent, onResolve: handleResolve }));
113
+ }
114
+ function ThemeStep({ accent, onPick, onAbort }) {
115
+ return (_jsx(Picker, { title: 'Theme', subtitle: 'Pick a color palette.', badge: progressBadge('theme'), rows: [
116
+ { id: 'dark', label: 'Dark', description: 'Default · saturated accents on a black terminal' },
117
+ { id: 'light', label: 'Light', description: 'Darker accents for white terminals (solarized-light, GitHub light)' },
118
+ { id: 'mono', label: 'Mono', description: 'No color · screenshots, CI logs, pipe-to-less' },
119
+ ], initialCursor: 0, accentColor: accent, onResolve: (r) => {
120
+ if (r.kind !== 'pick')
121
+ return onAbort();
122
+ onPick(r.id);
123
+ } }));
124
+ }
125
+ function ProviderStep({ accent, onPick, onAbort }) {
126
+ const detected = detectProviderFromEnv();
127
+ const rows = PROVIDER_CATALOG.map((p) => {
128
+ const envHit = !!process.env[p.envKey];
129
+ const status = envHit ? 'env detected' : p.local ? 'local · key optional' : 'needs API key';
130
+ return { id: p.id, label: p.label, value: status, description: p.hint };
131
+ });
132
+ const initialCursor = detected
133
+ ? Math.max(0, PROVIDER_CATALOG.findIndex((p) => p.id === detected.id))
134
+ : 0;
135
+ return (_jsx(Picker, { title: 'LLM provider', subtitle: detected
136
+ ? `Detected ${detected.envKey} in your shell — ${detected.label} is pre-selected. Pick "Other" to enter a custom OpenAI-compatible endpoint.`
137
+ : 'Pick the LLM provider for the chat agent. Pick "Other" to enter a custom OpenAI-compatible endpoint.', badge: progressBadge('provider'), rows: rows, initialCursor: initialCursor, allowOther: true, otherLabel: 'Other endpoint', otherDescription: 'OpenAI-compatible /v1/chat/completions URL', accentColor: accent, onResolve: (r) => {
138
+ if (r.kind === 'cancelled')
139
+ return onAbort();
140
+ if (r.kind === 'other') {
141
+ const url = r.text;
142
+ const custom = {
143
+ id: 'custom',
144
+ label: 'Custom endpoint',
145
+ hint: url,
146
+ endpoint: url,
147
+ envKey: 'BRAINROUTER_LLM_API_KEY',
148
+ local: /localhost|127\.0\.0\.1|::1|0\.0\.0\.0/.test(url),
149
+ models: [],
150
+ defaultModel: 'gpt-4o-mini',
151
+ };
152
+ onPick(custom, url);
153
+ return;
154
+ }
155
+ const provider = PROVIDER_CATALOG.find((p) => p.id === r.id);
156
+ if (provider)
157
+ onPick(provider);
158
+ } }));
159
+ }
160
+ function ApiKeyStep({ accent, provider, onAccept, onAbort }) {
161
+ const envValue = process.env[provider.envKey] ?? '';
162
+ const subtitle = envValue
163
+ ? `${provider.envKey} is set in your shell — press ENTER to accept, or type a different key.`
164
+ : provider.local
165
+ ? `${provider.label} is local — a blank API key is fine (just press ENTER).`
166
+ : `Paste your ${provider.label} API key. Stored at ~/.config/brainrouter/config.json.`;
167
+ return (_jsx(TextField, { title: 'API key', subtitle: subtitle, badge: `${progressBadge('apiKey')} · ${provider.label}`, prefilled: envValue, placeholder: provider.local ? '(blank OK for local endpoints)' : 'paste your API key here', accentColor: accent, validate: (raw) => {
168
+ const v = validateApiKey(raw, provider);
169
+ return v.kind === 'reject' ? v.reason : undefined;
170
+ }, onResolve: (r) => {
171
+ if (r.kind !== 'accept')
172
+ return onAbort();
173
+ const verdict = validateApiKey(r.text, provider);
174
+ const warning = verdict.kind === 'accept' ? verdict.warning : undefined;
175
+ onAccept(r.text, warning);
176
+ } }));
177
+ }
178
+ function ModelStep({ accent, provider, apiKey, customEndpoint, onPick, onAbort }) {
179
+ const [loading, setLoading] = useState(true);
180
+ const [modelsList, setModelsList] = useState(provider.models);
181
+ const [subtitleHint, setSubtitleHint] = useState(`Pick the chat model for ${provider.label}.`);
182
+ useEffect(() => {
183
+ let cancelled = false;
184
+ (async () => {
185
+ const res = await fetchOpenAiCompatibleModels(provider, apiKey, customEndpoint);
186
+ if (cancelled)
187
+ return;
188
+ if (res.ok) {
189
+ const withDefault = res.models.includes(provider.defaultModel)
190
+ ? [provider.defaultModel, ...res.models.filter((m) => m !== provider.defaultModel)]
191
+ : res.models;
192
+ setModelsList(withDefault);
193
+ setSubtitleHint(`Pick a model — ${res.models.length} returned by ${provider.label}'s /v1/models endpoint. Use "Other" to type any name.`);
194
+ }
195
+ else {
196
+ setSubtitleHint(`Pick a model. (Live list unavailable — ${res.error}. Showing curated short-list.) Use "Other" to type any name.`);
197
+ }
198
+ setLoading(false);
199
+ })();
200
+ return () => { cancelled = true; };
201
+ }, [provider, apiKey, customEndpoint]);
202
+ if (loading) {
203
+ return (_jsx(Frame, { title: 'Model', subtitle: `Fetching ${provider.label} models…`, badge: progressBadge('model'), accentColor: accent, children: _jsxs(Box, { children: [_jsx(Text, { color: "green", children: React.createElement(Spinner, { type: 'dots' }) }), _jsxs(Text, { color: "gray", children: [" loading ", provider.label, " /v1/models"] })] }) }));
204
+ }
205
+ const rows = (modelsList.length > 0 ? modelsList : [provider.defaultModel]).map((m) => ({
206
+ id: m,
207
+ label: m,
208
+ value: m === provider.defaultModel ? 'default' : '',
209
+ }));
210
+ const initialCursor = Math.max(0, modelsList.indexOf(provider.defaultModel));
211
+ return (_jsx(Picker, { title: 'Model', subtitle: subtitleHint, badge: progressBadge('model'), rows: rows, initialCursor: initialCursor, allowOther: true, otherLabel: 'Other model', otherDescription: 'Type any model name supported by this endpoint', accentColor: accent, onResolve: (r) => {
212
+ if (r.kind === 'cancelled')
213
+ return onAbort();
214
+ const model = r.kind === 'other' ? r.text.trim() : r.id;
215
+ onPick(model || provider.defaultModel);
216
+ } }));
217
+ }
218
+ function McpStep({ accent, draft, onAccept, onAbort }) {
219
+ // Stages:
220
+ // pick — top-level transport picker
221
+ // remote-url — text field for the remote http URL
222
+ // mcp-apikey — BrainRouter API key prompt (for local-http + remote-http)
223
+ // probing — spinner while the probe is in flight
224
+ const [stage, setStage] = useState('pick');
225
+ const [pendingPick, setPendingPick] = useState(undefined);
226
+ const [probeMsg, setProbeMsg] = useState('');
227
+ // 0.3.7 — kick the probe once we have the final pick (with apiKey
228
+ // already set). Shared between the post-url and post-key transitions
229
+ // so we don't have two copies of the same Promise+setStage dance.
230
+ function startProbe(pick) {
231
+ setPendingPick(pick);
232
+ setStage('probing');
233
+ setProbeMsg('contacting server (5s timeout)');
234
+ (async () => {
235
+ const probe = await probeMcp(pick, draft, (m) => setProbeMsg(m));
236
+ onAccept(pick, probe.warning);
237
+ })();
238
+ }
239
+ if (stage === 'probing' && pendingPick) {
240
+ return (_jsx(Frame, { title: 'MCP probe', subtitle: `Probing ${formatMcpForBadge(pendingPick)}…`, badge: progressBadge('mcp'), accentColor: accent, children: _jsxs(Box, { children: [_jsx(Text, { color: "green", children: React.createElement(Spinner, { type: 'dots' }) }), _jsxs(Text, { color: "gray", children: [" ", probeMsg || 'connecting…'] })] }) }));
241
+ }
242
+ if (stage === 'remote-url') {
243
+ return (_jsx(TextField, { title: 'Remote MCP URL', subtitle: 'Paste the full URL (e.g. https://brainrouter.example.com/mcp). Press Esc to back out.', badge: progressBadge('mcp'), prefilled: '', placeholder: 'https://...', accentColor: accent, validate: (raw) => {
244
+ const v = raw.trim();
245
+ if (!v)
246
+ return 'URL is required';
247
+ try {
248
+ new URL(v);
249
+ }
250
+ catch {
251
+ return 'not a valid URL';
252
+ }
253
+ return undefined;
254
+ }, onResolve: (r) => {
255
+ if (r.kind !== 'accept')
256
+ return setStage('pick');
257
+ // Carry the URL into the api-key stage; the BrainRouter MCP
258
+ // server's HTTP transport requires a Bearer token whenever
259
+ // auth is enabled, so we always offer the input (blank OK
260
+ // for servers without auth).
261
+ setPendingPick({ kind: 'remote-http', url: r.text.trim() });
262
+ setStage('mcp-apikey');
263
+ } }));
264
+ }
265
+ if (stage === 'mcp-apikey' && pendingPick) {
266
+ // 0.3.7 — added so users can input the BRAINROUTER_API_KEY during
267
+ // onboarding. Pre-fills from the env var; blank submission is
268
+ // valid (local servers without auth, dev mode).
269
+ const envValue = process.env.BRAINROUTER_API_KEY ?? '';
270
+ const isLocal = pendingPick.kind === 'local-http';
271
+ return (_jsx(TextField, { title: 'BrainRouter API key', subtitle: envValue
272
+ ? `BRAINROUTER_API_KEY is set — press ENTER to accept, type to override, or leave blank if the server is unauthenticated.`
273
+ : isLocal
274
+ ? `Optional — leave blank if your local brainrouter-mcp HTTP server runs without auth. Required when BRAINROUTER_API_KEY is set on the server side.`
275
+ : `Optional — leave blank if the hosted MCP doesn't require auth. Use the key issued by the BrainRouter dashboard (Users → Profile).`, badge: progressBadge('mcp'), prefilled: envValue, placeholder: '(blank OK)', accentColor: accent, onResolve: (r) => {
276
+ // Esc cancels the whole step back to the picker so the user
277
+ // can choose a different transport.
278
+ if (r.kind !== 'accept')
279
+ return setStage('pick');
280
+ const apiKey = r.text.trim() || undefined;
281
+ const next = pendingPick.kind === 'local-http'
282
+ ? { kind: 'local-http', apiKey }
283
+ : pendingPick.kind === 'remote-http'
284
+ ? { kind: 'remote-http', url: pendingPick.url, apiKey }
285
+ : pendingPick;
286
+ startProbe(next);
287
+ } }));
288
+ }
289
+ const rows = [
290
+ { 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' } },
291
+ { id: 'local-http', label: 'Local HTTP', value: 'http://localhost:3747', description: 'Connect to a brainrouter-mcp HTTP server running locally', pick: { kind: 'local-http' } },
292
+ { id: 'remote-http', label: 'Remote HTTP', value: 'custom URL', description: 'Connect to a hosted BrainRouter MCP (URL + API key)', pick: { kind: 'remote-http', url: '' } },
293
+ { id: 'skip', label: 'Skip', value: 'no MCP', description: 'Local tools only · no recall, skills, or capture', pick: { kind: 'skip' } },
294
+ ];
295
+ return (_jsx(Picker, { title: 'MCP server', subtitle: "BrainRouter's memory + skills live behind an MCP server. Pick how to reach it.", badge: progressBadge('mcp'), rows: rows, initialCursor: 0, accentColor: accent, onResolve: (r) => {
296
+ if (r.kind === 'cancelled')
297
+ return onAbort();
298
+ if (r.kind !== 'pick')
299
+ return;
300
+ const picked = rows.find((row) => row.id === r.id)?.pick;
301
+ if (!picked)
302
+ return;
303
+ if (picked.kind === 'remote-http') {
304
+ // URL first → then api-key stage → then probe.
305
+ setStage('remote-url');
306
+ return;
307
+ }
308
+ if (picked.kind === 'local-http') {
309
+ // Skip the URL prompt (fixed at http://localhost:3747/mcp)
310
+ // and go straight to the api-key stage.
311
+ setPendingPick(picked);
312
+ setStage('mcp-apikey');
313
+ return;
314
+ }
315
+ if (picked.kind === 'skip') {
316
+ onAccept(picked);
317
+ return;
318
+ }
319
+ // local-stdio — no api key needed (process-local auth).
320
+ startProbe(picked);
321
+ } }));
322
+ }
323
+ function formatMcpForBadge(pick) {
324
+ if (pick.kind === 'local-stdio')
325
+ return 'local stdio';
326
+ if (pick.kind === 'local-http')
327
+ return 'http://localhost:3747/mcp';
328
+ if (pick.kind === 'remote-http')
329
+ return pick.url;
330
+ return 'no MCP';
331
+ }
332
+ async function probeMcp(pick, draft, onStatus) {
333
+ if (pick.kind === 'skip')
334
+ return { ok: true };
335
+ const wrapper = new McpClientWrapper();
336
+ const llmConfig = draft.provider && draft.model
337
+ ? { provider: 'openai', apiKey: draft.apiKey ?? '', model: draft.model, endpoint: draft.customEndpoint ?? draft.provider.endpoint }
338
+ : undefined;
339
+ const serverConfig = mcpPickToServerConfig(pick);
340
+ if (!serverConfig)
341
+ return { ok: false, warning: 'Could not build MCP server config for this pick.' };
342
+ try {
343
+ onStatus('connecting…');
344
+ await Promise.race([
345
+ wrapper.connect(serverConfig, llmConfig, 'wizard'),
346
+ new Promise((_, reject) => setTimeout(() => reject(new Error('probe timed out after 5s')), 5_000)),
347
+ ]);
348
+ await wrapper.close();
349
+ return { ok: true };
350
+ }
351
+ catch (err) {
352
+ try {
353
+ await wrapper.close();
354
+ }
355
+ catch { /* ignore */ }
356
+ return {
357
+ ok: false,
358
+ warning: `MCP probe failed (${err?.message ?? err}). Profile saved — start the server and run /mcp reconnect later.`,
359
+ };
360
+ }
361
+ }
362
+ function mcpPickToServerConfig(pick) {
363
+ if (pick.kind === 'local-stdio') {
364
+ return { type: 'stdio', command: 'brainrouter-mcp', args: [], identity: 'brainrouter' };
365
+ }
366
+ if (pick.kind === 'local-http') {
367
+ return { type: 'http', url: 'http://localhost:3747/mcp', apiKey: pick.apiKey, identity: 'brainrouter' };
368
+ }
369
+ if (pick.kind === 'remote-http') {
370
+ return { type: 'http', url: pick.url, apiKey: pick.apiKey, identity: 'brainrouter' };
371
+ }
372
+ return undefined;
373
+ }
374
+ function AgentMdStep({ accent, workspaceRoot, onPick, onAbort }) {
375
+ const agentMdPath = path.join(workspaceRoot, 'AGENT.md');
376
+ const claudeMdPath = path.join(workspaceRoot, 'CLAUDE.md');
377
+ const exists = fs.existsSync(agentMdPath) || fs.existsSync(claudeMdPath);
378
+ const rows = exists
379
+ ? [
380
+ { id: 'skip', label: 'Skip', value: 'keep existing file', description: 'Leave the current AGENT.md / CLAUDE.md alone' },
381
+ { id: 'overwrite', label: 'Overwrite', value: 'replace contents', description: 'Drop the starter template over the existing file' },
382
+ ]
383
+ : [
384
+ { id: 'write', label: 'Write AGENT.md', value: 'recommended', description: 'Scaffold a starter template in the workspace root' },
385
+ { id: 'skip', label: 'Skip', value: 'no file', description: 'Write AGENT.md manually later' },
386
+ ];
387
+ return (_jsx(Picker, { title: 'AGENT.md', subtitle: exists
388
+ ? 'Workspace already has AGENT.md / CLAUDE.md — skipping by default. Pick "Overwrite" only if you really want to replace it.'
389
+ : 'AGENT.md gives every coding agent (Claude Code, Codex, BrainRouter, …) a single hub of repo conventions. Recommended.', badge: progressBadge('agentMd'), rows: rows, initialCursor: 0, accentColor: accent, onResolve: (r) => {
390
+ if (r.kind === 'cancelled')
391
+ return onAbort();
392
+ if (r.kind !== 'pick')
393
+ return;
394
+ onPick(r.id === 'write' || r.id === 'overwrite');
395
+ } }));
396
+ }
397
+ function DoneStep({ state, accent, onCommit }) {
398
+ useEffect(() => {
399
+ onCommit();
400
+ }, [onCommit]);
401
+ return (_jsx(Frame, { title: '\u2713 Setup complete', badge: 'Done', accentColor: accent, children: _jsxs(Box, { flexDirection: 'column', children: [_jsx(SummaryRow, { label: 'theme', value: state.draft.theme ?? 'dark' }), _jsx(SummaryRow, { label: 'provider', value: state.draft.provider?.label ?? '(unset)' }), _jsx(SummaryRow, { label: 'model', value: state.draft.model ?? '(unset)' }), _jsx(SummaryRow, { label: 'api key', value: maskApiKey(state.draft.apiKey ?? '') }), _jsx(SummaryRow, { label: 'mcp', value: formatMcpSummary(state.draft.mcp) }), _jsx(SummaryRow, { label: 'agent.md', value: state.draft.writeAgentMd ? 'written' : 'skipped' }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Config saved to ~/.config/brainrouter/config.json. Re-run any time with /init. Tweak individual knobs with /config." }) }), state.warnings.length > 0 ? (_jsxs(Box, { flexDirection: 'column', marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "Advisories:" }), state.warnings.map((w, i) => (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: " ! " }), _jsx(Text, { children: w.message })] }, i)))] })) : null] }) }));
402
+ }
403
+ function SummaryRow({ label, value }) {
404
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 10, children: _jsx(Text, { color: "gray", children: label }) }), _jsx(Text, { children: value })] }));
405
+ }
406
+ function formatMcpSummary(pick) {
407
+ if (!pick)
408
+ return '(unset)';
409
+ if (pick.kind === 'local-stdio')
410
+ return 'local stdio (brainrouter-mcp)';
411
+ if (pick.kind === 'local-http') {
412
+ return pick.apiKey
413
+ ? `local http (http://localhost:3747/mcp) · key ${maskApiKey(pick.apiKey)}`
414
+ : 'local http (http://localhost:3747/mcp) · no key';
415
+ }
416
+ if (pick.kind === 'remote-http') {
417
+ return pick.apiKey
418
+ ? `remote · ${pick.url} · key ${maskApiKey(pick.apiKey)}`
419
+ : `remote · ${pick.url} · no key`;
420
+ }
421
+ return 'skipped (offline-only)';
422
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactElement } from 'react';
2
+ /**
3
+ * Process-wide reference to the chat REPL's ChatController, set when
4
+ * `runChat` mounts and cleared when it unmounts. Lets `runPicker` /
5
+ * `runTextField` detect "I'm being called from inside the Ink chat
6
+ * REPL" and route their UI through the chat's overlay slot instead of
7
+ * mounting a SECOND Ink instance on the same stdin.
8
+ *
9
+ * Why a module-level ref rather than React context: the call site is
10
+ * `cli/commands/config.ts` -> `runHomePanel(ctx)` -> `runPicker(opts)`,
11
+ * which is a plain async function chain — there's no React tree at the
12
+ * dispatch entry. The ChatController lives inside the chat React tree
13
+ * but the slash-command dispatcher is outside it. A module-level
14
+ * ambient binding bridges the two without forcing every dispatcher to
15
+ * thread a controller argument through every command handler.
16
+ *
17
+ * Mutual exclusion: only ONE chat instance should ever be mounted at a
18
+ * time, so a single global is sufficient. setAmbientChat(undefined)
19
+ * MUST be called on unmount, otherwise a later standalone runPicker
20
+ * call would try to render into a dead controller.
21
+ */
22
+ export interface AmbientChatController {
23
+ /**
24
+ * Render `node` as an overlay above the chat composer. The promise
25
+ * resolves when `clearOverlay()` is called. Callers typically wire
26
+ * `<Picker onResolve={...}>` to a callback that resolves the outer
27
+ * promise and immediately calls clearOverlay().
28
+ */
29
+ showOverlay(node: ReactElement): Promise<void>;
30
+ /** Remove whatever overlay is currently shown; safe to call when none is set. */
31
+ clearOverlay(): void;
32
+ }
33
+ export declare function setAmbientChat(controller: AmbientChatController | undefined): void;
34
+ export declare function getAmbientChat(): AmbientChatController | undefined;
@@ -0,0 +1,7 @@
1
+ let ambient;
2
+ export function setAmbientChat(controller) {
3
+ ambient = controller;
4
+ }
5
+ export function getAmbientChat() {
6
+ return ambient;
7
+ }
@@ -0,0 +1,11 @@
1
+ export interface CapturedConsole<T> {
2
+ result: T;
3
+ output: string;
4
+ }
5
+ /**
6
+ * Legacy slash-command handlers still write through console.log/warn/error.
7
+ * In the Ink chat shell, letting those writes escape makes Ink promote them
8
+ * above the live frame, which visually places command output before the
9
+ * BrainRouter banner. Capture the writes and replay them into scrollback.
10
+ */
11
+ export declare function captureConsoleOutput<T>(fn: () => Promise<T> | T): Promise<CapturedConsole<T>>;
@@ -0,0 +1,33 @@
1
+ import { format } from 'node:util';
2
+ /**
3
+ * Legacy slash-command handlers still write through console.log/warn/error.
4
+ * In the Ink chat shell, letting those writes escape makes Ink promote them
5
+ * above the live frame, which visually places command output before the
6
+ * BrainRouter banner. Capture the writes and replay them into scrollback.
7
+ */
8
+ export async function captureConsoleOutput(fn) {
9
+ const originals = {
10
+ log: console.log,
11
+ warn: console.warn,
12
+ error: console.error,
13
+ info: console.info,
14
+ };
15
+ let output = '';
16
+ const append = (...args) => {
17
+ output += format(...args) + '\n';
18
+ };
19
+ console.log = append;
20
+ console.warn = append;
21
+ console.error = append;
22
+ console.info = append;
23
+ try {
24
+ const result = await fn();
25
+ return { result, output };
26
+ }
27
+ finally {
28
+ console.log = originals.log;
29
+ console.warn = originals.warn;
30
+ console.error = originals.error;
31
+ console.info = originals.info;
32
+ }
33
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Render markdown source to ANSI-styled terminal text suitable for an
3
+ * Ink `<Text>` element. Idempotent across calls (configures `marked`
4
+ * lazily on the first invocation).
5
+ *
6
+ * Empty / non-string input returns the input verbatim.
7
+ */
8
+ export declare function renderMarkdown(source: string): string;
9
+ /**
10
+ * Strip a single outer ``` markdown / ``` md fence pair when it wraps the
11
+ * entire input. Some LLMs (especially when asked to "format your reply
12
+ * in markdown") emit the whole response inside ``` markdown ... ``` —
13
+ * which then renders as a single yellow code block instead of formatted
14
+ * text. Direct port of codex's helper (markdown.rs:86–123).
15
+ *
16
+ * Also strips fences around tables — LLMs sometimes wrap tables in ``` md
17
+ * to "protect" the pipe characters, but marked then renders the table
18
+ * as code instead of as a native table.
19
+ *
20
+ * Exported for tests; the renderMarkdown caller chains this in.
21
+ */
22
+ export declare function unwrapMarkdownFences(source: string): string;
23
+ /**
24
+ * Re-scope ANSI styling across newline boundaries so each rendered line
25
+ * carries its own complete open/close pair.
26
+ *
27
+ * Walks the input as a stream of segments — plain text, ANSI SGR
28
+ * sequences, and `\n` — maintaining a small state machine of active
29
+ * styles (foreground color, background color, set of attribute flags).
30
+ * At every `\n`, emit a "close everything currently open" sequence,
31
+ * then the newline, then a "reopen everything that was open" sequence.
32
+ *
33
+ * Edge cases handled:
34
+ * - 256-color (38;5;N) and truecolor (38;2;R;G;B) sequences — treated
35
+ * as opaque opening codes, replayed verbatim
36
+ * - `0` / empty params reset all state
37
+ * - already-empty active state at a newline → emit just the newline
38
+ *
39
+ * Exported for tests.
40
+ */
41
+ export declare function preserveAnsiAcrossNewlines(text: string): string;