@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.
Files changed (129) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/changelog/0.2.0.md +15 -0
  8. package/changelog/0.3.0.md +20 -0
  9. package/changelog/0.3.1.md +22 -0
  10. package/changelog/0.3.2.md +15 -0
  11. package/changelog/0.3.3.md +19 -0
  12. package/changelog/0.3.4.md +20 -0
  13. package/changelog/0.3.5.md +9 -0
  14. package/changelog/0.3.6.md +9 -0
  15. package/changelog/0.3.7.md +20 -0
  16. package/changelog/0.3.8.md +30 -0
  17. package/changelog/README.md +41 -0
  18. package/dist/agent/agent.d.ts +34 -1
  19. package/dist/agent/agent.js +372 -79
  20. package/dist/agent/toolCallRecovery.d.ts +57 -0
  21. package/dist/agent/toolCallRecovery.js +130 -0
  22. package/dist/agent/toolSafety.d.ts +17 -0
  23. package/dist/agent/toolSafety.js +102 -0
  24. package/dist/cli/banner.d.ts +20 -0
  25. package/dist/cli/banner.js +47 -14
  26. package/dist/cli/cliPrompt.d.ts +40 -3
  27. package/dist/cli/cliPrompt.js +117 -25
  28. package/dist/cli/commands/_context.d.ts +3 -1
  29. package/dist/cli/commands/_helpers.d.ts +1 -1
  30. package/dist/cli/commands/config.d.ts +46 -0
  31. package/dist/cli/commands/config.js +1042 -0
  32. package/dist/cli/commands/init.d.ts +20 -0
  33. package/dist/cli/commands/init.js +64 -0
  34. package/dist/cli/commands/login.d.ts +13 -0
  35. package/dist/cli/commands/login.js +179 -0
  36. package/dist/cli/commands/mcp.d.ts +13 -11
  37. package/dist/cli/commands/mcp.js +261 -74
  38. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  39. package/dist/cli/commands/mcpInstall.js +87 -0
  40. package/dist/cli/commands/orchestration.js +51 -0
  41. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  42. package/dist/cli/commands/releaseNotes.js +109 -0
  43. package/dist/cli/commands/schedule.d.ts +18 -0
  44. package/dist/cli/commands/schedule.js +189 -0
  45. package/dist/cli/commands/ui.js +119 -60
  46. package/dist/cli/commands/workflow.d.ts +2 -0
  47. package/dist/cli/commands/workflow.js +54 -8
  48. package/dist/cli/ink/ChatApp.d.ts +206 -0
  49. package/dist/cli/ink/ChatApp.js +493 -0
  50. package/dist/cli/ink/Frame.d.ts +26 -0
  51. package/dist/cli/ink/Frame.js +5 -0
  52. package/dist/cli/ink/Picker.d.ts +71 -0
  53. package/dist/cli/ink/Picker.js +168 -0
  54. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  55. package/dist/cli/ink/SlashPalette.js +136 -0
  56. package/dist/cli/ink/TextField.d.ts +34 -0
  57. package/dist/cli/ink/TextField.js +47 -0
  58. package/dist/cli/ink/WizardApp.d.ts +7 -0
  59. package/dist/cli/ink/WizardApp.js +422 -0
  60. package/dist/cli/ink/ambientChat.d.ts +34 -0
  61. package/dist/cli/ink/ambientChat.js +7 -0
  62. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  63. package/dist/cli/ink/consoleCapture.js +33 -0
  64. package/dist/cli/ink/markdownRender.d.ts +41 -0
  65. package/dist/cli/ink/markdownRender.js +278 -0
  66. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  67. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  68. package/dist/cli/ink/runChat.d.ts +34 -0
  69. package/dist/cli/ink/runChat.js +682 -0
  70. package/dist/cli/ink/runPicker.d.ts +31 -0
  71. package/dist/cli/ink/runPicker.js +139 -0
  72. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  73. package/dist/cli/ink/runSlashPalette.js +33 -0
  74. package/dist/cli/ink/runWizard.d.ts +22 -0
  75. package/dist/cli/ink/runWizard.js +133 -0
  76. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  77. package/dist/cli/ink/stdinHandoff.js +78 -0
  78. package/dist/cli/ink/toolFormat.d.ts +75 -0
  79. package/dist/cli/ink/toolFormat.js +206 -0
  80. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  81. package/dist/cli/ink/useTerminalSize.js +26 -0
  82. package/dist/cli/repl.d.ts +25 -3
  83. package/dist/cli/repl.js +52 -714
  84. package/dist/cli/slashSuggest.d.ts +32 -0
  85. package/dist/cli/slashSuggest.js +146 -0
  86. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  87. package/dist/cli/wizard/modelsApi.js +166 -0
  88. package/dist/cli/wizard/picker.d.ts +202 -0
  89. package/dist/cli/wizard/picker.js +547 -0
  90. package/dist/cli/wizard/providers.d.ts +86 -0
  91. package/dist/cli/wizard/providers.js +190 -0
  92. package/dist/cli/wizard/runner.d.ts +13 -0
  93. package/dist/cli/wizard/runner.js +488 -0
  94. package/dist/cli/wizard/types.d.ts +122 -0
  95. package/dist/cli/wizard/types.js +109 -0
  96. package/dist/config/config.d.ts +13 -1
  97. package/dist/config/config.js +45 -3
  98. package/dist/index.js +157 -206
  99. package/dist/memory/briefing.d.ts +1 -1
  100. package/dist/memory/briefing.js +4 -4
  101. package/dist/memory/consolidation.d.ts +1 -1
  102. package/dist/orchestration/agentRegistry.d.ts +36 -0
  103. package/dist/orchestration/agentRegistry.js +64 -0
  104. package/dist/orchestration/orchestrator.d.ts +7 -0
  105. package/dist/orchestration/orchestrator.js +2 -0
  106. package/dist/orchestration/tools.d.ts +105 -3
  107. package/dist/orchestration/tools.js +167 -8
  108. package/dist/prompt/skillCatalog.d.ts +11 -0
  109. package/dist/prompt/skillCatalog.js +134 -0
  110. package/dist/prompt/skillRunner.d.ts +2 -2
  111. package/dist/prompt/skillRunner.js +2 -31
  112. package/dist/prompt/systemPrompt.js +7 -2
  113. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  114. package/dist/runtime/anthropicAdapter.js +293 -0
  115. package/dist/runtime/cronParser.d.ts +23 -0
  116. package/dist/runtime/cronParser.js +122 -0
  117. package/dist/runtime/mcpClient.js +14 -11
  118. package/dist/runtime/mcpPool.d.ts +170 -0
  119. package/dist/runtime/mcpPool.js +442 -0
  120. package/dist/runtime/mcpUtils.d.ts +17 -1
  121. package/dist/runtime/mcpUtils.js +23 -0
  122. package/dist/runtime/scheduleTicker.d.ts +33 -0
  123. package/dist/runtime/scheduleTicker.js +99 -0
  124. package/dist/runtime/vendorSnippets.d.ts +45 -0
  125. package/dist/runtime/vendorSnippets.js +153 -0
  126. package/dist/state/scheduleStore.d.ts +37 -0
  127. package/dist/state/scheduleStore.js +64 -0
  128. package/package.json +14 -5
  129. 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
