@kinqs/brainrouter-cli 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/dist/agent/agent.d.ts +12 -1
  8. package/dist/agent/agent.js +134 -18
  9. package/dist/cli/banner.d.ts +20 -0
  10. package/dist/cli/banner.js +47 -14
  11. package/dist/cli/cliPrompt.d.ts +40 -3
  12. package/dist/cli/cliPrompt.js +52 -25
  13. package/dist/cli/commands/_context.d.ts +3 -1
  14. package/dist/cli/commands/_helpers.d.ts +1 -1
  15. package/dist/cli/commands/config.d.ts +46 -0
  16. package/dist/cli/commands/config.js +1042 -0
  17. package/dist/cli/commands/init.d.ts +20 -0
  18. package/dist/cli/commands/init.js +64 -0
  19. package/dist/cli/commands/login.d.ts +13 -0
  20. package/dist/cli/commands/login.js +179 -0
  21. package/dist/cli/commands/mcp.d.ts +13 -11
  22. package/dist/cli/commands/mcp.js +239 -74
  23. package/dist/cli/commands/orchestration.js +18 -0
  24. package/dist/cli/commands/ui.js +117 -58
  25. package/dist/cli/commands/workflow.d.ts +2 -0
  26. package/dist/cli/commands/workflow.js +54 -8
  27. package/dist/cli/ink/ChatApp.d.ts +206 -0
  28. package/dist/cli/ink/ChatApp.js +493 -0
  29. package/dist/cli/ink/Frame.d.ts +26 -0
  30. package/dist/cli/ink/Frame.js +5 -0
  31. package/dist/cli/ink/Picker.d.ts +65 -0
  32. package/dist/cli/ink/Picker.js +133 -0
  33. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  34. package/dist/cli/ink/SlashPalette.js +136 -0
  35. package/dist/cli/ink/TextField.d.ts +34 -0
  36. package/dist/cli/ink/TextField.js +47 -0
  37. package/dist/cli/ink/WizardApp.d.ts +7 -0
  38. package/dist/cli/ink/WizardApp.js +422 -0
  39. package/dist/cli/ink/ambientChat.d.ts +34 -0
  40. package/dist/cli/ink/ambientChat.js +7 -0
  41. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  42. package/dist/cli/ink/consoleCapture.js +33 -0
  43. package/dist/cli/ink/markdownRender.d.ts +41 -0
  44. package/dist/cli/ink/markdownRender.js +278 -0
  45. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  46. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  47. package/dist/cli/ink/runChat.d.ts +34 -0
  48. package/dist/cli/ink/runChat.js +571 -0
  49. package/dist/cli/ink/runPicker.d.ts +31 -0
  50. package/dist/cli/ink/runPicker.js +139 -0
  51. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  52. package/dist/cli/ink/runSlashPalette.js +33 -0
  53. package/dist/cli/ink/runWizard.d.ts +22 -0
  54. package/dist/cli/ink/runWizard.js +133 -0
  55. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  56. package/dist/cli/ink/stdinHandoff.js +78 -0
  57. package/dist/cli/ink/toolFormat.d.ts +73 -0
  58. package/dist/cli/ink/toolFormat.js +180 -0
  59. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  60. package/dist/cli/ink/useTerminalSize.js +26 -0
  61. package/dist/cli/repl.d.ts +25 -3
  62. package/dist/cli/repl.js +43 -712
  63. package/dist/cli/slashSuggest.d.ts +32 -0
  64. package/dist/cli/slashSuggest.js +146 -0
  65. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  66. package/dist/cli/wizard/modelsApi.js +166 -0
  67. package/dist/cli/wizard/picker.d.ts +202 -0
  68. package/dist/cli/wizard/picker.js +547 -0
  69. package/dist/cli/wizard/providers.d.ts +86 -0
  70. package/dist/cli/wizard/providers.js +190 -0
  71. package/dist/cli/wizard/runner.d.ts +13 -0
  72. package/dist/cli/wizard/runner.js +488 -0
  73. package/dist/cli/wizard/types.d.ts +122 -0
  74. package/dist/cli/wizard/types.js +109 -0
  75. package/dist/config/config.d.ts +12 -0
  76. package/dist/config/config.js +45 -3
  77. package/dist/index.js +148 -206
  78. package/dist/memory/briefing.d.ts +1 -1
  79. package/dist/memory/consolidation.d.ts +1 -1
  80. package/dist/orchestration/agentRegistry.d.ts +36 -0
  81. package/dist/orchestration/agentRegistry.js +64 -0
  82. package/dist/orchestration/orchestrator.d.ts +7 -0
  83. package/dist/orchestration/orchestrator.js +2 -0
  84. package/dist/orchestration/tools.d.ts +10 -1
  85. package/dist/orchestration/tools.js +48 -4
  86. package/dist/prompt/skillCatalog.d.ts +11 -0
  87. package/dist/prompt/skillCatalog.js +134 -0
  88. package/dist/prompt/skillRunner.d.ts +2 -2
  89. package/dist/prompt/skillRunner.js +2 -31
  90. package/dist/prompt/systemPrompt.js +5 -1
  91. package/dist/runtime/mcpClient.js +14 -11
  92. package/dist/runtime/mcpPool.d.ts +162 -0
  93. package/dist/runtime/mcpPool.js +423 -0
  94. package/dist/runtime/mcpUtils.d.ts +3 -1
  95. package/package.json +8 -2
  96. package/.env.example +0 -116
