@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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.3.7 wizard — pure types + step state machine.
|
|
3
|
+
*
|
|
4
|
+
* The wizard walks the user through a small, ordered sequence of
|
|
5
|
+
* decisions. Each step has its own decision shape; together they fill
|
|
6
|
+
* in a `WizardDraft` that the Done step commits to disk.
|
|
7
|
+
*
|
|
8
|
+
* Why a typed Step enum + draft (instead of one giant async function
|
|
9
|
+
* with awaits in sequence)? Three reasons:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Esc backs out one step at a time.** A reducer transition lets
|
|
12
|
+
* us model "back" cleanly (Step.Provider → Step.Theme) without
|
|
13
|
+
* unwinding an async stack.
|
|
14
|
+
* 2. **The runner is testable.** Driving the reducer with synthetic
|
|
15
|
+
* events (`pick`, `back`, `abort`) lets us assert the wizard ends
|
|
16
|
+
* in a known terminal state without simulating a real TTY.
|
|
17
|
+
* 3. **The shape lifts straight from peer references.**
|
|
18
|
+
* `openSrc/codex/codex-rs/tui/src/onboarding/onboarding_screen.rs`
|
|
19
|
+
* uses the same Step enum + per-step state pattern; we copy the
|
|
20
|
+
* pattern, not the code.
|
|
21
|
+
*/
|
|
22
|
+
/** Ordered list — used by the runner to compute "next" and "previous". */
|
|
23
|
+
export const STEP_ORDER = [
|
|
24
|
+
'welcome',
|
|
25
|
+
'theme',
|
|
26
|
+
'provider',
|
|
27
|
+
'apiKey',
|
|
28
|
+
'model',
|
|
29
|
+
'mcp',
|
|
30
|
+
'agentMd',
|
|
31
|
+
'done',
|
|
32
|
+
];
|
|
33
|
+
export function initWizardState() {
|
|
34
|
+
return {
|
|
35
|
+
currentStep: 'welcome',
|
|
36
|
+
draft: {},
|
|
37
|
+
warnings: [],
|
|
38
|
+
committed: false,
|
|
39
|
+
aborted: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Compute the next step. Pure — used by `reduceWizard` and exposed for
|
|
44
|
+
* tests + the runner's progress indicator ("step 3 of 7").
|
|
45
|
+
*/
|
|
46
|
+
export function nextStep(current) {
|
|
47
|
+
const idx = STEP_ORDER.indexOf(current);
|
|
48
|
+
if (idx < 0 || idx === STEP_ORDER.length - 1)
|
|
49
|
+
return undefined;
|
|
50
|
+
return STEP_ORDER[idx + 1];
|
|
51
|
+
}
|
|
52
|
+
export function prevStep(current) {
|
|
53
|
+
const idx = STEP_ORDER.indexOf(current);
|
|
54
|
+
if (idx <= 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
return STEP_ORDER[idx - 1];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Pure reducer. Every wizard transition must go through here so the
|
|
60
|
+
* test suite can replay the same event sequence the runner emits.
|
|
61
|
+
*
|
|
62
|
+
* Contract:
|
|
63
|
+
* - `advance` applies the patch into the draft and steps forward;
|
|
64
|
+
* a no-op when called on the Done step.
|
|
65
|
+
* - `back` rewinds one step; a no-op on the first step.
|
|
66
|
+
* - `abort` lands the wizard in a terminal state with `aborted: true`
|
|
67
|
+
* and the draft preserved (caller may inspect for partial intent).
|
|
68
|
+
* - `warn` appends an advisory; doesn't move the step pointer.
|
|
69
|
+
* - `commit` flips `committed: true` on the Done step only.
|
|
70
|
+
*
|
|
71
|
+
* The reducer never throws — bad inputs are silently ignored so a
|
|
72
|
+
* stray key event doesn't crash the wizard mid-render.
|
|
73
|
+
*/
|
|
74
|
+
export function reduceWizard(state, event) {
|
|
75
|
+
if (state.aborted || state.committed)
|
|
76
|
+
return state;
|
|
77
|
+
switch (event.kind) {
|
|
78
|
+
case 'advance': {
|
|
79
|
+
const after = nextStep(state.currentStep);
|
|
80
|
+
if (!after)
|
|
81
|
+
return state;
|
|
82
|
+
return {
|
|
83
|
+
...state,
|
|
84
|
+
currentStep: after,
|
|
85
|
+
draft: { ...state.draft, ...event.patch },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
case 'back': {
|
|
89
|
+
const before = prevStep(state.currentStep);
|
|
90
|
+
if (!before)
|
|
91
|
+
return state;
|
|
92
|
+
return { ...state, currentStep: before };
|
|
93
|
+
}
|
|
94
|
+
case 'abort':
|
|
95
|
+
return { ...state, aborted: true };
|
|
96
|
+
case 'warn':
|
|
97
|
+
return {
|
|
98
|
+
...state,
|
|
99
|
+
warnings: [
|
|
100
|
+
...state.warnings,
|
|
101
|
+
{ step: state.currentStep, message: event.message },
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
case 'commit':
|
|
105
|
+
if (state.currentStep !== 'done')
|
|
106
|
+
return state;
|
|
107
|
+
return { ...state, committed: true };
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface ServerConfig {
|
|
|
26
26
|
identity?: 'brainrouter' | 'third-party';
|
|
27
27
|
}
|
|
28
28
|
export interface LLMConfig {
|
|
29
|
-
provider: 'openai';
|
|
29
|
+
provider: 'openai' | 'anthropic';
|
|
30
30
|
apiKey: string;
|
|
31
31
|
model: string;
|
|
32
32
|
endpoint?: string;
|
|
@@ -51,6 +51,18 @@ export declare function getConfigPath(): string;
|
|
|
51
51
|
* skeleton when no file exists rather than exiting.
|
|
52
52
|
*/
|
|
53
53
|
export declare function loadConfig(): Config;
|
|
54
|
+
/**
|
|
55
|
+
* Pick the best API key from the environment for a given endpoint.
|
|
56
|
+
* Order: provider-specific envKey (matched against `PROVIDER_CATALOG`
|
|
57
|
+
* by endpoint), then `OPENAI_API_KEY` (most common default), then the
|
|
58
|
+
* generic `BRAINROUTER_LLM_API_KEY`. Returns undefined if nothing is
|
|
59
|
+
* set so the caller can choose how to surface that.
|
|
60
|
+
*
|
|
61
|
+
* Kept here (vs imported from `cli/wizard/providers.ts`) so non-CLI
|
|
62
|
+
* callers — the MCP child env propagation, future SDK clients — can
|
|
63
|
+
* use it without dragging in the wizard surface.
|
|
64
|
+
*/
|
|
65
|
+
export declare function backfillApiKeyFromEnv(endpoint: string | undefined): string | undefined;
|
|
54
66
|
/**
|
|
55
67
|
* Setup-wizard variant of `loadConfig`. Returns the existing config when
|
|
56
68
|
* one is on disk, or an empty skeleton when none exists yet. Used by
|
package/dist/config/config.js
CHANGED
|
@@ -44,13 +44,55 @@ export function loadConfig() {
|
|
|
44
44
|
// load time so every downstream consumer (callOpenAI, mcpClient env
|
|
45
45
|
// propagation, the cognitive extractor LLM runner) sees a real value
|
|
46
46
|
// instead of the empty string.
|
|
47
|
+
//
|
|
48
|
+
// 0.3.7 — provider-specific fallback. Pre-0.3.7 we only checked
|
|
49
|
+
// OPENAI_API_KEY / BRAINROUTER_LLM_API_KEY, which silently broke
|
|
50
|
+
// users with config.llm.endpoint pointing at DeepSeek / OpenRouter /
|
|
51
|
+
// Gemini / etc. who had the *correct* provider key in their shell
|
|
52
|
+
// (DEEPSEEK_API_KEY, OPENROUTER_API_KEY, GEMINI_API_KEY, …). Now we
|
|
53
|
+
// match the saved endpoint to a provider entry and try ITS envKey
|
|
54
|
+
// FIRST, then fall through to the generic vars.
|
|
47
55
|
if (parsed.llm && !parsed.llm.apiKey.trim()) {
|
|
48
|
-
|
|
49
|
-
if (envKey)
|
|
50
|
-
parsed.llm.apiKey = envKey;
|
|
56
|
+
parsed.llm.apiKey = backfillApiKeyFromEnv(parsed.llm.endpoint) ?? '';
|
|
51
57
|
}
|
|
52
58
|
return parsed;
|
|
53
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Pick the best API key from the environment for a given endpoint.
|
|
62
|
+
* Order: provider-specific envKey (matched against `PROVIDER_CATALOG`
|
|
63
|
+
* by endpoint), then `OPENAI_API_KEY` (most common default), then the
|
|
64
|
+
* generic `BRAINROUTER_LLM_API_KEY`. Returns undefined if nothing is
|
|
65
|
+
* set so the caller can choose how to surface that.
|
|
66
|
+
*
|
|
67
|
+
* Kept here (vs imported from `cli/wizard/providers.ts`) so non-CLI
|
|
68
|
+
* callers — the MCP child env propagation, future SDK clients — can
|
|
69
|
+
* use it without dragging in the wizard surface.
|
|
70
|
+
*/
|
|
71
|
+
export function backfillApiKeyFromEnv(endpoint) {
|
|
72
|
+
// Provider-specific env vars in order of catalog precedence. Hardcoded
|
|
73
|
+
// here so this function stays free of the wizard import (which pulls
|
|
74
|
+
// in chalk, ink picker types, etc.). Keep in lockstep with
|
|
75
|
+
// `cli/wizard/providers.ts → PROVIDER_CATALOG`.
|
|
76
|
+
const PROVIDER_ENV_BY_ENDPOINT = [
|
|
77
|
+
{ endpoint: 'https://api.openai.com/v1', envKey: 'OPENAI_API_KEY' },
|
|
78
|
+
{ endpoint: 'https://api.deepseek.com/v1', envKey: 'DEEPSEEK_API_KEY' },
|
|
79
|
+
{ endpoint: 'https://openrouter.ai/api/v1', envKey: 'OPENROUTER_API_KEY' },
|
|
80
|
+
{ endpoint: 'https://generativelanguage.googleapis.com/v1beta/openai', envKey: 'GEMINI_API_KEY' },
|
|
81
|
+
{ endpoint: 'https://api.anthropic.com/v1', envKey: 'ANTHROPIC_API_KEY' },
|
|
82
|
+
{ endpoint: 'http://localhost:1234/v1', envKey: 'LMSTUDIO_API_KEY' },
|
|
83
|
+
{ endpoint: 'http://localhost:11434/v1', envKey: 'OLLAMA_API_KEY' },
|
|
84
|
+
];
|
|
85
|
+
if (endpoint) {
|
|
86
|
+
const trimmed = endpoint.replace(/\/$/, '');
|
|
87
|
+
const match = PROVIDER_ENV_BY_ENDPOINT.find((p) => p.endpoint === trimmed);
|
|
88
|
+
if (match) {
|
|
89
|
+
const value = process.env[match.envKey];
|
|
90
|
+
if (value && value.trim())
|
|
91
|
+
return value.trim();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return process.env.OPENAI_API_KEY?.trim() || process.env.BRAINROUTER_LLM_API_KEY?.trim() || undefined;
|
|
95
|
+
}
|
|
54
96
|
/**
|
|
55
97
|
* Setup-wizard variant of `loadConfig`. Returns the existing config when
|
|
56
98
|
* one is on disk, or an empty skeleton when none exists yet. Used by
|
package/dist/index.js
CHANGED
|
@@ -50,173 +50,58 @@ process.emitWarning = ((warning, ...rest) => {
|
|
|
50
50
|
return;
|
|
51
51
|
return originalEmitWarning(warning, ...rest);
|
|
52
52
|
});
|
|
53
|
+
/**
|
|
54
|
+
* Crash diagnostics — surface ANY exit reason so the user (or we) can
|
|
55
|
+
* see WHY the process died if the REPL ever silently quits. The
|
|
56
|
+
* symptom the user reported was "REPL prints banner, then bash prompt"
|
|
57
|
+
* with no error. If that happens again under any future regression,
|
|
58
|
+
* one of these handlers will catch it and print the cause.
|
|
59
|
+
*
|
|
60
|
+
* `BRAINROUTER_DEBUG_EXIT=1` (default off) enables verbose exit tracing
|
|
61
|
+
* including the beforeExit event so we can see whether the event loop
|
|
62
|
+
* drained (= stdin refcount issue) vs explicit process.exit (= bug).
|
|
63
|
+
*/
|
|
64
|
+
process.on('uncaughtException', (err) => {
|
|
65
|
+
process.stderr.write(`\n[brainrouter] Uncaught exception killed the process:\n${err?.stack ?? err}\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
});
|
|
68
|
+
process.on('unhandledRejection', (reason) => {
|
|
69
|
+
process.stderr.write(`\n[brainrouter] Unhandled promise rejection killed the process:\n${reason?.stack ?? reason}\n`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
72
|
+
if (process.env.BRAINROUTER_DEBUG_EXIT === '1') {
|
|
73
|
+
process.on('beforeExit', (code) => {
|
|
74
|
+
process.stderr.write(`[brainrouter:debug] beforeExit code=${code} (event loop drained — likely Ink stdin.unref leak)\n`);
|
|
75
|
+
});
|
|
76
|
+
process.on('exit', (code) => {
|
|
77
|
+
process.stderr.write(`[brainrouter:debug] exit code=${code}\n`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
53
80
|
import fs from 'node:fs';
|
|
54
|
-
import path from 'node:path';
|
|
55
|
-
import url from 'node:url';
|
|
56
81
|
import { Command } from 'commander';
|
|
57
82
|
import inquirer from 'inquirer';
|
|
58
83
|
import chalk from 'chalk';
|
|
59
|
-
import { loadConfig, loadOrInitConfig, saveConfig } from './config/config.js';
|
|
84
|
+
import { loadConfig, loadOrInitConfig, saveConfig, getConfigPath } from './config/config.js';
|
|
60
85
|
import { McpClientWrapper } from './runtime/mcpClient.js';
|
|
86
|
+
import { McpClientPool, selectMcpServerIds } from './runtime/mcpPool.js';
|
|
87
|
+
import { setKnownMcpServerIds } from './cli/ink/toolFormat.js';
|
|
61
88
|
import { Agent } from './agent/agent.js';
|
|
62
|
-
import {
|
|
89
|
+
import { runChat } from './cli/ink/runChat.js';
|
|
63
90
|
import { applyWorkspaceRoot, findWorkspaceRoot } from './config/workspace.js';
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* reranker, memory engine, server auth).
|
|
74
|
-
*
|
|
75
|
-
* Loading order:
|
|
76
|
-
* 1) `brainrouter-cli/.env` (PRIMARY for CLI process).
|
|
77
|
-
* 2) `brainrouter/.env` (FALLBACK — only for the LLM credentials, so
|
|
78
|
-
* a user who set up only the MCP config still
|
|
79
|
-
* gets a working CLI agent and vice versa).
|
|
80
|
-
*
|
|
81
|
-
* Shell env (anything already in `process.env`) wins over both — explicit
|
|
82
|
-
* env > .env file, as is conventional.
|
|
83
|
-
*
|
|
84
|
-
* The MCP child uses `import "dotenv/config"` which resolves relative to
|
|
85
|
-
* `process.cwd()`. The CLI sets the spawned child's cwd to the MCP package
|
|
86
|
-
* directory (see runtime/mcpClient.ts), so `brainrouter/.env` is loaded by
|
|
87
|
-
* the child directly — the CLI does NOT need to pre-load it for the MCP's
|
|
88
|
-
* sake.
|
|
89
|
-
*/
|
|
90
|
-
/**
|
|
91
|
-
* Vars the CLI process consumes from a sibling `brainrouter/.env` fallback.
|
|
92
|
-
*
|
|
93
|
-
* LLM credentials are deliberately EXCLUDED — `~/.config/brainrouter/config.json`
|
|
94
|
-
* is the canonical source for chat-LLM creds, endpoint, and model (set via
|
|
95
|
-
* `brainrouter login` or `brainrouter config`). Pulling them from `.env` in
|
|
96
|
-
* parallel created a silent precedence bug: env would shadow `config.json`
|
|
97
|
-
* because `loadBrainrouterEnv()` runs at module-load time before
|
|
98
|
-
* `loadConfig()`, and downstream callers like `mcpClient.connect()` check
|
|
99
|
-
* `mergedEnv.BRAINROUTER_LLM_ENDPOINT` before falling back to `llmConfig`.
|
|
100
|
-
*
|
|
101
|
-
* The only var we still allow through the fallback is `BRAINROUTER_API_KEY`
|
|
102
|
-
* — that's MCP-server auth (not LLM), and stdio mode propagates it from the
|
|
103
|
-
* CLI's process.env into the spawned child. If your `config.json` server
|
|
104
|
-
* profile already carries the API key in its `env` block, you don't need
|
|
105
|
-
* this fallback either, and it can go away in a follow-up cleanup.
|
|
106
|
-
*
|
|
107
|
-
* Anything outside this set is a pure MCP-server knob (embedding endpoint,
|
|
108
|
-
* JWT secret, extraction sweep config, prewarming, graph timeouts, admin
|
|
109
|
-
* creds) that just pollutes the CLI's environment with no effect — the MCP
|
|
110
|
-
* child loads `brainrouter/.env` directly via its own `dotenv/config`.
|
|
111
|
-
*/
|
|
112
|
-
const CLI_FALLBACK_ALLOWLIST = new Set([
|
|
113
|
-
'BRAINROUTER_API_KEY',
|
|
114
|
-
]);
|
|
115
|
-
function loadEnvFile(file, allowlist) {
|
|
116
|
-
try {
|
|
117
|
-
const raw = fs.readFileSync(file, 'utf8');
|
|
118
|
-
let count = 0;
|
|
119
|
-
for (const line of raw.split('\n')) {
|
|
120
|
-
const trimmed = line.trim();
|
|
121
|
-
if (!trimmed || trimmed.startsWith('#'))
|
|
122
|
-
continue;
|
|
123
|
-
const eq = trimmed.indexOf('=');
|
|
124
|
-
if (eq <= 0)
|
|
125
|
-
continue;
|
|
126
|
-
const key = trimmed.slice(0, eq).trim();
|
|
127
|
-
let value = trimmed.slice(eq + 1).trim();
|
|
128
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
129
|
-
value = value.slice(1, -1);
|
|
130
|
-
}
|
|
131
|
-
// Allowlist gate: when loading the MCP fallback file, only adopt vars
|
|
132
|
-
// the CLI actually reads. Primary CLI .env loads pass no allowlist and
|
|
133
|
-
// accept everything (it's the CLI's own config).
|
|
134
|
-
if (allowlist && !allowlist.has(key))
|
|
135
|
-
continue;
|
|
136
|
-
if (key && !(key in process.env)) {
|
|
137
|
-
process.env[key] = value;
|
|
138
|
-
count++;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return count;
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
return 0;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
function loadBrainrouterEnv() {
|
|
148
|
-
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
149
|
-
let count = 0;
|
|
150
|
-
let primary;
|
|
151
|
-
let fallback;
|
|
152
|
-
// PRIMARY: brainrouter-cli/.env (this package's own config).
|
|
153
|
-
// dist/index.js → ../.. = brainrouter-cli/, so .env sits next to package.json.
|
|
154
|
-
const cliCandidates = [
|
|
155
|
-
path.resolve(here, '..', '..', '.env'), // monorepo: brainrouter-cli/.env
|
|
156
|
-
path.resolve(here, '..', '..', '..', 'brainrouter-cli', '.env'), // installed/nested
|
|
157
|
-
path.resolve(process.cwd(), 'brainrouter-cli', '.env'), // running from repo root
|
|
158
|
-
];
|
|
159
|
-
for (const file of cliCandidates) {
|
|
160
|
-
if (fs.existsSync(file)) {
|
|
161
|
-
primary = file;
|
|
162
|
-
count += loadEnvFile(file);
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// FALLBACK: brainrouter/.env (MCP-side config). Only used to backstop the
|
|
167
|
-
// LLM credentials so a partial setup still works. The MCP child loads
|
|
168
|
-
// brainrouter/.env on its own anyway via cwd hint, so we don't need to
|
|
169
|
-
// import its server-only knobs (embedding endpoint, JWT secret, sweep
|
|
170
|
-
// intervals, prewarming) — those just clutter the CLI's process.env. The
|
|
171
|
-
// allowlist limits the fallback to vars the CLI actually reads.
|
|
172
|
-
//
|
|
173
|
-
// Only record the fallback in the result when it actually contributed at
|
|
174
|
-
// least one new var. If the primary file already set all the LLM creds,
|
|
175
|
-
// mentioning the fallback path in the startup banner is noise — the user
|
|
176
|
-
// already has the CLI fully configured locally and doesn't need to know
|
|
177
|
-
// a sibling .env was read but ignored.
|
|
178
|
-
const mcpCandidates = [
|
|
179
|
-
path.resolve(here, '..', '..', '..', 'brainrouter', '.env'),
|
|
180
|
-
path.resolve(process.cwd(), 'brainrouter', '.env'),
|
|
181
|
-
];
|
|
182
|
-
for (const file of mcpCandidates) {
|
|
183
|
-
if (fs.existsSync(file)) {
|
|
184
|
-
const added = loadEnvFile(file, CLI_FALLBACK_ALLOWLIST);
|
|
185
|
-
if (added > 0) {
|
|
186
|
-
fallback = file;
|
|
187
|
-
count += added;
|
|
188
|
-
}
|
|
189
|
-
break;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return { primary, fallback, count };
|
|
193
|
-
}
|
|
194
|
-
const envLoadResult = loadBrainrouterEnv();
|
|
195
|
-
if (envLoadResult.primary || envLoadResult.fallback) {
|
|
196
|
-
// Something contributed at least one var — show what loaded so the user can
|
|
197
|
-
// trace where runtime knobs (sandbox, timeouts, trace log, web search) are
|
|
198
|
-
// coming from. LLM creds intentionally do NOT flow through this path; they
|
|
199
|
-
// live in ~/.config/brainrouter/config.json.
|
|
200
|
-
const sources = [];
|
|
201
|
-
if (envLoadResult.primary)
|
|
202
|
-
sources.push(envLoadResult.primary);
|
|
203
|
-
if (envLoadResult.fallback)
|
|
204
|
-
sources.push(`${envLoadResult.fallback} (fallback)`);
|
|
205
|
-
const tag = envLoadResult.count > 0
|
|
206
|
-
? chalk.gray(` (${envLoadResult.count} new var${envLoadResult.count === 1 ? '' : 's'})`)
|
|
207
|
-
: chalk.gray(' (all keys already set in shell)');
|
|
208
|
-
console.error(chalk.gray(`env: loaded ${sources.join(', ')}`) + tag);
|
|
209
|
-
}
|
|
210
|
-
// No banner when nothing loaded — that's the normal case for users who
|
|
211
|
-
// configured the CLI via `brainrouter login` / `brainrouter config`. The old
|
|
212
|
-
// "set BRAINROUTER_LLM_API_KEY in your shell" hint contradicted the
|
|
213
|
-
// config.json-is-canonical design and confused users who already had a
|
|
214
|
-
// fully populated config.
|
|
91
|
+
import { runWizard, isOnboarded } from './cli/ink/runWizard.js';
|
|
92
|
+
// The CLI deliberately does NOT load any `.env` file. Source of truth for
|
|
93
|
+
// runtime config is `~/.config/brainrouter/config.json` (LLM creds, MCP
|
|
94
|
+
// server profiles, theme, etc.), set interactively via the wizard / `/login`
|
|
95
|
+
// / `/config`. The MCP server is a separate concern and loads its own
|
|
96
|
+
// `server.env` from its own working directory — that's the server's
|
|
97
|
+
// business, not the CLI's. Shell env (real `process.env`) still flows
|
|
98
|
+
// through normally for everything that reads it (e.g. `OPENAI_API_KEY`
|
|
99
|
+
// fallback inside `callOpenAI`).
|
|
215
100
|
const program = new Command();
|
|
216
101
|
program
|
|
217
102
|
.name('brainrouter')
|
|
218
103
|
.description('BrainRouter CLI — Premium interactive terminal-based agent client.')
|
|
219
|
-
.version('0.3.
|
|
104
|
+
.version('0.3.8');
|
|
220
105
|
// Chat Command (default)
|
|
221
106
|
program
|
|
222
107
|
.command('chat', { isDefault: true })
|
|
@@ -242,22 +127,64 @@ program
|
|
|
242
127
|
// boxed startup banner shows the workspace row, and `/workspace` exposes
|
|
243
128
|
// the launch CWD + detection reason on demand. Keeping a duplicate
|
|
244
129
|
// stale-chrome line above the banner undermines the banner-first design.
|
|
130
|
+
// 0.3.7 — first-run auto-trigger. When no config exists OR the
|
|
131
|
+
// onboarded marker is missing, drop the user straight into the
|
|
132
|
+
// wizard before constructing the Agent / MCP client. This replaces
|
|
133
|
+
// the pre-0.3.7 "Error: No BrainRouter config found ... run
|
|
134
|
+
// `brainrouter login`" exit-with-error path. The wizard owns its
|
|
135
|
+
// own readline for the wizard's lifetime; when it returns we
|
|
136
|
+
// continue into the REPL with the freshly-saved config.
|
|
137
|
+
if (!fs.existsSync(getConfigPath()) || !isOnboarded()) {
|
|
138
|
+
try {
|
|
139
|
+
const wizardResult = await runWizard({
|
|
140
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
141
|
+
});
|
|
142
|
+
if (wizardResult.state.aborted) {
|
|
143
|
+
console.error(chalk.gray('Wizard aborted before saving — exiting. Run `brainrouter` again any time to retry.'));
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error(chalk.red(`Wizard failed: ${err?.message ?? err}`));
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
245
152
|
const config = loadConfig();
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
153
|
+
// 0.3.7 — multi-MCP support. Third-party MCPs are additive and all
|
|
154
|
+
// connect concurrently. BrainRouter MCPs are different: users may store
|
|
155
|
+
// several BrainRouter profiles (local/staging/remote/self-hosted), but
|
|
156
|
+
// only one brain should be active at a time. `activeServer` selects that
|
|
157
|
+
// BrainRouter profile when it points at one; otherwise we use the first
|
|
158
|
+
// configured BrainRouter profile. `--profile <name>` still scopes the run
|
|
159
|
+
// to exactly one server for explicit single-server mode.
|
|
160
|
+
const requestedProfile = options.profile;
|
|
161
|
+
const allServerIds = Object.keys(config.servers);
|
|
162
|
+
if (allServerIds.length === 0) {
|
|
163
|
+
console.error(chalk.red('Error: No MCP server profiles in config.'));
|
|
164
|
+
console.error(chalk.gray('Run `/login` inside the REPL or `brainrouter login` to add a profile.'));
|
|
250
165
|
process.exit(1);
|
|
251
166
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
167
|
+
if (requestedProfile && !config.servers[requestedProfile]) {
|
|
168
|
+
console.error(chalk.red(`Error: Profile "${requestedProfile}" not found in config.`));
|
|
169
|
+
console.error(chalk.gray(`Available profiles: ${allServerIds.join(', ')}.`));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
const targetIds = selectMcpServerIds(config.servers, config.activeServer, requestedProfile);
|
|
173
|
+
// Pre-process each target's serverConfig to thread workspaceRoot
|
|
174
|
+
// into the stdio `--root` arg shape the MCP server expects.
|
|
175
|
+
const targetServers = {};
|
|
176
|
+
for (const id of targetIds) {
|
|
177
|
+
const cloned = { ...config.servers[id] };
|
|
178
|
+
if (cloned.type === 'stdio') {
|
|
179
|
+
const args = cloned.args ?? [];
|
|
180
|
+
const rootIndex = args.indexOf('--root');
|
|
181
|
+
cloned.args = rootIndex >= 0
|
|
182
|
+
? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
|
|
183
|
+
: [...args, '--root', workspace.workspaceRoot];
|
|
184
|
+
}
|
|
185
|
+
targetServers[id] = cloned;
|
|
186
|
+
config.servers[id] = cloned;
|
|
259
187
|
}
|
|
260
|
-
config.servers[profileName] = serverConfig;
|
|
261
188
|
const llm = config.llm || {
|
|
262
189
|
provider: 'openai',
|
|
263
190
|
model: 'gpt-4o-mini',
|
|
@@ -266,37 +193,38 @@ program
|
|
|
266
193
|
if (options.model) {
|
|
267
194
|
llm.model = options.model;
|
|
268
195
|
}
|
|
269
|
-
|
|
270
|
-
// "Connecting..."
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// try/catches every MCP call. Keep the REPL up so the user can edit
|
|
285
|
-
// code, drive shell commands, and recover when the server comes back.
|
|
286
|
-
// Pass --strict-mcp to flip back to hard-fail (useful in CI).
|
|
287
|
-
console.error(chalk.red(`Failed to connect to MCP server: ${err.message}`));
|
|
196
|
+
// Connect everyone concurrently — offline servers don't block.
|
|
197
|
+
// "Connecting..." status lines intentionally dropped (see prior
|
|
198
|
+
// comment); the banner's per-server row is the success signal.
|
|
199
|
+
const mcpClient = new McpClientPool();
|
|
200
|
+
const statuses = await mcpClient.connectAll(targetServers, llm, { timeoutMs: 5_000 });
|
|
201
|
+
// Register live server ids for Ink tool-name display so multi-word
|
|
202
|
+
// server names (e.g. `my_server`) don't get mis-stripped by the
|
|
203
|
+
// single-underscore prefix regex.
|
|
204
|
+
setKnownMcpServerIds(mcpClient.getServerIds());
|
|
205
|
+
const failures = statuses.filter((s) => s.status === 'failed');
|
|
206
|
+
if (failures.length === statuses.length) {
|
|
207
|
+
// Every server failed — equivalent to the pre-0.3.7 "MCP
|
|
208
|
+
// unreachable" path; same --strict-mcp semantics apply.
|
|
209
|
+
const summary = failures.map((s) => `${s.serverId}: ${s.error ?? 'unknown error'}`).join('\n ');
|
|
210
|
+
console.error(chalk.red(`Failed to connect to any MCP server:\n ${summary}`));
|
|
288
211
|
if (options.strictMcp) {
|
|
289
212
|
console.error(chalk.gray('--strict-mcp set; exiting.'));
|
|
290
213
|
process.exit(1);
|
|
291
214
|
}
|
|
292
|
-
//
|
|
293
|
-
|
|
215
|
+
// Falls through to offline-mode REPL — banner shows the warning.
|
|
216
|
+
}
|
|
217
|
+
else if (failures.length > 0) {
|
|
218
|
+
// Partial failure — surface the failing server names without
|
|
219
|
+
// exiting; user can /mcp reconnect <id> later.
|
|
220
|
+
const failed = failures.map((s) => s.serverId).join(', ');
|
|
221
|
+
console.error(chalk.yellow(`⚠ ${failures.length} of ${statuses.length} MCP servers offline: ${failed}. Other servers connected; use /mcp to inspect.`));
|
|
294
222
|
}
|
|
295
223
|
const agent = new Agent(mcpClient, llm, {
|
|
296
224
|
workspaceRoot: workspace.workspaceRoot,
|
|
297
225
|
launchCwd: workspace.launchCwd,
|
|
298
226
|
});
|
|
299
|
-
|
|
227
|
+
await runChat({ agent, mcpClient, config, workspace });
|
|
300
228
|
});
|
|
301
229
|
// One-shot non-interactive run — pipe-friendly for scripting/CI.
|
|
302
230
|
// brainrouter run "summarize the changes in src/"
|
|
@@ -348,28 +276,45 @@ program
|
|
|
348
276
|
const workspace = findWorkspaceRoot();
|
|
349
277
|
applyWorkspaceRoot(workspace.workspaceRoot);
|
|
350
278
|
const config = loadConfig();
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
279
|
+
// Multi-MCP: like `chat`, connect third-party servers concurrently but
|
|
280
|
+
// only one BrainRouter MCP profile at a time. `--profile <name>` scopes
|
|
281
|
+
// to exactly one.
|
|
282
|
+
const requestedProfile = options.profile;
|
|
283
|
+
const allServerIds = Object.keys(config.servers);
|
|
284
|
+
if (allServerIds.length === 0) {
|
|
285
|
+
console.error('Error: No MCP server profiles in config.');
|
|
355
286
|
process.exit(1);
|
|
356
287
|
}
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
288
|
+
if (requestedProfile && !config.servers[requestedProfile]) {
|
|
289
|
+
console.error(`Error: Profile "${requestedProfile}" not found.`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
const targetIds = selectMcpServerIds(config.servers, config.activeServer, requestedProfile);
|
|
293
|
+
const targetServers = {};
|
|
294
|
+
for (const id of targetIds) {
|
|
295
|
+
const cloned = { ...config.servers[id] };
|
|
296
|
+
if (cloned.type === 'stdio') {
|
|
297
|
+
const args = cloned.args ?? [];
|
|
298
|
+
const rootIndex = args.indexOf('--root');
|
|
299
|
+
cloned.args = rootIndex >= 0
|
|
300
|
+
? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
|
|
301
|
+
: [...args, '--root', workspace.workspaceRoot];
|
|
302
|
+
}
|
|
303
|
+
targetServers[id] = cloned;
|
|
363
304
|
}
|
|
364
305
|
const llm = config.llm ?? { provider: 'openai', model: 'gpt-4o-mini', apiKey: '' };
|
|
365
306
|
if (options.model)
|
|
366
307
|
llm.model = options.model;
|
|
367
|
-
const mcpClient = new
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
308
|
+
const mcpClient = new McpClientPool();
|
|
309
|
+
const statuses = await mcpClient.connectAll(targetServers, llm, { timeoutMs: 5_000 });
|
|
310
|
+
// Register live server ids for Ink tool-name display so multi-word
|
|
311
|
+
// server names (e.g. `my_server`) don't get mis-stripped by the
|
|
312
|
+
// single-underscore prefix regex.
|
|
313
|
+
setKnownMcpServerIds(mcpClient.getServerIds());
|
|
314
|
+
const allFailed = statuses.length > 0 && statuses.every((s) => s.status === 'failed');
|
|
315
|
+
if (allFailed) {
|
|
316
|
+
const summary = statuses.map((s) => `${s.serverId}: ${s.error ?? 'unknown'}`).join('; ');
|
|
317
|
+
console.error(`MCP connect failed (all servers): ${summary}`);
|
|
373
318
|
if (options.strictMcp)
|
|
374
319
|
process.exit(1);
|
|
375
320
|
// Offline mode for one-shot: same rationale as the chat command — local
|
|
@@ -378,6 +323,12 @@ program
|
|
|
378
323
|
// summarize" while the MCP server is down. CI can pass --strict-mcp.
|
|
379
324
|
console.error('Continuing in offline mode (no memory recall / skills). Pass --strict-mcp to exit instead.');
|
|
380
325
|
}
|
|
326
|
+
else {
|
|
327
|
+
const failed = statuses.filter((s) => s.status === 'failed');
|
|
328
|
+
if (failed.length > 0) {
|
|
329
|
+
process.stderr.write(`[mcp] ${failed.length} of ${statuses.length} servers offline: ${failed.map((f) => f.serverId).join(', ')}\n`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
381
332
|
const agent = new Agent(mcpClient, llm, {
|
|
382
333
|
workspaceRoot: workspace.workspaceRoot,
|
|
383
334
|
launchCwd: workspace.launchCwd,
|
package/dist/memory/briefing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { redactText } from '../state/sessionStore.js';
|
|
2
|
-
import { callMcpTool } from '../runtime/mcpUtils.js';
|
|
2
|
+
import { callMcpTool, hasMcpTool } from '../runtime/mcpUtils.js';
|
|
3
3
|
/**
|
|
4
4
|
* Run pre-turn memory queries in parallel and assemble a compact briefing block.
|
|
5
5
|
* This is the System-1 entry point: every turn pays a small fixed cost to ask
|
|
@@ -11,13 +11,13 @@ export async function buildMemoryBriefing(inputs) {
|
|
|
11
11
|
const maxChars = inputs.maxCharsPerSource ?? 4000;
|
|
12
12
|
const toolNames = new Set(mcpTools.map((t) => t.name));
|
|
13
13
|
const tasks = [];
|
|
14
|
-
if (toolNames
|
|
14
|
+
if (hasMcpTool(toolNames, 'memory_recall')) {
|
|
15
15
|
tasks.push(callSafe('memory_recall', { sessionKey, query, activeSkill }, mcpClient, maxChars, extractRecords));
|
|
16
16
|
}
|
|
17
|
-
if (toolNames
|
|
17
|
+
if (hasMcpTool(toolNames, 'memory_working_context')) {
|
|
18
18
|
tasks.push(callSafe('memory_working_context', { sessionKey, workspacePath: workspaceRoot }, mcpClient, maxChars));
|
|
19
19
|
}
|
|
20
|
-
if (toolNames
|
|
20
|
+
if (hasMcpTool(toolNames, 'memory_task_state') && !inputs.hasActiveGoal) {
|
|
21
21
|
tasks.push(callSafe('memory_task_state', { query }, mcpClient, maxChars));
|
|
22
22
|
}
|
|
23
23
|
const results = await Promise.all(tasks);
|