@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,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,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;
|