opencode-mcp-triage 0.7.0

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.
@@ -0,0 +1,764 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * opencode-mcp-triage CLI v0.6.1 — /mcp-triage slash command handler
4
+ *
5
+ * Reads MCP config and shows server status, subagent routing,
6
+ * and tool visibility. Runs out-of-process so cannot access
7
+ * runtime plugin state (use mcp_stats tool for that).
8
+ *
9
+ * Features:
10
+ * - Colored output matching opencode-triage style
11
+ * - Hidden vs exposed MCP tool visibility
12
+ * - Levenshtein typo correction for commands
13
+ * - JSON output mode (--json)
14
+ * - Benchmarking (--benchmark)
15
+ */
16
+ const { readFileSync, readdirSync, existsSync } = require("node:fs")
17
+ const { join } = require("node:path")
18
+ const { homedir } = require("node:os")
19
+
20
+ const COMMANDS = {
21
+ status: "Show MCP server status, hidden/exposed tools, and subagent routing",
22
+ list: "List all configured MCP servers and subagents",
23
+ measure: "Measure token savings by connecting to each MCP server",
24
+ help: "Show available commands",
25
+ }
26
+
27
+ const YELLOW = "\x1b[33m"
28
+ const GREEN = "\x1b[32m"
29
+ const RED = "\x1b[31m"
30
+ const CYAN = "\x1b[36m"
31
+ const RESET = "\x1b[0m"
32
+ const BOLD = "\x1b[1m"
33
+ const DIM = "\x1b[2m"
34
+
35
+ const PLUGIN_NAME = "opencode-mcp-triage"
36
+
37
+ const GLOBAL_CFG_PATH = join(homedir(), ".config", "opencode", "opencode.jsonc")
38
+ const GLOBAL_CFG_PATH_JSON = join(homedir(), ".config", "opencode", "opencode.json")
39
+
40
+ function levenshtein(a, b) {
41
+ const matrix = Array.from({ length: b.length + 1 }, (_, i) => [i])
42
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j
43
+ for (let i = 1; i <= b.length; i++) {
44
+ for (let j = 1; j <= a.length; j++) {
45
+ const cost = b[i - 1] === a[j - 1] ? 0 : 1
46
+ matrix[i][j] = Math.min(
47
+ matrix[i - 1][j] + 1,
48
+ matrix[i][j - 1] + 1,
49
+ matrix[i - 1][j - 1] + cost
50
+ )
51
+ }
52
+ }
53
+ return matrix[b.length][a.length]
54
+ }
55
+
56
+ function suggestCommand(typo, validCommands) {
57
+ let best = null
58
+ let bestDist = Infinity
59
+ for (const cmd of validCommands) {
60
+ const dist = levenshtein(typo, cmd)
61
+ if (dist < bestDist) {
62
+ bestDist = dist
63
+ best = cmd
64
+ }
65
+ }
66
+ return bestDist <= 3 ? best : null
67
+ }
68
+
69
+ function stripJsonc(raw) {
70
+ let result = ""
71
+ let inString = false
72
+ let escape = false
73
+ let i = 0
74
+
75
+ while (i < raw.length) {
76
+ const ch = raw[i]
77
+
78
+ if (inString) {
79
+ result += ch
80
+ if (escape) {
81
+ escape = false
82
+ } else if (ch === "\\") {
83
+ escape = true
84
+ } else if (ch === '"') {
85
+ inString = false
86
+ }
87
+ i++
88
+ continue
89
+ }
90
+
91
+ // Block comment: /* ... */
92
+ if (ch === "/" && i + 1 < raw.length && raw[i + 1] === "*") {
93
+ i += 2
94
+ while (i < raw.length) {
95
+ if (raw[i] === "*" && i + 1 < raw.length && raw[i + 1] === "/") {
96
+ i += 2
97
+ break
98
+ }
99
+ i++
100
+ }
101
+ continue
102
+ }
103
+
104
+ // Line comment: // ... (only when not inside a string)
105
+ if (ch === "/" && i + 1 < raw.length && raw[i + 1] === "/") {
106
+ i += 2
107
+ while (i < raw.length && raw[i] !== "\n") {
108
+ i++
109
+ }
110
+ continue
111
+ }
112
+
113
+ if (ch === '"') {
114
+ inString = true
115
+ }
116
+
117
+ result += ch
118
+ i++
119
+ }
120
+
121
+ // Strip trailing commas before } or ]
122
+ result = result.replace(/,(?=\s*[}\]])/g, "")
123
+ return result
124
+ }
125
+
126
+ function stripBOM(s) {
127
+ if (s.charCodeAt(0) === 0xfeff) return s.slice(1)
128
+ return s
129
+ }
130
+
131
+ function findConfig(baseDir) {
132
+ const isGlobal = baseDir === homedir()
133
+ const paths = isGlobal
134
+ ? [
135
+ join(baseDir, ".config", "opencode", "opencode.jsonc"),
136
+ join(baseDir, ".config", "opencode", "opencode.json"),
137
+ ]
138
+ : [
139
+ join(baseDir, ".opencode", "opencode.json"),
140
+ join(baseDir, ".opencode", "opencode.jsonc"),
141
+ join(baseDir, "opencode.jsonc"),
142
+ join(baseDir, "opencode.json"),
143
+ ]
144
+
145
+ for (const p of paths) {
146
+ if (existsSync(p)) {
147
+ try {
148
+ const raw = stripBOM(readFileSync(p, "utf-8"))
149
+ if (raw.length > 1024 * 1024) continue
150
+ return JSON.parse(stripJsonc(raw))
151
+ } catch {
152
+ // try next
153
+ }
154
+ }
155
+ }
156
+ return null
157
+ }
158
+
159
+ function collectConfigState() {
160
+ let localActive = false
161
+ const cwd = process.cwd()
162
+ const localPaths = [
163
+ join(cwd, ".opencode", "opencode.json"),
164
+ join(cwd, ".opencode", "opencode.jsonc"),
165
+ join(cwd, "opencode.jsonc"),
166
+ join(cwd, "opencode.json"),
167
+ ]
168
+ for (const p of localPaths) {
169
+ if (existsSync(p)) {
170
+ try {
171
+ const cfg = JSON.parse(stripJsonc(stripBOM(readFileSync(p, "utf-8"))))
172
+ localActive = (cfg.plugin || []).some(
173
+ (pl) => typeof pl === "string" && (pl.includes(PLUGIN_NAME) || pl === "file:" + process.cwd())
174
+ )
175
+ } catch {}
176
+ break
177
+ }
178
+ }
179
+
180
+ let globalActive = false
181
+ for (const p of [GLOBAL_CFG_PATH, GLOBAL_CFG_PATH_JSON]) {
182
+ if (existsSync(p)) {
183
+ try {
184
+ const cfg = JSON.parse(stripJsonc(stripBOM(readFileSync(p, "utf-8"))))
185
+ globalActive = (cfg.plugin || []).some(
186
+ (pl) => typeof pl === "string" && pl.includes(PLUGIN_NAME)
187
+ )
188
+ } catch {}
189
+ break
190
+ }
191
+ }
192
+
193
+ return { localActive, globalActive }
194
+ }
195
+
196
+ function extractMcpServers(config) {
197
+ const mcp = config.mcp || {}
198
+ return Object.entries(mcp)
199
+ .filter(([, entry]) => entry.enabled !== false)
200
+ .map(([name, entry]) => ({
201
+ name,
202
+ type: entry.type || "unknown",
203
+ enabled: entry.enabled !== false,
204
+ description: entry.description || "",
205
+ url: entry.url || "",
206
+ command: entry.command || [],
207
+ }))
208
+ }
209
+
210
+ function extractSubagents(config) {
211
+ const agent = config.agent || {}
212
+ const result = []
213
+ for (const [name, entry] of Object.entries(agent)) {
214
+ if (entry.mode === "primary") continue
215
+ if (!entry.tools || typeof entry.tools !== "object") continue
216
+ const mcps = Object.keys(entry.tools)
217
+ .filter((k) => k.endsWith("_*") && entry.tools[k] === true)
218
+ .map((k) => k.replace(/_?\*$/, ""))
219
+ if (mcps.length === 0) continue
220
+ result.push({ name, description: entry.description || "", mcps })
221
+ }
222
+ return result
223
+ }
224
+
225
+ function extractDisabledPatterns(config) {
226
+ const tools = config.tools || {}
227
+ return Object.entries(tools)
228
+ .filter(([, v]) => v === false)
229
+ .map(([k]) => k)
230
+ }
231
+
232
+ function cmdStatus(config, asJson) {
233
+ const { localActive, globalActive } = collectConfigState()
234
+ const mcpServers = extractMcpServers(config)
235
+ const subagents = extractSubagents(config)
236
+ const disabledPatterns = extractDisabledPatterns(config)
237
+ const mcpNames = mcpServers.map((s) => s.name)
238
+
239
+ const assigned = new Set()
240
+ for (const sa of subagents) {
241
+ for (const m of sa.mcps) assigned.add(m)
242
+ }
243
+
244
+ const hidden = mcpNames.filter((n) =>
245
+ disabledPatterns.some((p) => p === `${n}_*`)
246
+ )
247
+ const exposed = mcpNames.filter((n) =>
248
+ !disabledPatterns.some((p) => p === `${n}_*`)
249
+ )
250
+
251
+ const outOfSync = []
252
+ if (localActive && exposed.length > 0) {
253
+ outOfSync.push(`${exposed.length} MCP tool(s) exposed in project while plugin is ACTIVE`)
254
+ }
255
+ if (globalActive && exposed.length > 0) {
256
+ outOfSync.push(`${exposed.length} MCP tool(s) exposed globally while plugin is ACTIVE`)
257
+ }
258
+
259
+ if (asJson) {
260
+ const routingMap = subagents.map((sa) => ({
261
+ name: sa.name,
262
+ mcps: sa.mcps,
263
+ description: sa.description,
264
+ }))
265
+ console.log(JSON.stringify({
266
+ project: {
267
+ plugin: localActive ? "active" : "inactive",
268
+ mcpServers: mcpNames.length,
269
+ subagents: subagents.length,
270
+ },
271
+ global: {
272
+ plugin: globalActive ? "active" : "inactive",
273
+ },
274
+ mcpVisibility: {
275
+ hidden,
276
+ exposed,
277
+ },
278
+ routingMap,
279
+ unassigned: mcpNames.filter((n) => !assigned.has(n)),
280
+ outOfSync: outOfSync.length > 0 ? outOfSync : null,
281
+ }, null, 2))
282
+ return
283
+ }
284
+
285
+ const scopeSummary = []
286
+ if (localActive) scopeSummary.push(GREEN + "●" + RESET + " local")
287
+ if (globalActive) scopeSummary.push(GREEN + "●" + RESET + " global")
288
+ if (!localActive && !globalActive) scopeSummary.push(DIM + "○ inactive" + RESET)
289
+
290
+ console.log()
291
+ console.log(BOLD + "● MCP Triage Status" + RESET + DIM + " — " + scopeSummary.join(" · ") + RESET)
292
+ console.log()
293
+
294
+ console.log(` ${DIM}MCP servers:${RESET} ${mcpNames.length} │ ${DIM}Subagents:${RESET} ${subagents.length} │ ${DIM}Assigned:${RESET} ${assigned.size}/${mcpNames.length}`)
295
+ console.log()
296
+
297
+ if (outOfSync.length > 0) {
298
+ console.log(` ${YELLOW}⚠ ${outOfSync.join("; ")} — run plugin init to hide them${RESET}`)
299
+ console.log()
300
+ }
301
+
302
+ if (hidden.length > 0) {
303
+ console.log(` ${DIM}── Hidden (disabled in main session) ─────────────────${RESET}`)
304
+ for (const n of hidden) {
305
+ console.log(` ${GREEN}[hidden]${RESET} ${n}`)
306
+ }
307
+ console.log()
308
+ }
309
+
310
+ if (exposed.length > 0) {
311
+ console.log(` ${DIM}── Exposed (visible in main session) ─────────────────${RESET}`)
312
+ for (const n of exposed) {
313
+ console.log(` ${YELLOW}[exposed]${RESET} ${n}`)
314
+ }
315
+ console.log()
316
+ }
317
+
318
+ if (hidden.length === 0 && exposed.length === 0) {
319
+ console.log(` ${DIM}(no MCP servers configured)${RESET}`)
320
+ console.log()
321
+ }
322
+
323
+ if (subagents.length > 0) {
324
+ console.log(` ${DIM}── Subagent routing map ──────────────────────────────${RESET}`)
325
+ for (const sa of subagents) {
326
+ const mcps = sa.mcps.join(", ")
327
+ console.log(` ${CYAN}@${sa.name.padEnd(18)}${RESET} → ${mcps || "no MCP"}${sa.description ? DIM + ` (${sa.description})` + RESET : ""}`)
328
+ }
329
+ console.log()
330
+ }
331
+
332
+ if (hidden.length > 0 && exposed.length === 0) {
333
+ console.log(` ${GREEN}All MCP tools hidden from main session${RESET}`)
334
+ } else if (exposed.length > 0) {
335
+ console.log(` ${YELLOW}${exposed.length} MCP tool(s) still visible in main session${RESET}`)
336
+ }
337
+
338
+ console.log()
339
+ console.log(` ${DIM}── Token savings ───────────────────────────────────────${RESET}`)
340
+ console.log(` ${DIM}Run ${CYAN}opencode-mcp-triage measure${RESET}${DIM} to connect and measure${RESET}`)
341
+ console.log(` ${DIM}actual token savings from each MCP server.${RESET}`)
342
+ console.log()
343
+ }
344
+
345
+ function cmdList(config, asJson) {
346
+ const mcp = config.mcp || {}
347
+ const agent = config.agent || {}
348
+ const tools = config.tools || {}
349
+
350
+ if (asJson) {
351
+ const servers = Object.entries(mcp).map(([name, entry]) => ({
352
+ name,
353
+ type: entry.type || "unknown",
354
+ enabled: entry.enabled !== false,
355
+ location: entry.type === "remote" ? entry.url || "" : (entry.command || []).join(" "),
356
+ }))
357
+ const subagents = Object.entries(agent)
358
+ .filter(([, e]) => e.mode !== "primary")
359
+ .map(([name, entry]) => {
360
+ const mcps = entry.tools
361
+ ? Object.keys(entry.tools).filter((k) => k.endsWith("_*") && entry.tools[k] === true).map((k) => k.replace(/_?\*$/, ""))
362
+ : []
363
+ return { name, mcps, description: entry.description || "" }
364
+ })
365
+ const disabled = Object.entries(tools).filter(([, v]) => v === false).map(([p]) => p)
366
+ console.log(JSON.stringify({ servers, subagents, disabled }, null, 2))
367
+ return
368
+ }
369
+
370
+ console.log()
371
+ console.log(BOLD + "MCP Servers" + RESET)
372
+ console.log()
373
+
374
+ const entries = Object.entries(mcp)
375
+ if (entries.length === 0) {
376
+ console.log(DIM + " No MCP servers configured." + RESET)
377
+ } else {
378
+ for (const [name, entry] of entries) {
379
+ const enabled = entry.enabled !== false
380
+ const type = entry.type || "unknown"
381
+ const location = type === "remote" ? entry.url || "" : (entry.command || []).join(" ")
382
+ const status = enabled ? GREEN + "enabled" + RESET : RED + "disabled" + RESET
383
+ console.log(` ${name.padEnd(16)} [${type}] ${status} ${DIM}${location}${RESET}`)
384
+ }
385
+ }
386
+
387
+ console.log()
388
+ console.log(BOLD + "Subagents (MCP router)" + RESET)
389
+ console.log()
390
+
391
+ const subagents = extractSubagents(config)
392
+ if (subagents.length === 0) {
393
+ console.log(DIM + " No MCP subagents configured." + RESET)
394
+ } else {
395
+ for (const sa of subagents) {
396
+ const mcps = sa.mcps.join(", ")
397
+ console.log(` ${CYAN}@${sa.name.padEnd(18)}${RESET} → ${mcps || DIM + "no MCP" + RESET}${sa.description ? DIM + ` (${sa.description})` + RESET : ""}`)
398
+ }
399
+ }
400
+
401
+ console.log()
402
+ console.log(BOLD + "Global tool disables" + RESET)
403
+ const disabled = Object.entries(tools).filter(([, v]) => v === false)
404
+ if (disabled.length === 0) {
405
+ console.log(DIM + " No MCP tools disabled (all loaded in main session)" + RESET)
406
+ } else {
407
+ for (const [pattern] of disabled) {
408
+ console.log(` ${GREEN}${pattern}${RESET}`)
409
+ }
410
+ }
411
+ console.log()
412
+ }
413
+
414
+ function cmdHelp() {
415
+ console.log()
416
+ console.log(BOLD + "opencode-mcp-triage v0.6.1" + RESET + " — Subagent Router for MCP Tools")
417
+ console.log()
418
+ console.log(" Reduces MCP token usage by disabling all MCP tools globally")
419
+ console.log(" and routing work to scoped subagents via @mentions.")
420
+ console.log()
421
+ console.log(BOLD + "COMMANDS" + RESET)
422
+ console.log()
423
+ console.log(" status Show MCP server status, hidden/exposed tools, and subagent routing")
424
+ console.log(" list List all configured MCP servers and subagents")
425
+ console.log(" measure Connect to MCP servers and measure token savings per turn")
426
+ console.log(" help Show this help")
427
+ console.log()
428
+ console.log(BOLD + "FLAGS" + RESET)
429
+ console.log()
430
+ console.log(" --json Output as JSON (all commands)")
431
+ console.log(" --verbose Show error diagnostics during measure")
432
+ console.log(" --timeout=N Per-server timeout in seconds (default: 60)")
433
+ console.log()
434
+ console.log(BOLD + "HOW IT WORKS" + RESET)
435
+ console.log()
436
+ console.log(" 1. Global tool disables remove MCP tools from main session")
437
+ console.log(" 2. Subagents keep scoped MCP tools via agent.tools")
438
+ console.log(" 3. triage_mcp() routes queries to matching @subagent")
439
+ console.log(" 4. LLM invokes subagent via Task tool or @mention")
440
+ console.log()
441
+ console.log(BOLD + "CONFIGURE" + RESET)
442
+ console.log()
443
+ console.log(' "tools": { "mymcp_*": false } # disable globally')
444
+ console.log(' "agent": { "myagent": { # create subagent')
445
+ console.log(' "mode": "subagent",')
446
+ console.log(' "description": "...",')
447
+ console.log(' "tools": { "mymcp_*": true }')
448
+ console.log(' } }')
449
+ console.log()
450
+ }
451
+
452
+ // ── Measure (token savings) ────────────────────────────────
453
+ const { spawn } = require("node:child_process")
454
+
455
+ async function loadCachedTokens(verbose) {
456
+ const results = []
457
+ const authDir = join(homedir(), ".mcp-auth")
458
+ try {
459
+ const entries = readdirSync(authDir, { withFileTypes: true })
460
+ for (const entry of entries) {
461
+ const full = join(authDir, entry.name)
462
+ if (entry.isDirectory()) {
463
+ const sub = readdirSync(full, { withFileTypes: true })
464
+ for (const s of sub) {
465
+ const sf = join(full, s.name)
466
+ if (s.isDirectory()) continue
467
+ if (!s.name.endsWith("_tokens.json")) continue
468
+ try {
469
+ const raw = readFileSync(sf, "utf-8")
470
+ const tokens = JSON.parse(raw)
471
+ if (tokens.access_token) {
472
+ results.push({
473
+ token: tokens.access_token,
474
+ type: tokens.token_type || "Bearer",
475
+ })
476
+ }
477
+ } catch (e) {
478
+ if (verbose) process.stderr.write(` [mcp-auth: ${s.name}]`)
479
+ }
480
+ }
481
+ }
482
+ }
483
+ } catch (e) {
484
+ if (verbose) process.stderr.write(` [mcp-auth: ${e.message}]`)
485
+ }
486
+ return results
487
+ }
488
+
489
+ function parseSse(text) {
490
+ let lastData = null
491
+ for (const line of text.split("\n")) {
492
+ if (line.startsWith("data: ")) {
493
+ try { lastData = JSON.parse(line.slice(6)) } catch {}
494
+ }
495
+ }
496
+ return lastData
497
+ }
498
+
499
+ function calcStats(tools) {
500
+ let total = 0
501
+ for (const t of tools) total += JSON.stringify(t).length
502
+ return { tools: tools.length, chars: total, tokensEst: Math.round(total / 4) }
503
+ }
504
+
505
+ async function measureViaCachedToken(name, url, cachedTokens, envHeaders, verbose) {
506
+ if (!/^https:\/\//.test(url)) {
507
+ if (verbose) process.stderr.write(` [${name}: not https]`)
508
+ return null
509
+ }
510
+ for (const ct of cachedTokens) {
511
+ const headers = {
512
+ "Content-Type": "application/json",
513
+ "Accept": "application/json, text/event-stream",
514
+ "Authorization": `${ct.type} ${ct.token}`,
515
+ ...envHeaders,
516
+ }
517
+ try {
518
+ const initResp = await fetch(url, {
519
+ method: "POST", headers,
520
+ body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "initialize",
521
+ params: { protocolVersion: "2024-11-05", capabilities: {},
522
+ clientInfo: { name: "scanner", version: "1.0.0" } } }),
523
+ })
524
+ if (!initResp.ok) { if (verbose) process.stderr.write(` [${name}: HTTP ${initResp.status}]`); continue }
525
+ const text = await initResp.text()
526
+ const initResult = (initResp.headers.get("content-type") || "").includes("text/event-stream")
527
+ ? parseSse(text) : JSON.parse(text)
528
+ if (!initResult?.result) { if (verbose) process.stderr.write(` [${name}: no init result]`); continue }
529
+ const sessionId = initResp.headers.get("Mcp-Session-Id")
530
+ if (sessionId) headers["Mcp-Session-Id"] = sessionId
531
+ const toolsResp = await fetch(url, {
532
+ method: "POST", headers,
533
+ body: JSON.stringify({ jsonrpc: "2.0", id: "2", method: "tools/list" }),
534
+ })
535
+ if (!toolsResp.ok) { if (verbose) process.stderr.write(` [${name}: tools/list HTTP ${toolsResp.status}]`); continue }
536
+ const toolsText = await toolsResp.text()
537
+ const toolsData = (toolsResp.headers.get("content-type") || "").includes("text/event-stream")
538
+ ? parseSse(toolsText) : JSON.parse(toolsText)
539
+ if (toolsData.error) { if (verbose) process.stderr.write(` [${name}: ${toolsData.error.message}]`); continue }
540
+ return calcStats(toolsData.result?.tools || [])
541
+ } catch (e) {
542
+ if (verbose) process.stderr.write(` [${name}: ${e.message}]`)
543
+ }
544
+ }
545
+ return null
546
+ }
547
+
548
+ async function measureLocal(name, entry, verbose, timeoutMs) {
549
+ let [cmd, ...args] = entry.command || []
550
+ const SHORTHAND = { "netlify-mcp": ["npx", "-y", "@netlify/mcp"] }
551
+ const resolved = SHORTHAND[cmd]
552
+ if (resolved) { cmd = resolved[0]; args = [...resolved.slice(1), ...args] }
553
+
554
+ const env = { ...process.env }
555
+ if (entry.env) Object.assign(env, entry.env)
556
+ if (entry.environment) Object.assign(env, entry.environment)
557
+
558
+ return new Promise((resolve) => {
559
+ let proc
560
+ try {
561
+ proc = spawn(cmd, args, { env, stdio: ["pipe", "pipe", "pipe"], shell: true })
562
+ } catch (e) {
563
+ if (verbose) process.stderr.write(` [${name}: spawn ${e.message}]`)
564
+ return resolve(null)
565
+ }
566
+ let stdout = ""
567
+ let done = false
568
+
569
+ proc.stdout.setEncoding("utf-8")
570
+ proc.stdout.on("data", (chunk) => {
571
+ stdout += chunk
572
+ if (done) return
573
+ const lines = stdout.split("\n").filter(Boolean)
574
+ for (const line of lines) {
575
+ try {
576
+ const parsed = JSON.parse(line)
577
+ if ((parsed.id === 2 || parsed.id === "2") && (parsed.result || parsed.error)) {
578
+ done = true
579
+ proc.stdin.end()
580
+ proc.kill()
581
+ if (parsed.error && verbose) process.stderr.write(` [${name}: rpc ${parsed.error.message}]`)
582
+ resolve(parsed.error ? null : calcStats(parsed.result.tools || []))
583
+ return
584
+ }
585
+ } catch {}
586
+ }
587
+ })
588
+ proc.stdout.on("error", (e) => { if (!done) { done = true; proc.kill(); if (verbose) process.stderr.write(` [${name}: stdout ${e.message}]`); resolve(null) } })
589
+ proc.on("error", (e) => { if (!done) { done = true; if (verbose) process.stderr.write(` [${name}: proc ${e.message}]`); resolve(null) } })
590
+ proc.on("exit", (code) => { if (!done) { done = true; if (verbose && code !== 0) process.stderr.write(` [${name}: exited ${code}]`); resolve(null) } })
591
+
592
+ function send(msg) { try { proc.stdin.write(JSON.stringify(msg) + "\n") } catch {} }
593
+
594
+ setTimeout(() => { if (!done) { done = true; proc.kill(); if (verbose) process.stderr.write(` [${name}: timeout]`); resolve(null) } }, timeoutMs)
595
+ send({ jsonrpc: "2.0", id: "1", method: "initialize",
596
+ params: { protocolVersion: "2024-11-05", capabilities: {},
597
+ clientInfo: { name: "scanner", version: "1.0.0" } } })
598
+ setTimeout(() => {
599
+ send({ jsonrpc: "2.0", method: "notifications/initialized" })
600
+ send({ jsonrpc: "2.0", id: "2", method: "tools/list" })
601
+ }, 2000)
602
+ })
603
+ }
604
+
605
+ async function measureRemote(name, entry, verbose) {
606
+ const url = entry.url
607
+ if (!url || !/^https:\/\//.test(url)) {
608
+ if (verbose) process.stderr.write(` [${name}: not https]`)
609
+ return null
610
+ }
611
+ const headers = {
612
+ "Content-Type": "application/json",
613
+ "Accept": "application/json, text/event-stream",
614
+ ...(entry.headers || {}),
615
+ }
616
+ try {
617
+ const initResp = await fetch(url, {
618
+ method: "POST", headers,
619
+ body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "initialize",
620
+ params: { protocolVersion: "2024-11-05", capabilities: {},
621
+ clientInfo: { name: "scanner", version: "1.0.0" } } }),
622
+ })
623
+ if (!initResp.ok) { if (verbose) process.stderr.write(` [${name}: HTTP ${initResp.status}]`); return null }
624
+ const text = await initResp.text()
625
+ const initResult = (initResp.headers.get("content-type") || "").includes("text/event-stream")
626
+ ? parseSse(text) : JSON.parse(text)
627
+ if (!initResult?.result) { if (verbose) process.stderr.write(` [${name}: no init result]`); return null }
628
+ const sessionId = initResp.headers.get("Mcp-Session-Id")
629
+ if (sessionId) headers["Mcp-Session-Id"] = sessionId
630
+ const toolsResp = await fetch(url, {
631
+ method: "POST", headers,
632
+ body: JSON.stringify({ jsonrpc: "2.0", id: "2", method: "tools/list" }),
633
+ })
634
+ if (!toolsResp.ok) { if (verbose) process.stderr.write(` [${name}: tools HTTP ${toolsResp.status}]`); return null }
635
+ const toolsText = await toolsResp.text()
636
+ const toolsData = (toolsResp.headers.get("content-type") || "").includes("text/event-stream")
637
+ ? parseSse(toolsText) : JSON.parse(toolsText)
638
+ if (toolsData.error) { if (verbose) process.stderr.write(` [${name}: ${toolsData.error.message}]`); return null }
639
+ return calcStats(toolsData.result?.tools || [])
640
+ } catch (e) {
641
+ if (verbose) process.stderr.write(` [${name}: ${e.message}]`)
642
+ return null
643
+ }
644
+ }
645
+
646
+ async function cmdMeasure(config, asJson, verbose, perServerTimeout) {
647
+ const mcps = config.mcp || {}
648
+ const names = Object.keys(mcps)
649
+ const cachedTokens = await loadCachedTokens(verbose)
650
+ const savings = {}
651
+
652
+ if (!asJson) process.stderr.write(" Measuring")
653
+ for (const name of names) {
654
+ const entry = mcps[name]
655
+ if (entry.enabled === false) continue
656
+
657
+ let result = null
658
+ const isMcpRemote = entry.type === "local" &&
659
+ entry.command?.[0] === "mcp-remote" &&
660
+ entry.command?.[1]
661
+
662
+ try {
663
+ if (!asJson) process.stderr.write(".")
664
+ if (isMcpRemote) {
665
+ result = await measureViaCachedToken(name, entry.command[1], cachedTokens, entry.headers || {}, verbose)
666
+ } else if (entry.type === "local") {
667
+ result = await measureLocal(name, entry, verbose, perServerTimeout)
668
+ } else {
669
+ result = await measureRemote(name, entry, verbose)
670
+ }
671
+ } catch (e) {
672
+ if (verbose) process.stderr.write(` [${name}: ${e.message}]`)
673
+ }
674
+ if (result) savings[name] = result
675
+ }
676
+ if (!asJson) process.stderr.write(" done\n")
677
+
678
+ if (asJson) {
679
+ console.log(JSON.stringify({ savings }, null, 2))
680
+ return
681
+ }
682
+
683
+ const allNames = Object.keys(savings)
684
+ if (allNames.length === 0) {
685
+ console.log(`\n ${DIM}(no MCP servers connected or all failed)${RESET}\n`)
686
+ return
687
+ }
688
+
689
+ let grandChars = 0, grandTokenEst = 0, grandTools = 0
690
+ console.log(`\n${BOLD} TOKENS SAVED PER TURN${RESET} ${DIM}(by routing MCPs to subagents)${RESET}\n`)
691
+ for (const name of allNames) {
692
+ const s = savings[name]
693
+ const line = ` ${name.padEnd(12)} ${GREEN}${String(s.tools).padStart(3)} tools${RESET} ${String(s.chars).padStart(7)} chars ~${CYAN}${String(s.tokensEst).padStart(5)} tokens${RESET}`
694
+ console.log(line)
695
+ grandChars += s.chars
696
+ grandTokenEst += s.tokensEst
697
+ grandTools += s.tools
698
+ }
699
+ console.log(` ${DIM}${"-".repeat(52)}${RESET}`)
700
+ console.log(` ${BOLD}${"TOTAL".padEnd(12)}${RESET} ${GREEN}${String(grandTools).padStart(3)} tools${RESET} ${String(grandChars).padStart(7)} chars ~${CYAN}${String(grandTokenEst).padStart(6)} tokens${RESET}`)
701
+ console.log(` ${DIM}${"=".repeat(52)}${RESET}`)
702
+ console.log(` ${BOLD}Each user turn saves ~${grandTokenEst.toLocaleString()} tokens${RESET}`)
703
+ console.log(` ${DIM}that would otherwise be sent with every prompt.${RESET}\n`)
704
+ }
705
+
706
+ // ── Main ───────────────────────────────────────────────────
707
+ const args = process.argv.slice(2)
708
+ const rawCmd = args[0] || "help"
709
+ const flags = args.slice(1)
710
+ const asJson = flags.includes("--json")
711
+ const verbose = flags.includes("--verbose")
712
+
713
+ // Parse --timeout=N (seconds) from flags
714
+ let perServerTimeout = 60000 // default 60s
715
+ const timeoutFlag = flags.find(f => /^--timeout(=.+)?$/.test(f))
716
+ if (timeoutFlag) {
717
+ const val = timeoutFlag.includes("=") ? timeoutFlag.split("=")[1] : flags[flags.indexOf(timeoutFlag) + 1]
718
+ const n = parseInt(val, 10)
719
+ if (n > 0) perServerTimeout = n * 1000
720
+ }
721
+
722
+ const cwd = process.cwd()
723
+ const globalConfig = findConfig(homedir())
724
+ const projectConfig = findConfig(cwd)
725
+
726
+ const config = {
727
+ mcp: { ...(globalConfig?.mcp || {}), ...(projectConfig?.mcp || {}) },
728
+ agent: { ...(globalConfig?.agent || {}), ...(projectConfig?.agent || {}) },
729
+ tools: { ...(globalConfig?.tools || {}), ...(projectConfig?.tools || {}) },
730
+ }
731
+
732
+ if (!globalConfig && !projectConfig) {
733
+ console.log("No opencode.jsonc found in project or global config.")
734
+ process.exit(1)
735
+ }
736
+
737
+ const validCommands = Object.keys(COMMANDS)
738
+ if (!validCommands.includes(rawCmd)) {
739
+ const suggestion = suggestCommand(rawCmd, validCommands)
740
+ if (suggestion) {
741
+ console.log(`Did you mean "${suggestion}"? (typo: "${rawCmd}")`)
742
+ console.log("")
743
+ } else {
744
+ console.log(`Unknown command: "${rawCmd}"`)
745
+ console.log("")
746
+ }
747
+ cmdHelp()
748
+ process.exit(1)
749
+ }
750
+
751
+ switch (rawCmd) {
752
+ case "list":
753
+ cmdList(config, asJson)
754
+ break
755
+ case "status":
756
+ cmdStatus(config, asJson)
757
+ break
758
+ case "measure":
759
+ cmdMeasure(config, asJson, verbose, perServerTimeout).then(() => process.exit(0)).catch((e) => { process.stderr.write(`\n Fatal: ${e.message}\n`); process.exit(1) })
760
+ break
761
+ case "help":
762
+ default:
763
+ cmdHelp()
764
+ }