@kinqs/brainrouter-cli 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog/0.2.0.md +15 -0
- package/changelog/0.3.0.md +20 -0
- package/changelog/0.3.1.md +22 -0
- package/changelog/0.3.2.md +15 -0
- package/changelog/0.3.3.md +19 -0
- package/changelog/0.3.4.md +20 -0
- package/changelog/0.3.5.md +9 -0
- package/changelog/0.3.6.md +9 -0
- package/changelog/0.3.7.md +20 -0
- package/changelog/0.3.8.md +30 -0
- package/changelog/README.md +41 -0
- package/dist/agent/agent.d.ts +22 -0
- package/dist/agent/agent.js +259 -82
- package/dist/agent/toolCallRecovery.d.ts +57 -0
- package/dist/agent/toolCallRecovery.js +130 -0
- package/dist/agent/toolSafety.d.ts +17 -0
- package/dist/agent/toolSafety.js +102 -0
- package/dist/cli/banner.js +2 -2
- package/dist/cli/cliPrompt.js +65 -0
- package/dist/cli/commands/config.js +1 -1
- package/dist/cli/commands/mcp.d.ts +1 -1
- package/dist/cli/commands/mcp.js +29 -7
- package/dist/cli/commands/mcpInstall.d.ts +20 -0
- package/dist/cli/commands/mcpInstall.js +87 -0
- package/dist/cli/commands/orchestration.js +33 -0
- package/dist/cli/commands/releaseNotes.d.ts +24 -0
- package/dist/cli/commands/releaseNotes.js +109 -0
- package/dist/cli/commands/schedule.d.ts +18 -0
- package/dist/cli/commands/schedule.js +189 -0
- package/dist/cli/commands/ui.js +2 -2
- package/dist/cli/ink/Picker.d.ts +6 -0
- package/dist/cli/ink/Picker.js +41 -6
- package/dist/cli/ink/runChat.js +112 -1
- package/dist/cli/ink/toolFormat.d.ts +11 -9
- package/dist/cli/ink/toolFormat.js +42 -16
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +9 -2
- package/dist/config/config.d.ts +1 -1
- package/dist/index.js +10 -1
- package/dist/memory/briefing.js +4 -4
- package/dist/orchestration/tools.d.ts +95 -2
- package/dist/orchestration/tools.js +119 -4
- package/dist/prompt/systemPrompt.js +5 -4
- package/dist/runtime/anthropicAdapter.d.ts +100 -0
- package/dist/runtime/anthropicAdapter.js +293 -0
- package/dist/runtime/cronParser.d.ts +23 -0
- package/dist/runtime/cronParser.js +122 -0
- package/dist/runtime/mcpClient.js +1 -1
- package/dist/runtime/mcpPool.d.ts +8 -0
- package/dist/runtime/mcpPool.js +19 -0
- package/dist/runtime/mcpUtils.d.ts +14 -0
- package/dist/runtime/mcpUtils.js +23 -0
- package/dist/runtime/scheduleTicker.d.ts +33 -0
- package/dist/runtime/scheduleTicker.js +99 -0
- package/dist/runtime/vendorSnippets.d.ts +45 -0
- package/dist/runtime/vendorSnippets.js +153 -0
- package/dist/state/scheduleStore.d.ts +37 -0
- package/dist/state/scheduleStore.js +64 -0
- package/package.json +7 -4
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Strict tool-call recovery helpers (0.3.8-I4 / roadmap §8).
|
|
2
|
+
//
|
|
3
|
+
// Adapted from deer-flow/backend/packages/harness/deerflow/agents/middlewares/
|
|
4
|
+
// dangling_tool_call_middleware.py — same pattern: detect tool_calls that
|
|
5
|
+
// never received a paired tool_result and inject synthetic placeholders so
|
|
6
|
+
// strict OpenAI-compatible validators don't reject the next request.
|
|
7
|
+
//
|
|
8
|
+
// These helpers are intentionally pure (no agent.ts imports) so they can be
|
|
9
|
+
// unit-tested in isolation and reused if another runtime grows similar needs.
|
|
10
|
+
/**
|
|
11
|
+
* Drop duplicate tool_call ids inside a single assistant response. Keeps the
|
|
12
|
+
* LAST occurrence (closest to the model's final intent). Calls without a
|
|
13
|
+
* string id are passed through unchanged — the orphan safety net will catch
|
|
14
|
+
* them later.
|
|
15
|
+
*
|
|
16
|
+
* `onDuplicate` is invoked once per dropped duplicate so callers can log a
|
|
17
|
+
* warning without coupling this module to a logger.
|
|
18
|
+
*/
|
|
19
|
+
export function dedupeToolCalls(calls, onDuplicate) {
|
|
20
|
+
if (!Array.isArray(calls) || calls.length === 0)
|
|
21
|
+
return [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
const out = [];
|
|
24
|
+
for (let i = calls.length - 1; i >= 0; i--) {
|
|
25
|
+
const c = calls[i];
|
|
26
|
+
const id = c?.id;
|
|
27
|
+
if (typeof id !== 'string' || id === '') {
|
|
28
|
+
out.push(c);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (seen.has(id)) {
|
|
32
|
+
onDuplicate?.(id, i);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
seen.add(id);
|
|
36
|
+
out.push(c);
|
|
37
|
+
}
|
|
38
|
+
return out.reverse();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Try-parse `tool_call.function.arguments`. On parse failure return a
|
|
42
|
+
* structured error string instead of throwing, so the caller can attach a
|
|
43
|
+
* synthetic tool_result that the next model turn can read.
|
|
44
|
+
*/
|
|
45
|
+
export function parseArgumentsOrError(call) {
|
|
46
|
+
const raw = call?.function?.arguments;
|
|
47
|
+
if (raw == null)
|
|
48
|
+
return { args: {}, rawArguments: '' };
|
|
49
|
+
if (typeof raw !== 'string') {
|
|
50
|
+
// Provider already parsed it for us.
|
|
51
|
+
return {
|
|
52
|
+
args: (raw && typeof raw === 'object') ? raw : {},
|
|
53
|
+
rawArguments: (() => { try {
|
|
54
|
+
return JSON.stringify(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return String(raw);
|
|
58
|
+
} })(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (raw.trim() === '')
|
|
62
|
+
return { args: {}, rawArguments: raw };
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(raw);
|
|
65
|
+
return {
|
|
66
|
+
args: (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {},
|
|
67
|
+
rawArguments: raw,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
const msg = e?.message ?? String(e);
|
|
72
|
+
// Keep the raw arguments visible — the model often needs to see exactly
|
|
73
|
+
// what it produced to self-correct (e.g. trailing comma, missing quote).
|
|
74
|
+
const previewedRaw = raw.length > 400 ? `${raw.slice(0, 400)}…[truncated ${raw.length - 400} chars]` : raw;
|
|
75
|
+
return {
|
|
76
|
+
args: {},
|
|
77
|
+
error: `Tool argument JSON was malformed: ${msg}. Raw arguments emitted by the model: ${previewedRaw}. Re-issue the tool call with valid JSON arguments.`,
|
|
78
|
+
rawArguments: raw,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* For every tool_call in `calls` that has no matching tool_result in
|
|
84
|
+
* `results`, build a synthetic tool message so the next LLM request stays
|
|
85
|
+
* well-formed (OpenAI strictly requires tool_call ↔ tool_result pairing).
|
|
86
|
+
*
|
|
87
|
+
* IMPORTANT: the synthetic `content` MUST start with `ERROR:` and be a plain
|
|
88
|
+
* string. The agent runtime's R1 child-drain guardrail tracks spawned
|
|
89
|
+
* children by `parseJsonObject(resultText)` on tool results — if the
|
|
90
|
+
* synthetic envelope parses as JSON with an `id` field, the guardrail would
|
|
91
|
+
* incorrectly think a child agent was spawned and try to wait on it.
|
|
92
|
+
*/
|
|
93
|
+
export function synthesizeOrphanResults(calls, results) {
|
|
94
|
+
if (!Array.isArray(calls) || calls.length === 0)
|
|
95
|
+
return [];
|
|
96
|
+
const have = new Set(results.map((r) => r.tool_call_id));
|
|
97
|
+
const synthetic = [];
|
|
98
|
+
for (const c of calls) {
|
|
99
|
+
const id = c?.id;
|
|
100
|
+
if (typeof id !== 'string' || id === '')
|
|
101
|
+
continue;
|
|
102
|
+
if (have.has(id))
|
|
103
|
+
continue;
|
|
104
|
+
synthetic.push({
|
|
105
|
+
role: 'tool',
|
|
106
|
+
tool_call_id: id,
|
|
107
|
+
name: c?.function?.name ?? 'unknown',
|
|
108
|
+
content: 'ERROR: tool call orphaned by model; no execution recorded. Re-issue the tool call if you still need this work done.',
|
|
109
|
+
isError: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return synthetic;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Use the caller's existing `normalizeToolName` to surface a "did you mean"
|
|
116
|
+
* suggestion when the LLM emits a tool name that doesn't exist as-is but
|
|
117
|
+
* normalizes to a real registered tool. Tolerates the single-underscore
|
|
118
|
+
* `mcp_<server>_<tool>` prefix (R5 convention) since `normalizeToolName`
|
|
119
|
+
* matches by flattened form.
|
|
120
|
+
*/
|
|
121
|
+
export function suggestSimilarToolName(raw, candidates, normalize) {
|
|
122
|
+
const trimmed = (raw ?? '').trim();
|
|
123
|
+
if (!trimmed || candidates.includes(trimmed))
|
|
124
|
+
return undefined;
|
|
125
|
+
const suggestion = normalize(trimmed, candidates);
|
|
126
|
+
if (suggestion && suggestion !== trimmed && candidates.includes(suggestion)) {
|
|
127
|
+
return suggestion;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True iff `toolName` is on the conservative parallel-safe whitelist.
|
|
3
|
+
* Accepts both the bare local tool name (`read_file`) and the MCP-prefixed
|
|
4
|
+
* form (`mcp_brainrouter_memory_recall`). Anything else — including any
|
|
5
|
+
* unknown tool name — returns false so the caller falls back to safe
|
|
6
|
+
* serial execution.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isParallelSafe(toolName: string): boolean;
|
|
9
|
+
/** Companion to `isParallelSafe` — true iff the name resolves to a known MCP read tool. */
|
|
10
|
+
export declare function isMcpReadTool(toolName: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Kill switch: `BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS=false` (or `0`/`off`/`no`)
|
|
13
|
+
* forces every batch back to strict serial execution — the pre-R4 shape.
|
|
14
|
+
* Useful when debugging an issue and you want to rule out concurrency, or
|
|
15
|
+
* when running against an LLM provider that rate-limits tool dispatch.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parallelExecutionEnabled(): boolean;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// 0.3.8-R4 — Single source of truth for which tool calls are safe to
|
|
2
|
+
// dispatch concurrently within one LLM response.
|
|
3
|
+
//
|
|
4
|
+
// Pre-R4 the runtime executed every tool call from one assistant message
|
|
5
|
+
// strictly serially. That's safe but it's pure latency loss for the common
|
|
6
|
+
// case of "read 5 files in one turn" — none of those reads share state or
|
|
7
|
+
// depend on each other's results. Writes and shell commands still need to
|
|
8
|
+
// serialize to preserve causality.
|
|
9
|
+
//
|
|
10
|
+
// `isParallelSafe(toolName)` is the conservative whitelist. Anything not on
|
|
11
|
+
// the list is treated as serial — the failure mode is "we ran something
|
|
12
|
+
// sequentially that could have been concurrent," which is the same
|
|
13
|
+
// performance the pre-R4 code shipped with. Adding tools here is the only
|
|
14
|
+
// way to opt them in.
|
|
15
|
+
/**
|
|
16
|
+
* Local read-only tools whose execution is independent and has no
|
|
17
|
+
* observable side effect on the workspace, on child-session state, or on
|
|
18
|
+
* the agent's own bookkeeping. These can run concurrently within a single
|
|
19
|
+
* LLM response.
|
|
20
|
+
*
|
|
21
|
+
* Explicitly EXCLUDED (must stay serial):
|
|
22
|
+
* - write_file / edit_file / apply_patch / run_command — workspace mutation.
|
|
23
|
+
* - spawn_agent / spawn_agents / task_agent / delegate_agent
|
|
24
|
+
* / wait_agent / wait_agents / close_agent / route_agent
|
|
25
|
+
* / read_agent_transcript — orchestration / child-session mutation.
|
|
26
|
+
* (R1's child-drain guardrail tracks every spawn/wait one-by-one;
|
|
27
|
+
* running them in parallel would let bookkeeping diverge.)
|
|
28
|
+
* - update_plan / goal_complete / goal_blocked — session state mutation.
|
|
29
|
+
* - ask_user_choice — interactive picker; must not interleave with other UI.
|
|
30
|
+
* - list_agents — reads orchestration state but classified serial out of
|
|
31
|
+
* caution; cheap and rarely batched.
|
|
32
|
+
*/
|
|
33
|
+
const PARALLEL_SAFE_LOCAL_TOOLS = new Set([
|
|
34
|
+
'read_file',
|
|
35
|
+
'list_dir',
|
|
36
|
+
'grep_search',
|
|
37
|
+
'glob_files',
|
|
38
|
+
'fetch_url',
|
|
39
|
+
'web_search',
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* MCP read tools — bare tool names (without the `mcp_<server>_` prefix)
|
|
43
|
+
* that BrainRouter knows to be read-only. The pool normalises any legacy
|
|
44
|
+
* double-underscore emissions to the canonical single-underscore
|
|
45
|
+
* `mcp_<server>_<tool>` form at its boundary (0.3.8-R5), so the matcher
|
|
46
|
+
* here only deals with that one shape.
|
|
47
|
+
*/
|
|
48
|
+
const PARALLEL_SAFE_MCP_READ_TOOLS = new Set([
|
|
49
|
+
'memory_recall',
|
|
50
|
+
'memory_search',
|
|
51
|
+
'memory_file_history',
|
|
52
|
+
'memory_task_state',
|
|
53
|
+
'memory_contradictions',
|
|
54
|
+
'memory_inspect',
|
|
55
|
+
'memory_list_records',
|
|
56
|
+
]);
|
|
57
|
+
/**
|
|
58
|
+
* True iff `toolName` is on the conservative parallel-safe whitelist.
|
|
59
|
+
* Accepts both the bare local tool name (`read_file`) and the MCP-prefixed
|
|
60
|
+
* form (`mcp_brainrouter_memory_recall`). Anything else — including any
|
|
61
|
+
* unknown tool name — returns false so the caller falls back to safe
|
|
62
|
+
* serial execution.
|
|
63
|
+
*/
|
|
64
|
+
export function isParallelSafe(toolName) {
|
|
65
|
+
if (!toolName)
|
|
66
|
+
return false;
|
|
67
|
+
if (PARALLEL_SAFE_LOCAL_TOOLS.has(toolName))
|
|
68
|
+
return true;
|
|
69
|
+
const bare = stripMcpPrefix(toolName);
|
|
70
|
+
if (bare && PARALLEL_SAFE_MCP_READ_TOOLS.has(bare))
|
|
71
|
+
return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** Companion to `isParallelSafe` — true iff the name resolves to a known MCP read tool. */
|
|
75
|
+
export function isMcpReadTool(toolName) {
|
|
76
|
+
const bare = stripMcpPrefix(toolName);
|
|
77
|
+
return !!bare && PARALLEL_SAFE_MCP_READ_TOOLS.has(bare);
|
|
78
|
+
}
|
|
79
|
+
function stripMcpPrefix(name) {
|
|
80
|
+
// Canonical single-underscore shape: mcp_<server>_<tool>. Server names
|
|
81
|
+
// may contain underscores so we suffix-match against known bare tools
|
|
82
|
+
// instead of guessing where the server segment ends.
|
|
83
|
+
if (name.startsWith('mcp_')) {
|
|
84
|
+
for (const known of PARALLEL_SAFE_MCP_READ_TOOLS) {
|
|
85
|
+
if (name.endsWith('_' + known))
|
|
86
|
+
return known;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Kill switch: `BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS=false` (or `0`/`off`/`no`)
|
|
93
|
+
* forces every batch back to strict serial execution — the pre-R4 shape.
|
|
94
|
+
* Useful when debugging an issue and you want to rule out concurrency, or
|
|
95
|
+
* when running against an LLM provider that rate-limits tool dispatch.
|
|
96
|
+
*/
|
|
97
|
+
export function parallelExecutionEnabled() {
|
|
98
|
+
const raw = (process.env.BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS ?? '').trim().toLowerCase();
|
|
99
|
+
if (raw === 'false' || raw === '0' || raw === 'off' || raw === 'no')
|
|
100
|
+
return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
package/dist/cli/banner.js
CHANGED
|
@@ -9,7 +9,7 @@ import { BOX } from './theme.js';
|
|
|
9
9
|
* (chalk title + workspace line + connecting-to line) with a single visually
|
|
10
10
|
* scannable block:
|
|
11
11
|
*
|
|
12
|
-
* ╭─ 🧠 BrainRouter CLI 0.3.
|
|
12
|
+
* ╭─ 🧠 BrainRouter CLI 0.3.8 ──────────────────────────────╮
|
|
13
13
|
* │ workspace BrainRouter · c5b8c12d │
|
|
14
14
|
* │ mcp local-http · http · online │
|
|
15
15
|
* │ workflow cli-shell-redesign (in-progress) │
|
|
@@ -26,7 +26,7 @@ import { BOX } from './theme.js';
|
|
|
26
26
|
* The function returns a single string with embedded ANSI; the caller prints
|
|
27
27
|
* it once. Pure-function so tests can assert against the rendered output.
|
|
28
28
|
*/
|
|
29
|
-
const VERSION = '0.3.
|
|
29
|
+
const VERSION = '0.3.8';
|
|
30
30
|
const TITLE = '🧠 BrainRouter CLI';
|
|
31
31
|
// Width floor for the BOXED banner. Below this we fall through to the
|
|
32
32
|
// `renderPlainBanner` plaintext format. Was 56 — that caused the box to
|
package/dist/cli/cliPrompt.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
|
+
import { getAmbientChat } from './ink/ambientChat.js';
|
|
2
3
|
/**
|
|
3
4
|
* Shared bridge between the REPL's readline interface and modules outside
|
|
4
5
|
* repl.ts that need to (a) write above the prompt without scrambling input,
|
|
@@ -33,6 +34,9 @@ export function isPickerActive() { return pickerActive; }
|
|
|
33
34
|
* (e.g. piped non-interactive runs).
|
|
34
35
|
*/
|
|
35
36
|
export function askYesNo(question, defaultValue = false) {
|
|
37
|
+
if (getAmbientChat() && process.stdin.isTTY) {
|
|
38
|
+
return runInkYesNo(question, defaultValue);
|
|
39
|
+
}
|
|
36
40
|
if (!activeReadline || !process.stdin.isTTY) {
|
|
37
41
|
return Promise.resolve(defaultValue);
|
|
38
42
|
}
|
|
@@ -235,8 +239,69 @@ export function askChoice(question, options, opts = {}) {
|
|
|
235
239
|
return Promise.reject(new NoTTYError('ask_user_choice requires an interactive TTY (no readline interface is active or stdin is not a TTY). ' +
|
|
236
240
|
'Fall back to deciding yourself based on the available context, and state which option you picked and why in your reply.'));
|
|
237
241
|
}
|
|
242
|
+
if (getAmbientChat()) {
|
|
243
|
+
return runInkChoice(question, options, opts);
|
|
244
|
+
}
|
|
238
245
|
return runPicker(question, options, opts);
|
|
239
246
|
}
|
|
247
|
+
async function runInkYesNo(question, defaultValue) {
|
|
248
|
+
const { runPicker } = await import('./ink/runPicker.js');
|
|
249
|
+
const result = await runPicker({
|
|
250
|
+
title: question,
|
|
251
|
+
badge: 'Confirm',
|
|
252
|
+
rows: [
|
|
253
|
+
{ id: 'yes', label: 'Yes', description: 'Allow this action' },
|
|
254
|
+
{ id: 'no', label: 'No', description: 'Do not allow this action' },
|
|
255
|
+
],
|
|
256
|
+
initialCursor: defaultValue ? 0 : 1,
|
|
257
|
+
allowOther: false,
|
|
258
|
+
});
|
|
259
|
+
if (result.kind !== 'pick')
|
|
260
|
+
return defaultValue;
|
|
261
|
+
return result.id === 'yes';
|
|
262
|
+
}
|
|
263
|
+
async function runInkChoice(question, options, opts) {
|
|
264
|
+
const { runPicker } = await import('./ink/runPicker.js');
|
|
265
|
+
const rows = options.map((option, i) => ({
|
|
266
|
+
id: `choice:${i}`,
|
|
267
|
+
label: option.label,
|
|
268
|
+
description: option.description,
|
|
269
|
+
}));
|
|
270
|
+
const result = await runPicker({
|
|
271
|
+
title: question,
|
|
272
|
+
badge: opts.header,
|
|
273
|
+
rows,
|
|
274
|
+
initialCursor: opts.initialCursor,
|
|
275
|
+
allowOther: true,
|
|
276
|
+
otherLabel: OTHER_LABEL,
|
|
277
|
+
otherDescription: OTHER_DESCRIPTION,
|
|
278
|
+
prefilledOther: opts.prefilledOther,
|
|
279
|
+
multiSelect: !!opts.multiSelect,
|
|
280
|
+
onCursorChange: opts.onCursorChange
|
|
281
|
+
? (_id, index) => {
|
|
282
|
+
opts.onCursorChange?.(index);
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
: undefined,
|
|
286
|
+
});
|
|
287
|
+
if (result.kind === 'cancelled') {
|
|
288
|
+
throw new CancelledChoiceError();
|
|
289
|
+
}
|
|
290
|
+
if (result.kind === 'other') {
|
|
291
|
+
return result.text;
|
|
292
|
+
}
|
|
293
|
+
if (result.kind === 'multi') {
|
|
294
|
+
const answers = result.ids.map((id) => {
|
|
295
|
+
const idx = Number(id.slice('choice:'.length));
|
|
296
|
+
return options[idx]?.label;
|
|
297
|
+
}).filter((label) => !!label);
|
|
298
|
+
if (result.otherText)
|
|
299
|
+
answers.push(result.otherText);
|
|
300
|
+
return answers;
|
|
301
|
+
}
|
|
302
|
+
const idx = Number(result.id.slice('choice:'.length));
|
|
303
|
+
return options[idx]?.label ?? options[0].label;
|
|
304
|
+
}
|
|
240
305
|
function runPicker(question, options, opts) {
|
|
241
306
|
return new Promise((resolve, reject) => {
|
|
242
307
|
const rl = activeReadline;
|
|
@@ -313,7 +313,7 @@ async function addMcpProfile(ctx, theme) {
|
|
|
313
313
|
const nameRes = await promptText({
|
|
314
314
|
theme,
|
|
315
315
|
title: 'New MCP server — name',
|
|
316
|
-
subtitle: 'Short identifier. Used in tool prefixes:
|
|
316
|
+
subtitle: 'Short identifier. Used in tool prefixes: mcp_<name>_<tool>.',
|
|
317
317
|
badge: 'MCP',
|
|
318
318
|
placeholder: 'github, filesystem, my-brain, …',
|
|
319
319
|
validate: (raw) => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* /mcp — alias for /mcp list
|
|
7
7
|
* /mcp list — every configured profile + per-server status
|
|
8
|
-
* /mcp tools [server] — MCP tools grouped by `
|
|
8
|
+
* /mcp tools [server] — MCP tools grouped by `mcp_<server>_*`
|
|
9
9
|
* namespace; pass a server id to scope
|
|
10
10
|
* /mcp connect <name> — connect a configured server that's idle/offline
|
|
11
11
|
* /mcp disconnect <name> — close one server's transport (config preserved)
|
package/dist/cli/commands/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* /mcp — alias for /mcp list
|
|
7
7
|
* /mcp list — every configured profile + per-server status
|
|
8
|
-
* /mcp tools [server] — MCP tools grouped by `
|
|
8
|
+
* /mcp tools [server] — MCP tools grouped by `mcp_<server>_*`
|
|
9
9
|
* namespace; pass a server id to scope
|
|
10
10
|
* /mcp connect <name> — connect a configured server that's idle/offline
|
|
11
11
|
* /mcp disconnect <name> — close one server's transport (config preserved)
|
|
@@ -22,6 +22,7 @@ import { resolveIdentityFromConfig } from '../../runtime/mcpClient.js';
|
|
|
22
22
|
import { selectMcpServerIds } from '../../runtime/mcpPool.js';
|
|
23
23
|
import { buildBannerInputs, renderBanner } from '../banner.js';
|
|
24
24
|
import { resolveTheme } from '../theme.js';
|
|
25
|
+
import { runMcpInstall } from './mcpInstall.js';
|
|
25
26
|
export async function tryHandleMcpCommand(ctx) {
|
|
26
27
|
const { command, args, mcpClient, config } = ctx;
|
|
27
28
|
if (command !== '/mcp')
|
|
@@ -42,12 +43,28 @@ export async function tryHandleMcpCommand(ctx) {
|
|
|
42
43
|
const res = await mcpClient.listTools();
|
|
43
44
|
const allTools = res.tools || [];
|
|
44
45
|
spinner.succeed(chalk.green(`${allTools.length} tools across ${connected.length} server${connected.length === 1 ? '' : 's'}`));
|
|
45
|
-
// Pool tools are exposed as `
|
|
46
|
+
// Pool tools are exposed as `mcp_<serverId>_<rawTool>`. Group by serverId.
|
|
47
|
+
const knownIds = new Set(connected.map((s) => s.serverId));
|
|
46
48
|
const byServer = {};
|
|
47
49
|
for (const t of allTools) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
let serverId = '__unknown__';
|
|
51
|
+
let raw = t.name;
|
|
52
|
+
if (t.name.startsWith('mcp_')) {
|
|
53
|
+
const rest = t.name.slice('mcp_'.length);
|
|
54
|
+
// Server ids may contain underscores; match the longest known id.
|
|
55
|
+
const id = [...knownIds].sort((a, b) => b.length - a.length).find((k) => rest.startsWith(`${k}_`));
|
|
56
|
+
if (id) {
|
|
57
|
+
serverId = id;
|
|
58
|
+
raw = rest.slice(id.length + 1);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const idx = rest.indexOf('_');
|
|
62
|
+
if (idx > 0) {
|
|
63
|
+
serverId = rest.slice(0, idx);
|
|
64
|
+
raw = rest.slice(idx + 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
51
68
|
if (onlyServer && serverId !== onlyServer)
|
|
52
69
|
continue;
|
|
53
70
|
(byServer[serverId] ||= []).push(raw);
|
|
@@ -64,7 +81,7 @@ export async function tryHandleMcpCommand(ctx) {
|
|
|
64
81
|
const identTag = formatIdentityTag(ident);
|
|
65
82
|
console.log(` ${chalk.bold.green(id)} ${identTag} (${byServer[id].length})`);
|
|
66
83
|
for (const name of byServer[id].sort()) {
|
|
67
|
-
console.log(` ${chalk.gray('•')} ${name} ${chalk.gray(`
|
|
84
|
+
console.log(` ${chalk.gray('•')} ${name} ${chalk.gray(`mcp_${id}_${name}`)}`);
|
|
68
85
|
}
|
|
69
86
|
}
|
|
70
87
|
}
|
|
@@ -200,6 +217,11 @@ export async function tryHandleMcpCommand(ctx) {
|
|
|
200
217
|
}
|
|
201
218
|
return true;
|
|
202
219
|
}
|
|
220
|
+
if (sub === 'install') {
|
|
221
|
+
const result = runMcpInstall(args.slice(1), config);
|
|
222
|
+
console.log(result.output);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
203
225
|
if (sub === 'disconnect') {
|
|
204
226
|
if (!targetName) {
|
|
205
227
|
console.log(chalk.red('\nUsage: /mcp disconnect <name>\n'));
|
|
@@ -218,7 +240,7 @@ export async function tryHandleMcpCommand(ctx) {
|
|
|
218
240
|
}
|
|
219
241
|
return true;
|
|
220
242
|
}
|
|
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`));
|
|
243
|
+
console.log(chalk.red(`\nUnknown /mcp subcommand "${sub}". Usage: /mcp list | /mcp tools [server] | /mcp connect <name> | /mcp disconnect <name> | /mcp reconnect [name] | /mcp install <vendor>|list\n`));
|
|
222
244
|
return true;
|
|
223
245
|
}
|
|
224
246
|
function formatIdentityTag(identity) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/mcp install` — generates per-vendor MCP config snippets for non-CLI
|
|
3
|
+
* hosts (Claude Desktop, Cursor, Windsurf, VS Code Continue, Zed, Cline).
|
|
4
|
+
*
|
|
5
|
+
* Pattern: print-only. We do NOT write to vendor config files — the user
|
|
6
|
+
* pastes the block themselves. Direct-write is a future enhancement
|
|
7
|
+
* (roadmap: tracked under post-0.4.0 polish; intentionally not a follow-up).
|
|
8
|
+
*
|
|
9
|
+
* Adapted from semble's per-agent install docs pattern
|
|
10
|
+
* (openSrc/semble/src/semble/agents/) — one focused entry per vendor.
|
|
11
|
+
*/
|
|
12
|
+
import type { Config } from '../../config/config.js';
|
|
13
|
+
export interface RenderResult {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
output: string;
|
|
16
|
+
}
|
|
17
|
+
export interface RunOpts {
|
|
18
|
+
platform?: NodeJS.Platform;
|
|
19
|
+
}
|
|
20
|
+
export declare function runMcpInstall(args: string[], config: Config, opts?: RunOpts): RenderResult;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/mcp install` — generates per-vendor MCP config snippets for non-CLI
|
|
3
|
+
* hosts (Claude Desktop, Cursor, Windsurf, VS Code Continue, Zed, Cline).
|
|
4
|
+
*
|
|
5
|
+
* Pattern: print-only. We do NOT write to vendor config files — the user
|
|
6
|
+
* pastes the block themselves. Direct-write is a future enhancement
|
|
7
|
+
* (roadmap: tracked under post-0.4.0 polish; intentionally not a follow-up).
|
|
8
|
+
*
|
|
9
|
+
* Adapted from semble's per-agent install docs pattern
|
|
10
|
+
* (openSrc/semble/src/semble/agents/) — one focused entry per vendor.
|
|
11
|
+
*/
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { displayPath, getVendor, listVendors, renderSnippet, VENDORS } from '../../runtime/vendorSnippets.js';
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the active BrainRouter profile from config. Returns null when
|
|
16
|
+
* the user hasn't logged in yet — caller prints a `/login` hint.
|
|
17
|
+
*/
|
|
18
|
+
function resolveActiveBrainrouter(config) {
|
|
19
|
+
const name = config.activeServer;
|
|
20
|
+
if (!name)
|
|
21
|
+
return null;
|
|
22
|
+
const profile = config.servers?.[name];
|
|
23
|
+
if (!profile)
|
|
24
|
+
return null;
|
|
25
|
+
if (profile.type !== 'http')
|
|
26
|
+
return null;
|
|
27
|
+
const url = profile.url?.trim();
|
|
28
|
+
const apiKey = profile.apiKey?.trim();
|
|
29
|
+
if (!url || !apiKey)
|
|
30
|
+
return null;
|
|
31
|
+
return { url, apiKey };
|
|
32
|
+
}
|
|
33
|
+
export function runMcpInstall(args, config, opts = {}) {
|
|
34
|
+
const platform = opts.platform ?? process.platform;
|
|
35
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
36
|
+
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
output: `${chalk.bold('Usage')}\n` +
|
|
40
|
+
` /mcp install list — list supported vendors\n` +
|
|
41
|
+
` /mcp install <vendor> — print paste-ready snippet for one vendor\n\n` +
|
|
42
|
+
`Vendors: ${listVendors().map((v) => v.id).join(', ')}\n`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (sub === 'list') {
|
|
46
|
+
const lines = [chalk.bold('\nSupported MCP hosts')];
|
|
47
|
+
for (const v of listVendors()) {
|
|
48
|
+
const p = displayPath(v.configPath(platform), platform);
|
|
49
|
+
lines.push(` ${chalk.bold.cyan(v.id.padEnd(18))} ${chalk.gray(v.label.padEnd(28))} ${chalk.gray(p)}`);
|
|
50
|
+
}
|
|
51
|
+
lines.push('', chalk.gray(`Run "/mcp install <id>" for a paste-ready snippet.`), '');
|
|
52
|
+
return { ok: true, output: lines.join('\n') };
|
|
53
|
+
}
|
|
54
|
+
const entry = getVendor(sub);
|
|
55
|
+
if (!entry) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
output: chalk.red(`\nUnknown vendor "${sub}".\n`) +
|
|
59
|
+
chalk.gray(`Known: ${Object.keys(VENDORS).join(', ')}\n`),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const active = resolveActiveBrainrouter(config);
|
|
63
|
+
if (!active) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
output: chalk.red('\nNo active BrainRouter profile with URL + API key.\n') +
|
|
67
|
+
chalk.gray('Run `/login` to configure one, then re-run this command.\n'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const snippet = renderSnippet(entry, { url: active.url, apiKey: active.apiKey });
|
|
71
|
+
const configPath = displayPath(entry.configPath(platform), platform);
|
|
72
|
+
const out = [];
|
|
73
|
+
out.push('');
|
|
74
|
+
out.push(chalk.bold.cyan(`${entry.label} (${entry.id})`));
|
|
75
|
+
out.push(chalk.gray(`Config file: ${configPath}`));
|
|
76
|
+
if (entry.note)
|
|
77
|
+
out.push(chalk.gray(`Note: ${entry.note}`));
|
|
78
|
+
out.push('');
|
|
79
|
+
out.push(chalk.yellow('⚠ This block contains your live API key — paste into your vendor config and do not commit.'));
|
|
80
|
+
out.push('');
|
|
81
|
+
out.push(snippet);
|
|
82
|
+
out.push('');
|
|
83
|
+
out.push(chalk.gray(`Restart: ${entry.restart}`));
|
|
84
|
+
out.push(chalk.gray('Web reference: brainrouter-docs/mcp-install.md'));
|
|
85
|
+
out.push('');
|
|
86
|
+
return { ok: true, output: out.join('\n') };
|
|
87
|
+
}
|
|
@@ -44,6 +44,39 @@ export async function tryHandleOrchestrationCommand(ctx) {
|
|
|
44
44
|
console.log();
|
|
45
45
|
return true;
|
|
46
46
|
}
|
|
47
|
+
// `--watch`: poll the same data shape every second and re-render the
|
|
48
|
+
// running-children list inline. Same shape as `/agents` and the Ink
|
|
49
|
+
// status row so the user gets a single mental model (roadmap §3).
|
|
50
|
+
if (args.includes('--watch')) {
|
|
51
|
+
const intervalMs = 1000;
|
|
52
|
+
const maxTicks = 600; // ~10 min safety cap; Ctrl-C exits early.
|
|
53
|
+
let ticks = 0;
|
|
54
|
+
console.log(chalk.bold('\nWatching child agents (Ctrl-C to stop)…'));
|
|
55
|
+
await new Promise((resolve) => {
|
|
56
|
+
const handle = setInterval(() => {
|
|
57
|
+
reconcileStale(agent.workspaceRoot);
|
|
58
|
+
const running = listSessions(agent.workspaceRoot)
|
|
59
|
+
.filter((s) => s.status === 'pending' || s.status === 'running');
|
|
60
|
+
const stamp = new Date().toISOString().slice(11, 19);
|
|
61
|
+
if (running.length === 0) {
|
|
62
|
+
process.stdout.write(`\r[${stamp}] no running children${' '.repeat(40)}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const parts = running.map((s) => `${s.id.slice(0, 14)} (${s.role})`).join(', ');
|
|
66
|
+
process.stdout.write(`\r[${stamp}] running: ${parts}${' '.repeat(10)}`);
|
|
67
|
+
}
|
|
68
|
+
if (++ticks >= maxTicks) {
|
|
69
|
+
clearInterval(handle);
|
|
70
|
+
process.stdout.write('\n');
|
|
71
|
+
resolve();
|
|
72
|
+
}
|
|
73
|
+
}, intervalMs);
|
|
74
|
+
const onSig = () => { clearInterval(handle); process.stdout.write('\n'); process.off('SIGINT', onSig); resolve(); };
|
|
75
|
+
process.once('SIGINT', onSig);
|
|
76
|
+
});
|
|
77
|
+
console.log();
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
47
80
|
reconcileStale(agent.workspaceRoot);
|
|
48
81
|
const sessions = listSessions(agent.workspaceRoot);
|
|
49
82
|
// `--json` for scripting. Emits a single JSON line on stdout so
|
|
@@ -0,0 +1,24 @@
|
|
|
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 type { CommandContext } from './_context.js';
|
|
13
|
+
export interface ReleaseNotesDeps {
|
|
14
|
+
/** Override the changelog directory (tests). Defaults to bundled `changelog/`. */
|
|
15
|
+
changelogDir?: string;
|
|
16
|
+
/** Override the current version (tests). Defaults to package.json#version. */
|
|
17
|
+
currentVersion?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function tryHandleReleaseNotesCommand(ctx: CommandContext, deps?: ReleaseNotesDeps): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Pure handler — returns the rendered string. Split from `tryHandle*` so unit
|
|
22
|
+
* tests can assert on the output without capturing stdout.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runReleaseNotes(args: string[], deps?: ReleaseNotesDeps): string;
|