@@ -1,53 +1,71 @@
1
1
  /**
2
- * 0.3.6 item 11: `/mcp` slash-command surface. Scope-limited foundation
3
- * the full multi-MCP federation (parallel cross-MCP tool calls, MCP
4
- * marketplace, capability tiers) is deferred to 0.4.0. What ships here:
2
+ * `/mcp` slash-command surface. 0.3.7 multi-MCP rewrite third-party
3
+ * MCPs connect concurrently while BrainRouter MCP profiles are mutually
4
+ * exclusive so exactly one brain is active at a time.
5
5
  *
6
- * /mcp show status of the active MCP (alias for /mcp list)
7
- * /mcp list list every configured profile with identity + status
8
- * /mcp reconnectreconnect the currently-active profile
9
- * /mcp tools — list MCP tools grouped by namespace (pre-Item-11
10
- * `/mcp` no-arg behaviour, moved here verbatim)
6
+ * /mcp — alias for /mcp list
7
+ * /mcp list — every configured profile + per-server status
8
+ * /mcp tools [server] MCP tools grouped by `mcp__<server>__*`
9
+ * namespace; pass a server id to scope
10
+ * /mcp connect <name> — connect a configured server that's idle/offline
11
+ * /mcp disconnect <name> — close one server's transport (config preserved)
12
+ * /mcp reconnect [name] — reconnect ONE (when name given) or the selected pool
11
13
  *
12
- * The reconnect path leans on `mcpClient.close()` + `mcpClient.connect()`
13
- * with the same config the CLI launched against no plumbing required
14
- * for the user beyond typing the command.
14
+ * Backwards-compat: `/mcp reconnect` with no arg reconnects the same
15
+ * selected set used at boot: all third-party MCPs plus the active
16
+ * BrainRouter MCP. Pass an explicit name to target one.
15
17
  */
16
18
  import chalk from 'chalk';
17
19
  import { spinner as makeSpinner } from '../spinner.js';
