@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
+ * `/release-notes` slash command — show the changelog for the running CLI version.
3
+ *
4
+ * /release-notes → current version's notes
5
+ * /release-notes <version> → specific version
6
+ * /release-notes list → every shipped version, sorted descending
7
+ *
8
+ * Changelog files ship inside the published package at `changelog/<version>.md`.
9
+ * The repo-root `brainrouter-changelog/` is copied into `brainrouter-cli/changelog/`
10
+ * by `prepublishOnly` so users who install via npm see them.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import chalk from 'chalk';
16
+ const MAX_LINES = 200;
17
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
18
+ export async function tryHandleReleaseNotesCommand(ctx, deps = {}) {
19
+ if (ctx.command !== '/release-notes')
20
+ return false;
21
+ const out = runReleaseNotes(ctx.args, deps);
22
+ console.log(out);
23
+ return true;
24
+ }
25
+ /**
26
+ * Pure handler — returns the rendered string. Split from `tryHandle*` so unit
27
+ * tests can assert on the output without capturing stdout.
28
+ */
29
+ export function runReleaseNotes(args, deps = {}) {
30
+ const dir = deps.changelogDir ?? defaultChangelogDir();
31
+ const sub = (args[0] ?? '').toLowerCase();
32
+ if (sub === 'list')
33
+ return renderList(dir);
34
+ let version;
35
+ if (sub) {
36
+ if (!SEMVER_RE.test(sub)) {
37
+ return chalk.red(`Not a valid semver: "${args[0]}". Try /release-notes list.`);
38
+ }
39
+ version = sub;
40
+ }
41
+ else {
42
+ const v = deps.currentVersion ?? readCurrentVersion();
43
+ if (!v)
44
+ return chalk.red('Could not determine current CLI version.');
45
+ version = v;
46
+ }
47
+ const filePath = path.join(dir, `${version}.md`);
48
+ let body;
49
+ try {
50
+ body = fs.readFileSync(filePath, 'utf8');
51
+ }
52
+ catch {
53
+ return chalk.yellow(`no notes shipped for ${version}`);
54
+ }
55
+ return truncate(body, version);
56
+ }
57
+ function renderList(dir) {
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(dir);
61
+ }
62
+ catch {
63
+ return chalk.yellow('No bundled changelog directory found.');
64
+ }
65
+ const versions = entries
66
+ .filter((f) => f.endsWith('.md'))
67
+ .map((f) => f.slice(0, -3))
68
+ .filter((v) => SEMVER_RE.test(v))
69
+ .sort(compareSemverDesc);
70
+ if (versions.length === 0)
71
+ return chalk.yellow('No changelog versions bundled.');
72
+ return versions.join('\n');
73
+ }
74
+ function truncate(body, version) {
75
+ const lines = body.split('\n');
76
+ if (lines.length <= MAX_LINES)
77
+ return body;
78
+ const head = lines.slice(0, MAX_LINES).join('\n');
79
+ return `${head}\n\n…truncated at ${MAX_LINES} lines. Run \`/release-notes ${version}\` on its own to scroll the full file in a fresh paginator.`;
80
+ }
81
+ function compareSemverDesc(a, b) {
82
+ const pa = a.split(/[-+]/)[0].split('.').map(Number);
83
+ const pb = b.split(/[-+]/)[0].split('.').map(Number);
84
+ for (let i = 0; i < 3; i++) {
85
+ if (pa[i] !== pb[i])
86
+ return pb[i] - pa[i];
87
+ }
88
+ // Identical core → keep pre-release sort stable by string compare (descending).
89
+ return b.localeCompare(a);
90
+ }
91
+ // --- Package-root resolution -------------------------------------------------
92
+ /**
93
+ * `brainrouter-cli/changelog/` — relative to this compiled file. The dist
94
+ * layout mirrors src, so both `src/cli/commands/releaseNotes.ts` (dev/tsx)
95
+ * and `dist/cli/commands/releaseNotes.js` (built) resolve to the same root.
96
+ */
97
+ function defaultChangelogDir() {
98
+ return fileURLToPath(new URL('../../../changelog', import.meta.url));
99
+ }
100
+ function readCurrentVersion() {
101
+ try {
102
+ const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
103
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
104
+ return pkg.version;
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `/schedule` slash command — recurring cron + one-shot dispatch.
3
+ *
4
+ * Recurring : /schedule cron "*\/15 * * * *" /ci-status
5
+ * One-shot : /schedule in 30s /agents
6
+ * /schedule at 14:30 /agents
7
+ * Management : /schedule list
8
+ * /schedule remove <id>
9
+ * /schedule disable <id>
10
+ * /schedule enable <id>
11
+ *
12
+ * The dispatched command runs in the SAME session that registered the
13
+ * schedule (we use `agent.sessionKey` as the owner). The ticker filters
14
+ * by owner — if a different REPL is open against the same workspace,
15
+ * it won't fire someone else's jobs.
16
+ */
17
+ import type { CommandContext } from './_context.js';
18
+ export declare function tryHandleScheduleCommand(ctx: CommandContext): Promise<boolean>;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * `/schedule` slash command — recurring cron + one-shot dispatch.
3
+ *
4
+ * Recurring : /schedule cron "*\/15 * * * *" /ci-status
5
+ * One-shot : /schedule in 30s /agents
6
+ * /schedule at 14:30 /agents
7
+ * Management : /schedule list
8
+ * /schedule remove <id>
9
+ * /schedule disable <id>
10
+ * /schedule enable <id>
11
+ *
12
+ * The dispatched command runs in the SAME session that registered the
13
+ * schedule (we use `agent.sessionKey` as the owner). The ticker filters
14
+ * by owner — if a different REPL is open against the same workspace,
15
+ * it won't fire someone else's jobs.
16
+ */
17
+ import chalk from 'chalk';
18
+ import { parseInterval } from '../../runtime/loopRunner.js';
19
+ import { parseCron, nextCronFire } from '../../runtime/cronParser.js';
20
+ import { addSchedule, loadSchedules, removeSchedule, setScheduleEnabled, } from '../../state/scheduleStore.js';
21
+ export async function tryHandleScheduleCommand(ctx) {
22
+ if (ctx.command !== '/schedule')
23
+ return false;
24
+ const { args, agent } = ctx;
25
+ const sub = (args[0] ?? '').toLowerCase();
26
+ if (!sub || sub === 'list') {
27
+ renderList(agent.workspaceRoot, agent.sessionKey);
28
+ return true;
29
+ }
30
+ if (sub === 'remove' || sub === 'rm') {
31
+ const id = args[1];
32
+ if (!id) {
33
+ console.log(chalk.red('\nUsage: /schedule remove <id>\n'));
34
+ return true;
35
+ }
36
+ const ok = removeSchedule(agent.workspaceRoot, id);
37
+ console.log(ok
38
+ ? chalk.green(`\n✓ Removed ${id}.\n`)
39
+ : chalk.yellow(`\nNo schedule with id ${id}.\n`));
40
+ return true;
41
+ }
42
+ if (sub === 'disable' || sub === 'enable') {
43
+ const id = args[1];
44
+ if (!id) {
45
+ console.log(chalk.red(`\nUsage: /schedule ${sub} <id>\n`));
46
+ return true;
47
+ }
48
+ const ok = setScheduleEnabled(agent.workspaceRoot, id, sub === 'enable');
49
+ console.log(ok
50
+ ? chalk.green(`\n✓ ${sub === 'enable' ? 'Enabled' : 'Disabled'} ${id}.\n`)
51
+ : chalk.yellow(`\nNo schedule with id ${id}.\n`));
52
+ return true;
53
+ }
54
+ if (sub === 'cron') {
55
+ // Need to re-join because the splitter cracked the quoted cron expr
56
+ // across tokens. Re-join args after the leading "cron".
57
+ const rest = args.slice(1).join(' ').trim();
58
+ const m = /^"([^"]+)"\s+(\/\S.*)$/.exec(rest);
59
+ if (!m) {
60
+ console.log(chalk.red('\nUsage: /schedule cron "<expr>" /command'));
61
+ console.log(chalk.gray(' e.g. /schedule cron "*/15 * * * *" /ci-status\n'));
62
+ return true;
63
+ }
64
+ const expr = m[1];
65
+ const command = m[2].trim();
66
+ if (!command.startsWith('/')) {
67
+ console.log(chalk.red('\nSchedule only dispatches slash commands (must start with `/`).\n'));
68
+ return true;
69
+ }
70
+ const cron = parseCron(expr);
71
+ if (!cron) {
72
+ console.log(chalk.red(`\nInvalid cron expression: "${expr}"`));
73
+ console.log(chalk.gray(' Expected 5 fields: minute hour dom month dow\n'));
74
+ return true;
75
+ }
76
+ const nextRun = nextCronFire(cron, new Date());
77
+ const rec = addSchedule(agent.workspaceRoot, {
78
+ kind: 'cron',
79
+ expr,
80
+ command,
81
+ owner: agent.sessionKey,
82
+ nextRun: nextRun.toISOString(),
83
+ });
84
+ console.log(chalk.green(`\n✓ Registered ${rec.id}: cron "${expr}" → ${command}`));
85
+ console.log(chalk.gray(` Next fire: ${formatWhen(nextRun)}\n`));
86
+ return true;
87
+ }
88
+ if (sub === 'in') {
89
+ const ms = parseInterval(args[1] ?? '');
90
+ const command = args.slice(2).join(' ').trim();
91
+ if (!ms || !command) {
92
+ console.log(chalk.red('\nUsage: /schedule in <duration> /command'));
93
+ console.log(chalk.gray(' e.g. /schedule in 5m /ci-status\n'));
94
+ return true;
95
+ }
96
+ if (!command.startsWith('/')) {
97
+ console.log(chalk.red('\nSchedule only dispatches slash commands.\n'));
98
+ return true;
99
+ }
100
+ const nextRun = new Date(Date.now() + ms);
101
+ const rec = addSchedule(agent.workspaceRoot, {
102
+ kind: 'once',
103
+ expr: nextRun.toISOString(),
104
+ command,
105
+ owner: agent.sessionKey,
106
+ nextRun: nextRun.toISOString(),
107
+ });
108
+ console.log(chalk.green(`\n✓ Registered ${rec.id}: one-shot in ${args[1]} → ${command}`));
109
+ console.log(chalk.gray(` Fires at: ${formatWhen(nextRun)}\n`));
110
+ return true;
111
+ }
112
+ if (sub === 'at') {
113
+ const time = args[1] ?? '';
114
+ const command = args.slice(2).join(' ').trim();
115
+ const tm = /^(\d{1,2}):(\d{2})$/.exec(time);
116
+ if (!tm || !command) {
117
+ console.log(chalk.red('\nUsage: /schedule at HH:MM /command'));
118
+ console.log(chalk.gray(' e.g. /schedule at 14:30 /agents\n'));
119
+ return true;
120
+ }
121
+ if (!command.startsWith('/')) {
122
+ console.log(chalk.red('\nSchedule only dispatches slash commands.\n'));
123
+ return true;
124
+ }
125
+ const h = Number(tm[1]);
126
+ const min = Number(tm[2]);
127
+ if (h > 23 || min > 59) {
128
+ console.log(chalk.red('\nInvalid time. Hours 0-23, minutes 0-59.\n'));
129
+ return true;
130
+ }
131
+ const now = new Date();
132
+ const target = new Date(now);
133
+ target.setHours(h, min, 0, 0);
134
+ if (target.getTime() <= now.getTime())
135
+ target.setDate(target.getDate() + 1);
136
+ const rec = addSchedule(agent.workspaceRoot, {
137
+ kind: 'once',
138
+ expr: target.toISOString(),
139
+ command,
140
+ owner: agent.sessionKey,
141
+ nextRun: target.toISOString(),
142
+ });
143
+ console.log(chalk.green(`\n✓ Registered ${rec.id}: one-shot at ${time} → ${command}`));
144
+ console.log(chalk.gray(` Fires at: ${formatWhen(target)}\n`));
145
+ return true;
146
+ }
147
+ console.log(chalk.red(`\nUnknown subcommand: /schedule ${sub}`));
148
+ console.log(chalk.gray(' Try: list | cron "<expr>" /cmd | in <dur> /cmd | at HH:MM /cmd | remove <id> | disable <id> | enable <id>\n'));
149
+ return true;
150
+ }
151
+ function renderList(workspaceRoot, sessionKey) {
152
+ const all = loadSchedules(workspaceRoot);
153
+ const mine = all.filter((s) => s.owner === sessionKey);
154
+ if (mine.length === 0) {
155
+ console.log(chalk.yellow('\nNo schedules registered for this session.'));
156
+ console.log(chalk.gray(' Add one with /schedule cron "<expr>" /command or /schedule in 5m /command\n'));
157
+ return;
158
+ }
159
+ console.log(chalk.bold('\nSchedules'));
160
+ for (const s of mine) {
161
+ const status = s.enabled ? chalk.green('●') : chalk.gray('○');
162
+ const kind = s.kind === 'cron' ? `cron "${s.expr}"` : `once`;
163
+ console.log(` ${status} ${chalk.cyan(s.id)} ${chalk.gray(kind.padEnd(28))} → ${s.command}`);
164
+ console.log(` ${chalk.gray(`next: ${formatWhen(new Date(s.nextRun))}${s.lastRun ? ` · last: ${formatWhen(new Date(s.lastRun))}` : ''}`)}`);
165
+ }
166
+ console.log();
167
+ }
168
+ function formatWhen(d) {
169
+ if (!Number.isFinite(d.getTime()))
170
+ return '(invalid)';
171
+ const now = Date.now();
172
+ const delta = d.getTime() - now;
173
+ const abs = Math.abs(delta);
174
+ const human = humanDelta(abs);
175
+ const rel = delta >= 0 ? `in ${human}` : `${human} ago`;
176
+ return `${d.toISOString().replace('T', ' ').slice(0, 16)} (${rel})`;
177
+ }
178
+ function humanDelta(ms) {
179
+ const s = Math.round(ms / 1000);
180
+ if (s < 60)
181
+ return `${s}s`;
182
+ const m = Math.round(s / 60);
183
+ if (m < 60)
184
+ return `${m}m`;
185
+ const h = Math.round(m / 60);
186
+ if (h < 48)
187
+ return `${h}h`;
188
+ return `${Math.round(h / 24)}d`;
189
+ }
@@ -8,14 +8,21 @@ import { execSync } from 'node:child_process';
8
8
  import chalk from 'chalk';
9
9
  import { spinner as makeSpinner } from '../spinner.js';
10
10
  import { LOCAL_TOOLS } from '../../agent/agent.js';
11
- import { callMcpTool } from '../../runtime/mcpUtils.js';
11
+ import { callMcpTool, hasMcpTool } 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:'));
@@ -126,7 +116,7 @@ export async function tryHandleUiCommand(ctx) {
126
116
  const toolNames = new Set((res.tools || []).map((tool) => tool.name));
127
117
  const memoryTools = ['memory_recall', 'memory_capture_turn', 'memory_working_offload'];
128
118
  for (const name of memoryTools) {
129
- const hasTool = toolNames.has(name);
119
+ const hasTool = hasMcpTool(toolNames, name);
130
120
  console.log(` ${name}: ${hasTool ? chalk.green('available') : chalk.yellow('not exposed')}`);
131
121
  }
132
122
  }
@@ -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;