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