20
+ import { saveConfig } from '../../config/config.js';
21
+ import { resolveIdentityFromConfig } from '../../runtime/mcpClient.js';
22
+ import { selectMcpServerIds } from '../../runtime/mcpPool.js';
23
+ import { buildBannerInputs, renderBanner } from '../banner.js';
24
+ import { resolveTheme } from '../theme.js';
18
25
  export async function tryHandleMcpCommand(ctx) {
19
26
  const { command, args, mcpClient, config } = ctx;
20
27
  if (command !== '/mcp')
21
28
  return false;
22
29
  const sub = (args[0] ?? 'list').toLowerCase();
30
+ const targetName = args[1]?.trim();
23
31
  if (sub === 'tools') {
24
- // Pre-Item-11 `/mcp` no-arg behaviour: namespace-grouped tool listing.
25
- // Kept verbatim under a subcommand so the muscle memory survives.
26
- const profileName = config.activeServer;
27
- const server = config.servers[profileName];
28
- console.log(chalk.bold('\nMCP server'));
29
- console.log(` Profile: ${chalk.green(profileName)} (${chalk.cyan(server?.type ?? 'unknown')})`);
30
- if (server?.type === 'http') {
31
- console.log(` URL: ${chalk.blue(server.url ?? '')}`);
32
- }
33
- else if (server?.type === 'stdio') {
34
- console.log(` Cmd: ${chalk.blue(server.command ?? '')} ${server.args?.join(' ') || ''}`);
35
- }
36
- const spinner = makeSpinner(chalk.gray('Fetching MCP tool surface...')).start();
32
+ const onlyServer = targetName;
33
+ console.log(chalk.bold('\nMCP tools (pooled)'));
34
+ const statuses = mcpClient.getStatuses();
35
+ const connected = statuses.filter((s) => s.status === 'connected');
36
+ if (connected.length === 0) {
37
+ console.log(chalk.yellow(' No MCP servers connected. Try /mcp reconnect.\n'));
38
+ return true;
39
+ }
40
+ const spinner = makeSpinner(chalk.gray('Fetching pooled tool surface...')).start();
37
41
  try {
38
42
  const res = await mcpClient.listTools();
39
- const tools = res.tools || [];
40
- spinner.succeed(chalk.green(`${tools.length} MCP tools available`));
41
- const namespaces = {};
42
- for (const t of tools) {
43
- const parts = (t.name || '').split('_');
44
- const ns = parts.length > 1 ? parts[0] : 'misc';
45
- (namespaces[ns] ||= []).push(t.name);
43
+ const allTools = res.tools || [];
44
+ spinner.succeed(chalk.green(`${allTools.length} tools across ${connected.length} server${connected.length === 1 ? '' : 's'}`));
45
+ // Pool tools are exposed as `mcp__<serverId>__<rawTool>`. Group by serverId.
46
+ const byServer = {};
47
+ for (const t of allTools) {
48
+ const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(t.name);
49
+ const serverId = m?.[1] ?? '__unknown__';
50
+ const raw = m?.[2] ?? t.name;
51
+ if (onlyServer && serverId !== onlyServer)
52
+ continue;
53
+ (byServer[serverId] ||= []).push(raw);
46
54
  }
47
- for (const ns of Object.keys(namespaces).sort()) {
48
- console.log(`\n ${chalk.bold.cyan(ns)} (${namespaces[ns].length})`);
49
- for (const name of namespaces[ns].sort()) {
50
- console.log(` ${chalk.gray('•')} ${name}`);
55
+ const serverIds = Object.keys(byServer).sort();
56
+ if (serverIds.length === 0) {
57
+ console.log(chalk.gray(`\n No tools for server "${onlyServer}".\n`));
58
+ return true;
59
+ }
60
+ for (const section of groupServerIdsByEcosystem(serverIds, (id) => mcpClient.getStatus(id)?.identity ?? 'unknown')) {
61
+ console.log(`\n${section.title}`);
62
+ for (const id of section.ids) {
63
+ const ident = mcpClient.getStatus(id)?.identity ?? 'unknown';
64
+ const identTag = formatIdentityTag(ident);
65
+ console.log(` ${chalk.bold.green(id)} ${identTag} (${byServer[id].length})`);
66
+ for (const name of byServer[id].sort()) {
67
+ console.log(` ${chalk.gray('•')} ${name} ${chalk.gray(`mcp__${id}__${name}`)}`);
68
+ }
51
69
  }
52
70
  }
53
71
  }
@@ -58,64 +76,211 @@ export async function tryHandleMcpCommand(ctx) {
58
76
  return true;
59
77
  }
60
78
  if (sub === 'list') {
61
- const activeName = config.activeServer;
62
79
  const profiles = Object.keys(config.servers ?? {});
63
80
  if (profiles.length === 0) {
64
- console.log(chalk.yellow('\nNo MCP profiles configured. Run `brainrouter login` or `brainrouter config` to set one up.\n'));
81
+ console.log(chalk.yellow('\nNo MCP profiles configured. Run `/login` or `brainrouter config` to set one up.\n'));
65
82
  return true;
66
83
  }
67
- console.log(chalk.bold('\nConfigured MCP profiles'));
68
- for (const name of profiles) {
84
+ console.log(chalk.bold('\nMCP servers'));
85
+ const statuses = mcpClient.getStatuses();
86
+ const statusById = new Map(statuses.map((s) => [s.serverId, s]));
87
+ const activeName = config.activeServer;
88
+ for (const section of groupServerIdsByEcosystem(profiles, (name) => {
69
89
  const profile = config.servers[name];
70
- const isActive = name === activeName;
71
- // Identity: explicit config field > live wrapper > 'unknown'.
72
- let identity = profile.identity ?? 'unknown';
73
- if (isActive && typeof mcpClient.getIdentity === 'function') {
74
- identity = mcpClient.getIdentity();
90
+ return statusById.get(name)?.identity ?? profile?.identity ?? 'unknown';
91
+ })) {
92
+ console.log(`\n${section.title}`);
93
+ for (const name of section.ids) {
94
+ const profile = config.servers[name];
95
+ const poolStatus = statusById.get(name);
96
+ // Identity: pool live > config metadata > 'unknown'.
97
+ const identity = poolStatus?.identity ?? profile.identity ?? 'unknown';
98
+ // Status: pool live > 'not in pool' (configured but never tried).
99
+ const liveStatus = poolStatus?.status;
100
+ const statusLabel = liveStatus === 'connected' ? chalk.green('online') :
101
+ liveStatus === 'failed' ? chalk.red('failed') :
102
+ liveStatus === 'connecting' ? chalk.yellow('connecting') :
103
+ liveStatus === 'offline' ? chalk.gray('disconnected') :
104
+ chalk.gray('idle');
105
+ const idLabel = formatIdentityTag(identity);
106
+ const marker = name === activeName ? chalk.bold('★ ') : ' ';
107
+ const transport = profile.type;
108
+ const target = profile.type === 'http' ? profile.url ?? '<no url>' : profile.command ?? '<no command>';
109
+ const toolTag = poolStatus?.toolCount != null ? chalk.gray(`${poolStatus.toolCount} tools`) : '';
110
+ const errTag = liveStatus === 'failed' && poolStatus?.error ? chalk.red(` · ${poolStatus.error}`) : '';
111
+ console.log(`${marker}${chalk.bold(name)} ${idLabel} ${transport} ${statusLabel} ${chalk.gray(target)} ${toolTag}${errTag}`);
75
112
  }
76
- const onlineLabel = isActive
77
- ? (mcpClient.isConnected() ? chalk.green('online') : chalk.red('offline'))
78
- : chalk.gray('idle');
79
- const idLabel = identity === 'brainrouter'
80
- ? chalk.cyan('brainrouter')
81
- : identity === 'third-party'
82
- ? chalk.yellow('third-party')
83
- : chalk.gray('unknown');
84
- const marker = isActive ? chalk.bold('★ ') : ' ';
85
- const transport = profile.type;
86
- const target = profile.type === 'http' ? profile.url ?? '<no url>' : profile.command ?? '<no command>';
87
- console.log(`${marker}${chalk.bold(name)} ${idLabel} ${transport} ${onlineLabel} ${chalk.gray(target)}`);
88
- }
89
- console.log(chalk.gray('\n★ = active profile. /mcp reconnect to refresh the active connection.\n'));
113
+ }
114
+ console.log(chalk.gray('\n★ = highlighted profile in the banner.'));
115
+ console.log(chalk.gray(' Multi-MCP: third-party MCPs connect together; only one BrainRouter MCP is active. Use /mcp connect|disconnect|reconnect <name> to manage.\n'));
90
116
  return true;
91
117
  }
92
118
  if (sub === 'reconnect') {
93
- const activeName = config.activeServer;
94
- const profile = config.servers?.[activeName];
95
- if (!profile) {
96
- console.log(chalk.red(`\nNo active MCP profile to reconnect (\`activeServer\` = ${JSON.stringify(activeName)}).\n`));
119
+ if (!targetName) {
120
+ // No name → reconnect every configured server in the pool.
121
+ const ids = selectMcpServerIds(config.servers ?? {}, config.activeServer);
122
+ if (ids.length === 0) {
123
+ console.log(chalk.red(`\nNo MCP profiles configured.\n`));
124
+ return true;
125
+ }
126
+ console.log(chalk.gray(`Reconnecting all servers (${ids.length})…`));
127
+ await Promise.allSettled(ids.map(async (id) => {
128
+ try {
129
+ await mcpClient.reconnectOne(id);
130
+ const s = mcpClient.getStatus(id);
131
+ if (s?.status === 'connected') {
132
+ console.log(chalk.green(` ✓ ${id}`));
133
+ }
134
+ else {
135
+ console.log(chalk.red(` ✗ ${id} — ${s?.error ?? 'failed'}`));
136
+ }
137
+ }
138
+ catch (err) {
139
+ console.log(chalk.red(` ✗ ${id} — ${err?.message ?? err}`));
140
+ }
141
+ }));
142
+ console.log();
97
143
  return true;
98
144
  }
99
- console.log(chalk.gray(`Reconnecting "${activeName}"…`));
145
+ if (!config.servers?.[targetName]) {
146
+ console.log(chalk.red(`\nNo profile named "${targetName}".\n`));
147
+ return true;
148
+ }
149
+ console.log(chalk.gray(`Reconnecting "${targetName}"…`));
100
150
  try {
101
- try {
102
- await mcpClient.close();
151
+ await disconnectOtherBrainrouterServers(ctx, targetName);
152
+ await mcpClient.reconnectOne(targetName);
153
+ const s = mcpClient.getStatus(targetName);
154
+ if (s?.status === 'connected') {
155
+ const activated = activateBrainrouterProfile(ctx, targetName);
156
+ console.log(chalk.green(`✓ Reconnected to "${targetName}".\n`));
157
+ if (activated) {
158
+ console.log(chalk.gray(` Active BrainRouter profile saved as "${targetName}" for this and future sessions.\n`));
159
+ printRefreshedBanner(ctx);
160
+ }
103
161
  }
104
- catch { /* idempotent */ }
105
- await mcpClient.connect(profile, config.llm, activeName);
106
- // Re-probe tools so identity tagging and the prompt's tool list refresh.
107
- try {
108
- await mcpClient.listTools();
162
+ else {
163
+ console.log(chalk.red(`✗ "${targetName}" remained ${s?.status ?? 'offline'} — ${s?.error ?? 'unknown'}\n`));
109
164
  }
110
- catch { /* tool-list failure is non-fatal */ }
111
- console.log(chalk.green(`✓ Reconnected to "${activeName}" (${profile.type}).\n`));
112
165
  }
113
166
  catch (err) {
114
167
  console.log(chalk.red(`✗ Reconnect failed: ${err?.message ?? err}\n`));
115
- console.log(chalk.gray('The CLI stays in offline mode. Check the MCP server, then try `/mcp reconnect` again.\n'));
116
168
  }
117
169
  return true;
118
170
  }
119
- console.log(chalk.red(`\nUnknown /mcp subcommand "${sub}". Usage: /mcp list | /mcp reconnect | /mcp tools\n`));
171
+ if (sub === 'connect') {
172
+ if (!targetName) {
173
+ console.log(chalk.red('\nUsage: /mcp connect <name>\n'));
174
+ return true;
175
+ }
176
+ const profile = config.servers?.[targetName];
177
+ if (!profile) {
178
+ console.log(chalk.red(`\nNo profile named "${targetName}". Available: ${Object.keys(config.servers ?? {}).join(', ') || '(none)'}.\n`));
179
+ return true;
180
+ }
181
+ console.log(chalk.gray(`Connecting "${targetName}"…`));
182
+ try {
183
+ await disconnectOtherBrainrouterServers(ctx, targetName);
184
+ await mcpClient.connectOne(targetName, profile, config.llm, 5_000);
185
+ const s = mcpClient.getStatus(targetName);
186
+ if (s?.status === 'connected') {
187
+ const activated = activateBrainrouterProfile(ctx, targetName);
188
+ console.log(chalk.green(`✓ "${targetName}" online (${s.toolCount ?? 0} tools).\n`));
189
+ if (activated) {
190
+ console.log(chalk.gray(` Active BrainRouter profile saved as "${targetName}" for this and future sessions.\n`));
191
+ printRefreshedBanner(ctx);
192
+ }
193
+ }
194
+ else {
195
+ console.log(chalk.red(`✗ "${targetName}" failed — ${s?.error ?? 'unknown'}\n`));
196
+ }
197
+ }
198
+ catch (err) {
199
+ console.log(chalk.red(`✗ Connect failed: ${err?.message ?? err}\n`));
200
+ }
201
+ return true;
202
+ }
203
+ if (sub === 'disconnect') {
204
+ if (!targetName) {
205
+ console.log(chalk.red('\nUsage: /mcp disconnect <name>\n'));
206
+ return true;
207
+ }
208
+ if (!mcpClient.getStatus(targetName)) {
209
+ console.log(chalk.yellow(`\n"${targetName}" is not in the pool.\n`));
210
+ return true;
211
+ }
212
+ try {
213
+ await mcpClient.disconnectOne(targetName);
214
+ console.log(chalk.green(`✓ "${targetName}" disconnected. Config preserved — /mcp connect ${targetName} to bring it back.\n`));
215
+ }
216
+ catch (err) {
217
+ console.log(chalk.red(`✗ Disconnect failed: ${err?.message ?? err}\n`));
218
+ }
219
+ return true;
220
+ }
221
+ console.log(chalk.red(`\nUnknown /mcp subcommand "${sub}". Usage: /mcp list | /mcp tools [server] | /mcp connect <name> | /mcp disconnect <name> | /mcp reconnect [name]\n`));
222
+ return true;
223
+ }
224
+ function formatIdentityTag(identity) {
225
+ return identity === 'brainrouter' ? chalk.cyan('brainrouter') :
226
+ identity === 'third-party' ? chalk.yellow('third-party') :
227
+ chalk.gray('unknown');
228
+ }
229
+ function groupServerIdsByEcosystem(ids, identityFor) {
230
+ const brainrouter = [];
231
+ const other = [];
232
+ for (const id of ids.slice().sort()) {
233
+ if (identityFor(id) === 'brainrouter')
234
+ brainrouter.push(id);
235
+ else
236
+ other.push(id);
237
+ }
238
+ const sections = [];
239
+ if (brainrouter.length > 0) {
240
+ sections.push({ title: chalk.bold.cyan('BrainRouter MCP (Our Ecosystem)'), ids: brainrouter });
241
+ }
242
+ if (other.length > 0) {
243
+ sections.push({ title: chalk.bold.yellow('Third-party MCPs (Other)'), ids: other });
244
+ }
245
+ return sections;
246
+ }
247
+ async function disconnectOtherBrainrouterServers(ctx, targetName) {
248
+ const targetProfile = ctx.config.servers?.[targetName];
249
+ if (!targetProfile)
250
+ return;
251
+ if (resolveIdentityFromConfig(targetProfile, targetName) !== 'brainrouter')
252
+ return;
253
+ for (const [id, profile] of Object.entries(ctx.config.servers ?? {})) {
254
+ if (id === targetName)
255
+ continue;
256
+ if (resolveIdentityFromConfig(profile, id) !== 'brainrouter')
257
+ continue;
258
+ const status = ctx.mcpClient.getStatus(id);
259
+ if (status?.status === 'connected' || status?.status === 'connecting') {
260
+ await ctx.mcpClient.disconnectOne(id);
261
+ }
262
+ }
263
+ }
264
+ function activateBrainrouterProfile(ctx, targetName) {
265
+ const status = ctx.mcpClient.getStatus(targetName);
266
+ const profile = ctx.config.servers?.[targetName];
267
+ const isBrainrouter = status?.identity === 'brainrouter' ||
268
+ (profile ? resolveIdentityFromConfig(profile, targetName) === 'brainrouter' : false);
269
+ if (!isBrainrouter)
270
+ return false;
271
+ ctx.config.activeServer = targetName;
272
+ saveConfig(ctx.config);
120
273
  return true;
121
274
  }
275
+ function printRefreshedBanner(ctx) {
276
+ const theme = resolveTheme(ctx.agent.workspaceRoot);
277
+ const banner = renderBanner(buildBannerInputs(ctx.config, ctx.agent, ctx.mcpClient), theme);
278
+ if (ctx.repl.replaceBanner) {
279
+ ctx.repl.replaceBanner('\n' + banner);
280
+ }
281
+ else {
282
+ console.log(chalk.gray('Updated active BrainRouter banner:'));
283
+ console.log(banner);
284
+ console.log();
285
+ }
286
+ }
@@ -5,6 +5,7 @@
5
5
  import chalk from 'chalk';
6
6
  import { childSessionKey } from '../../runtime/mcpUtils.js';
7
7
  import { listRoles } from '../../orchestration/roles.js';
8
+ import { listAll as listAgentDefs } from '../../orchestration/agentRegistry.js';
8
9
  import { formatSessionSummary, getSession, listSessions, reconcileStale } from '../../orchestration/orchestrator.js';
9
10
  import { readPreferences, writePreferences } from '../../state/preferencesStore.js';
10
11
  import { readTranscriptEntries } from '../../state/sessionStore.js';
@@ -26,6 +27,23 @@ export async function tryHandleOrchestrationCommand(ctx) {
26
27
  }
27
28
  case '/agents':
28
29
  {
30
+ if (args[0] === 'defs') {
31
+ const defs = listAgentDefs(agent.workspaceRoot);
32
+ console.log(chalk.bold('\nAgent Definitions:'));
33
+ const ID_W = Math.max(...defs.map((l) => l.def.id.length), 4) + 2;
34
+ const TIER_W = 12;
35
+ const SRC_W = 10;
36
+ console.log(chalk.gray(` ${'ID'.padEnd(ID_W)}${'TIER'.padEnd(TIER_W)}${'SOURCE'.padEnd(SRC_W)}PATH`));
37
+ for (const loaded of defs) {
38
+ const idStr = chalk.cyan(loaded.def.id.padEnd(ID_W));
39
+ const tierColor = loaded.def.tier === 'reasoning' ? chalk.blue : chalk.yellow;
40
+ const tierStr = tierColor(loaded.def.tier.padEnd(TIER_W));
41
+ const srcStr = chalk.gray(loaded.source.padEnd(SRC_W));
42
+ console.log(` ${idStr}${tierStr}${srcStr}${chalk.gray(loaded.filePath)}`);
43
+ }
44
+ console.log();
45
+ return true;
46
+ }
29
47
  reconcileStale(agent.workspaceRoot);
30
48
  const sessions = listSessions(agent.workspaceRoot);
31
49
  // `--json` for scripting. Emits a single JSON line on stdout so
@@ -12,10 +12,17 @@ import { callMcpTool } from '../../runtime/mcpUtils.js';
12
12
  import { listSessions, reconcileStale } from '../../orchestration/orchestrator.js';
13
13
  import { readPreferences, resolveEffort, writePreferences } from '../../state/preferencesStore.js';
14
14
  import { readPlan } from '../../state/taskStore.js';
15
- import { getConfigPath } from '../../config/config.js';
15
+ // initAgentMd usage moved to commands/init.ts (0.3.7 wizard). The
16
+ // legacy /config + /init switch cases here are gone — the dispatcher
17
+ // in repl.ts routes them to the new handlers first. getConfigPath
18
+ // stays in scope because /doctor still surfaces the path.
19
+ import { getConfigPath, saveConfig } from '../../config/config.js';
16
20
  import { copyToClipboard } from '../../runtime/clipboard.js';
17
- import { initAgentMd } from '../../prompt/initAgentMd.js';
18
21
  import { completeWorkspacePath, renderHelp } from '../repl.js';
22
+ import { PROVIDER_CATALOG, findProvider } from '../wizard/providers.js';
23
+ import { selectModel } from '../wizard/modelsApi.js';
24
+ import { buildTheme } from '../theme.js';
25
+ import { listFilesystemSkills } from '../../prompt/skillCatalog.js';
19
26
  export async function tryHandleUiCommand(ctx) {
20
27
  const { command, args, agent, mcpClient, config, rl, repl } = ctx;
21
28
  // 'ctx' alias to keep references to the old ReplContext name working
@@ -78,27 +85,10 @@ export async function tryHandleUiCommand(ctx) {
78
85
  console.log();
79
86
  return true;
80
87
  }
81
- case '/config':
82
- {
83
- console.log(chalk.bold('\n⚙️ Active Configuration:'));
84
- console.log(` File Path: ${chalk.blue(getConfigPath())}\n`);
85
- // Print config without API keys
86
- const scrubbedConfig = JSON.parse(JSON.stringify(config));
87
- if (scrubbedConfig.llm?.apiKey) {
88
- scrubbedConfig.llm.apiKey = 'br_••••••••••••••••';
89
- }
90
- for (const s of Object.values(scrubbedConfig.servers)) {
91
- const srv = s;
92
- if (srv.apiKey)
93
- srv.apiKey = 'br_••••••••••••••••';
94
- if (srv.env?.BRAINROUTER_API_KEY) {
95
- srv.env.BRAINROUTER_API_KEY = 'br_••••••••••••••••';
96
- }
97
- }
98
- console.log(chalk.gray(JSON.stringify(scrubbedConfig, null, 2)));
99
- console.log();
100
- return true;
101
- }
88
+ // /config now lives in commands/config.ts (0.3.7 settings home panel
89
+ // + verb-overloaded get/set). The dispatcher in repl.ts routes it
90
+ // before this case, so leaving anything here is dead — removed.
91
+ // Use `/config raw` if you want the old scrubbed-JSON dump.
102
92
  case '/doctor':
103
93
  {
104
94
  console.log(chalk.bold('\nBrainRouter Doctor:'));
@@ -178,30 +168,69 @@ export async function tryHandleUiCommand(ctx) {
178
168
  console.log();
179
169
  return true;
180
170
  }
181
- case '/init':
182
- {
183
- const result = initAgentMd(agent.workspaceRoot);
184
- if (result.status === 'created') {
185
- console.log(chalk.green(`\n✓ Created ${result.path}`));
186
- console.log(chalk.gray('Edit it to describe your project, conventions, and boundaries — any AGENT.md-aware coding agent will read it.\n'));
187
- }
188
- else {
189
- console.log(chalk.yellow(`\nFile already exists: ${result.path}`));
190
- console.log(chalk.gray('Open it and edit by hand if you want to refresh it.\n'));
191
- }
192
- return true;
193
- }
171
+ // /init is now the onboarding-wizard entrypoint (commands/init.ts).
172
+ // The AGENT.md-only path lives behind `/init agentmd` for back-compat.
173
+ // Routed before this case in repl.ts; no fall-through handler needed.
194
174
  case '/model':
195
175
  {
196
176
  const newModel = args[0];
197
- if (!newModel) {
198
- console.log(chalk.bold(`\nCurrent model: ${chalk.cyan(agent.getModel())}`));
199
- console.log(chalk.gray('Switch with: /model <model-name> (e.g. /model gpt-4o-mini, /model gpt-5, /model qwen2.5-coder)\n'));
177
+ const previous = agent.getModel();
178
+ // Direct-switch form `/model <name>` stays for scripts and muscle
179
+ // memory. No-arg opens the picker (0.3.7).
180
+ if (newModel) {
181
+ agent.setModel(newModel);
182
+ if (config.llm) {
183
+ config.llm.model = newModel;
184
+ saveConfig(config);
185
+ }
186
+ console.log(chalk.green(`\n✓ Model switched: ${chalk.gray(previous)} → ${chalk.cyan(newModel)}\n`));
200
187
  return true;
201
188
  }
202
- const previous = agent.getModel();
203
- agent.setModel(newModel);
204
- console.log(chalk.green(`\n✓ Model switched: ${chalk.gray(previous)} ${chalk.cyan(newModel)}\n`));
189
+ // No-arg open the picker. Resolves provider by matching the
190
+ // saved endpoint against PROVIDER_CATALOG; falls back to the
191
+ // OpenAI entry when nothing matches (the agent loop also
192
+ // defaults to OpenAI-compatible shapes).
193
+ const themeMode = readPreferences(agent.workspaceRoot).theme;
194
+ const theme = buildTheme(themeMode === 'mono' ? 'mono' : themeMode === 'light' ? 'light' : 'dark');
195
+ const llm = config.llm;
196
+ const provider = (llm?.endpoint && PROVIDER_CATALOG.find((p) => p.endpoint.replace(/\/$/, '') === (llm.endpoint ?? '').replace(/\/$/, ''))) ||
197
+ findProvider('openai');
198
+ const result = await selectModel({
199
+ theme,
200
+ provider,
201
+ apiKey: llm?.apiKey ?? '',
202
+ endpointOverride: llm?.endpoint,
203
+ currentModel: previous,
204
+ title: '/model — quick-swap',
205
+ badge: provider.label,
206
+ });
207
+ if (!result) {
208
+ console.log(chalk.yellow('\n /model cancelled.\n'));
209
+ return true;
210
+ }
211
+ if (result.model === previous) {
212
+ console.log(chalk.gray(`\n Model unchanged (${previous}).\n`));
213
+ return true;
214
+ }
215
+ // Cross-provider sanity check — if the picked model looks like
216
+ // a different vendor's namespace (anthropic/*, google/*, etc.)
217
+ // and the active provider isn't a multi-vendor gateway, warn so
218
+ // the user doesn't hit a confusing 404 on the next turn.
219
+ if (looksLikeForeignModel(result.model, provider)) {
220
+ console.log(chalk.yellow(`\n ⚠ "${result.model}" looks like a different provider's namespace. ` +
221
+ `Active endpoint: ${provider.label}.` +
222
+ `\n Run /config provider <id> to switch endpoints, or /model again to pick a native model.\n`));
223
+ }
224
+ agent.setModel(result.model);
225
+ if (config.llm) {
226
+ config.llm.model = result.model;
227
+ saveConfig(config);
228
+ }
229
+ const sourceTag = result.source === 'live' ? `live · ${result.liveCount} models` :
230
+ result.source === 'fallback' ? `offline · static catalog (${result.liveError ?? 'unknown'})` :
231
+ 'static catalog';
232
+ console.log(chalk.green(`\n✓ Model switched: ${chalk.gray(previous)} → ${chalk.cyan(result.model)}`));
233
+ console.log(chalk.gray(` Source: ${sourceTag}\n`));
205
234
  return true;
206
235
  }
207
236
  // /mcp moved to its own command file (commands/mcp.ts) as part of 0.3.6
@@ -410,12 +439,22 @@ export async function tryHandleUiCommand(ctx) {
410
439
  console.log(chalk.gray(' Drop a folder under skills/<category>/<name>/SKILL.md to register one.\n'));
411
440
  return true;
412
441
  }
413
- for (const root of roots) {
414
- const entries = fs.readdirSync(root, { withFileTypes: true });
415
- for (const entry of entries) {
416
- if (!entry.isDirectory())
417
- continue;
418
- console.log(chalk.cyan(` ${path.relative(agent.workspaceRoot, path.join(root, entry.name))}`));
442
+ const skills = listFilesystemSkills(agent.workspaceRoot);
443
+ if (skills.length > 0) {
444
+ console.log(chalk.gray(' Skills'));
445
+ for (const skill of skills) {
446
+ const category = skill.category ? `${skill.category}/` : '';
447
+ console.log(` • ${chalk.cyan(`${category}${skill.name}`)} (${chalk.gray(skill.scope ?? 'filesystem')})`);
448
+ }
449
+ }
450
+ if (fs.existsSync(pluginsRoot)) {
451
+ const entries = fs.readdirSync(pluginsRoot, { withFileTypes: true });
452
+ const pluginDirs = entries.filter((entry) => entry.isDirectory());
453
+ if (pluginDirs.length > 0) {
454
+ console.log(chalk.gray(' Plugin folders'));
455
+ for (const entry of pluginDirs) {
456
+ console.log(` • ${chalk.cyan(path.relative(agent.workspaceRoot, path.join(pluginsRoot, entry.name)))}`);
457
+ }
419
458
  }
420
459
  }
421
460
  console.log();
@@ -496,23 +535,19 @@ export async function tryHandleUiCommand(ctx) {
496
535
  case '/where':
497
536
  {
498
537
  const { gatherWhereInputs, renderWhere } = await import('../whereView.js');
538
+ const { resolveDisplayedMcpState } = await import('../banner.js');
499
539
  const { resolveTheme } = await import('../theme.js');
500
540
  const theme = resolveTheme(agent.workspaceRoot);
501
- const profileName = config.activeServer;
502
- const server = config.servers[profileName];
541
+ const displayedMcp = resolveDisplayedMcpState(config, mcpClient);
503
542
  const briefing = agent.getLastBriefing();
504
543
  const inputs = gatherWhereInputs({
505
544
  workspaceRoot: agent.workspaceRoot,
506
545
  sessionKey: agent.sessionKey,
507
546
  model: agent.getModel(),
508
- mcpProfile: profileName,
509
- mcpTransport: server?.type ?? 'unknown',
510
- mcpOnline: mcpClient.isConnected(),
511
- // 10c: identity flows from the live wrapper; falls back to the
512
- // config field when present, otherwise 'unknown'.
513
- mcpIdentity: typeof mcpClient.getIdentity === 'function'
514
- ? mcpClient.getIdentity()
515
- : (server?.identity ?? 'unknown'),
547
+ mcpProfile: displayedMcp.profile,
548
+ mcpTransport: displayedMcp.transport,
549
+ mcpOnline: displayedMcp.online,
550
+ mcpIdentity: displayedMcp.identity,
516
551
  accessMode: agent.getAccessMode(),
517
552
  recalledRecords: agent.getRecalledRecords(),
518
553
  briefingSources: briefing.sources,
@@ -527,3 +562,27 @@ export async function tryHandleUiCommand(ctx) {
527
562
  }
528
563
  return false;
529
564
  }
565
+ /**
566
+ * Heuristic — does the picked model id look like it belongs to a
567
+ * different vendor than the active provider's endpoint? Catches the
568
+ * common foot-gun of picking `anthropic/claude-*` while pointed at
569
+ * OpenAI direct, where the request 404s at the endpoint and the user
570
+ * has no obvious "you needed to switch endpoints" signal.
571
+ *
572
+ * Returns false for gateway providers (OpenRouter, "anthropic-via-gateway")
573
+ * since multi-vendor namespaces are expected there.
574
+ */
575
+ function looksLikeForeignModel(model, provider) {
576
+ // Gateways are vendor-agnostic by design.
577
+ if (provider.id === 'openrouter' || provider.id === 'anthropic-via-gateway')
578
+ return false;
579
+ const FOREIGN_PREFIXES = {
580
+ openai: ['anthropic/', 'google/', 'meta/', 'mistralai/', 'qwen/', 'deepseek/'],
581
+ deepseek: ['anthropic/', 'google/', 'openai/', 'meta/', 'mistralai/'],
582
+ gemini: ['anthropic/', 'openai/', 'meta/', 'mistralai/', 'deepseek/'],
583
+ lmstudio: [],
584
+ ollama: [],
585
+ };
586
+ const list = FOREIGN_PREFIXES[provider.id] ?? [];
587
+ return list.some((prefix) => model.startsWith(prefix));
588
+ }
@@ -3,6 +3,7 @@
3
3
  * Hand-tune imports if the compiler complains.
4
4
  */
5
5
  import type { CommandContext } from './_context.js';
6
+ import { type SkillListItem } from '../../prompt/skillCatalog.js';
6
7
  /**
7
8
  * Decide whether `/grill-me` should refuse to fire because the current
8
9
  * workflow already has a written `spec.md`. The clarifying pass is meant to
@@ -22,3 +23,4 @@ export declare function shouldSkipGrillMe(workspaceRoot: string, force: boolean,
22
23
  specPath?: string;
23
24
  };
24
25
  export declare function tryHandleWorkflowCommand(ctx: CommandContext): Promise<boolean>;
26
+ export declare function normalizeSkillsList(payload: any): SkillListItem[] | undefined;