+ }
@@ -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
@@ -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
- const envKey = process.env.OPENAI_API_KEY || process.env.BRAINROUTER_LLM_API_KEY;
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 { startREPL } from './cli/repl.js';
89
+ import { runChat } from './cli/ink/runChat.js';
63
90
  import { applyWorkspaceRoot, findWorkspaceRoot } from './config/workspace.js';
64
- /**
65
- * Load `.env` files into the CLI's `process.env`.
66
- *
67
- * The CLI and the MCP server have separate concerns and now ship separate
68
- * config files:
69
- *
70
- * - `brainrouter-cli/.env` — CLI-only knobs (chat LLM, tool loop,
71
- * sandbox, web search, trace log).
72
- * - `brainrouter/.env` — MCP-only knobs (extraction LLM, embeddings,
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.5');
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
- const profileName = options.profile || config.activeServer;
247
- const configuredServer = config.servers[profileName];
248
- if (!configuredServer) {
249
- console.error(chalk.red(`Error: Profile "${profileName}" not found in config.`));
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
- const serverConfig = { ...configuredServer };
253
- if (serverConfig.type === 'stdio') {
254
- const args = serverConfig.args ?? [];
255
- const rootIndex = args.indexOf('--root');
256
- serverConfig.args = rootIndex >= 0
257
- ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
258
- : [...args, '--root', workspace.workspaceRoot];
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
- const mcpClient = new McpClientWrapper();
270
- // "Connecting..." / "Successfully connected!" status lines intentionally
271
- // dropped they printed for the ~1-2s of connect AND scrolled the
272
- // banner up. On success the banner's `mcp ... online` row IS the
273
- // success signal; on failure the catch-block error + the post-banner
274
- // OFFLINE MODE warning in startREPL together cover both diagnosis and
275
- // remediation.
276
- try {
277
- await mcpClient.connect(serverConfig, llm, profileName);
278
- }
279
- catch (err) {
280
- // Degraded "offline mode": the MCP server is the cognitive memory layer
281
- // (recall, skills, capture, citations) losing it is painful but not
282
- // fatal. Local tools (read_file, write_file, list_dir, grep_search,
283
- // run_command, spawn_agent) still work, and the agent's runTurn already
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
- // The banner-adjacent OFFLINE MODE warning in startREPL covers the
293
- // remediation hint. No second warning here.
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
- startREPL(agent, mcpClient, config, workspace);
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
- const profileName = options.profile || config.activeServer;
352
- const serverConfig = { ...config.servers[profileName] };
353
- if (!serverConfig) {
354
- console.error(`Error: Profile "${profileName}" not found.`);
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 (serverConfig.type === 'stdio') {
358
- const args = serverConfig.args ?? [];
359
- const rootIndex = args.indexOf('--root');
360
- serverConfig.args = rootIndex >= 0
361
- ? [...args.slice(0, rootIndex + 1), workspace.workspaceRoot, ...args.slice(rootIndex + 2)]
362
- : [...args, '--root', workspace.workspaceRoot];
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 McpClientWrapper();
368
- try {
369
- await mcpClient.connect(serverConfig, llm, profileName);
370
- }
371
- catch (err) {
372
- console.error(`MCP connect failed: ${err.message}`);
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,
@@ -1,4 +1,4 @@
1
- import type { McpClientWrapper } from '../runtime/mcpClient.js';
1
+ import type { McpClientPool as McpClientWrapper } from '../runtime/mcpPool.js';
2
2
  export interface BriefingInputs {
3
3
  mcpClient: McpClientWrapper;
4
4
  mcpTools: Array<{
@@ -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.has('memory_recall')) {
14
+ if (hasMcpTool(toolNames, 'memory_recall')) {
15
15
  tasks.push(callSafe('memory_recall', { sessionKey, query, activeSkill }, mcpClient, maxChars, extractRecords));
16
16
  }
17
- if (toolNames.has('memory_working_context')) {
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.has('memory_task_state') && !inputs.hasActiveGoal) {
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);