portawhip 0.1.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. package/surface-matrix.yaml +93 -0
@@ -0,0 +1,379 @@
1
+ // Auto-discovers already-installed capabilities from live tool state, so
2
+ // the registry links whatever is loaded — regardless of which host or tool
3
+ // originally installed it — instead of only the entries someone remembered
4
+ // to hand-type into recipe.yaml. This extends VISION.md's "cross-host/
5
+ // cross-OS by detection, not by list" principle from host detection to
6
+ // registry *content*.
7
+ //
8
+ // Delegates the actual detection to the same backends Step 1 already uses
9
+ // (add-mcp, asm, mise) — this module owns zero install/config parsing logic.
10
+
11
+ import spawnSync from "cross-spawn";
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+ import { readEnrichmentCache } from "./enrich.mjs";
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+ const ROOT = resolve(HERE, "..", "..");
20
+ const DISCOVERY_MAX_BUFFER = 16 * 1024 * 1024;
21
+ const SKILL_SCAN_MAX_DEPTH = 9;
22
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
23
+ const SURFACE_SKIP_DIRS = new Set(["docs"]);
24
+
25
+ // Small, deliberately conservative stoplist: generic words that appear in
26
+ // almost every skill description and would otherwise become high-frequency
27
+ // noise triggers (the exact failure mode that made v1's hook misfire).
28
+ const STOPWORDS = new Set([
29
+ "this", "that", "with", "from", "your", "have", "been", "using", "used",
30
+ "use", "when", "will", "into", "provides", "provide", "includes",
31
+ "include", "features", "feature", "across", "about", "these", "those",
32
+ "which", "where", "while", "then", "than", "also", "such", "only",
33
+ "some", "more", "most", "each", "every", "other", "make", "makes",
34
+ "making", "need", "needs", "needed", "help", "helps", "helping",
35
+ "user", "users", "their", "them", "they", "file", "files", "data",
36
+ "code", "tool", "tools", "agent", "agents", "claude", "system",
37
+ "essential", "developers", "developer", "building", "builds", "full",
38
+ "stack", "produces", "produce", "designed", "design", "applications",
39
+ "application",
40
+ ]);
41
+
42
+ function extractKeywords(name, description, max = 6) {
43
+ const text = `${name} ${description || ""}`.toLowerCase();
44
+ const words = text.match(/[a-z][a-z-]{3,}/g) || [];
45
+ const nameLower = name.toLowerCase();
46
+ const freq = new Map();
47
+ for (const w of words) {
48
+ if (STOPWORDS.has(w) || w === nameLower) continue;
49
+ freq.set(w, (freq.get(w) || 0) + 1);
50
+ }
51
+ const ranked = [...freq.entries()].sort((a, b) => b[1] - a[1]).map(([w]) => w);
52
+ return [name, ...ranked.slice(0, max)];
53
+ }
54
+
55
+ function truncate(text, max = 160) {
56
+ if (!text) return "";
57
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
58
+ }
59
+
60
+ function parseSkillFrontmatter(text, fallbackName) {
61
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
62
+ if (!match) return { name: fallbackName, description: "" };
63
+ const frontmatter = match[1];
64
+ const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? fallbackName;
65
+ const description =
66
+ frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
67
+ return { name, description };
68
+ }
69
+
70
+ function parseMarkdownFrontmatter(text, fallbackName) {
71
+ const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
72
+ const data = {};
73
+ if (match) {
74
+ for (const line of match[1].split(/\r?\n/)) {
75
+ const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
76
+ if (!field) continue;
77
+ data[field[1]] = field[2].replace(/^["']|["']$/g, "").trim();
78
+ }
79
+ }
80
+ const heading = String(text ?? "").match(/^#\s+(.+)$/m)?.[1]?.trim();
81
+ return {
82
+ name: data.name || fallbackName,
83
+ description: data.description || heading || "",
84
+ };
85
+ }
86
+
87
+ function markdownFilesUnder(root, segmentName, maxDepth = SKILL_SCAN_MAX_DEPTH) {
88
+ const found = [];
89
+ if (!existsSync(root)) return found;
90
+ // When the root itself IS the segment dir (a host-native leaf like
91
+ // ~/.claude/commands), start already in-segment so its .md files are
92
+ // collected directly — without this, only the segment-walk case (a
93
+ // "commands"/"agents" dir nested in a plugin tree) is found, and the
94
+ // host-native user dirs are silently missed.
95
+ const startInSegment = root.split(/[\\/]/).pop() === segmentName;
96
+ const stack = [{ dir: root, depth: 0, inSegment: startInSegment }];
97
+ while (stack.length > 0) {
98
+ const { dir, depth, inSegment } = stack.pop();
99
+ let dirents;
100
+ try {
101
+ dirents = readdirSync(dir, { withFileTypes: true });
102
+ } catch {
103
+ continue;
104
+ }
105
+ for (const dirent of dirents) {
106
+ const path = join(dir, dirent.name);
107
+ if (dirent.isFile() && inSegment && dirent.name.endsWith(".md")) {
108
+ found.push(path);
109
+ continue;
110
+ }
111
+ if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
112
+ if (SKIP_DIRS.has(dirent.name)) continue;
113
+ if (!inSegment && SURFACE_SKIP_DIRS.has(dirent.name)) continue;
114
+ if (depth >= maxDepth) continue;
115
+ stack.push({ dir: path, depth: depth + 1, inSegment: inSegment || dirent.name === segmentName });
116
+ }
117
+ }
118
+ return found;
119
+ }
120
+
121
+ function fileStem(path) {
122
+ return path.split(/[\\/]/).pop().replace(/\.md$/i, "");
123
+ }
124
+
125
+ function discoverMarkdownSurface(type, segmentName, roots = defaultPluginRoots()) {
126
+ const seen = new Map();
127
+ for (const root of roots) {
128
+ for (const path of markdownFilesUnder(root, segmentName)) {
129
+ try {
130
+ const fallback = fileStem(path);
131
+ const metadata = parseMarkdownFrontmatter(readFileSync(path, "utf8"), fallback);
132
+ const id = metadata.name || fallback;
133
+ if (seen.has(id)) continue;
134
+ const description = truncate(metadata.description || `${type}: ${id}`);
135
+ const slash = type === "command" ? `/${fallback}` : fallback;
136
+ seen.set(id, {
137
+ id,
138
+ type,
139
+ source: path,
140
+ path,
141
+ origin: `auto:${type}`,
142
+ route: {
143
+ triggers: [id, slash, ...extractKeywords(id, description)],
144
+ description,
145
+ when: ["user_prompt"],
146
+ inject: "hint",
147
+ },
148
+ });
149
+ } catch {
150
+ // A malformed markdown file should not block the whole surface scan.
151
+ }
152
+ }
153
+ }
154
+ return [...seen.values()];
155
+ }
156
+
157
+ export function defaultSkillRoots() {
158
+ return [
159
+ join(homedir(), ".codex", "skills"),
160
+ join(homedir(), ".agents", "skills"),
161
+ join(homedir(), ".claude", "skills"),
162
+ join(homedir(), ".claude", "plugins", "cache"),
163
+ join(homedir(), ".claude", "plugins", "marketplaces"),
164
+ ];
165
+ }
166
+
167
+ export function defaultPluginRoots() {
168
+ return [
169
+ join(homedir(), ".claude", "plugins", "cache"),
170
+ join(homedir(), ".claude", "plugins", "marketplaces"),
171
+ ];
172
+ }
173
+
174
+ // Host-native command/agent dirs (leaf dirs whose basename IS the segment, so
175
+ // markdownFilesUnder collects them directly), on top of the plugin roots.
176
+ // Data catalog, not decision logic — each path is a documented host
177
+ // convention (Claude Code: ~/.claude/{commands,agents} + project .claude/…).
178
+ // Codex prompts (~/.codex/prompts) use a different segment name and are added
179
+ // once a codex install is present to verify against. cwd-relative project
180
+ // dirs are resolved by the caller's process cwd, matching the router's
181
+ // per-project behavior.
182
+ export function defaultCommandRoots() {
183
+ return [
184
+ ...defaultPluginRoots(),
185
+ join(homedir(), ".claude", "commands"),
186
+ resolve(".claude", "commands"),
187
+ ];
188
+ }
189
+
190
+ export function defaultAgentRoots() {
191
+ return [
192
+ ...defaultPluginRoots(),
193
+ join(homedir(), ".claude", "agents"),
194
+ resolve(".claude", "agents"),
195
+ ];
196
+ }
197
+
198
+ function scanSkillDirs(root, maxDepth = SKILL_SCAN_MAX_DEPTH) {
199
+ const found = [];
200
+ if (!existsSync(root)) return found;
201
+ const stack = [{ dir: root, depth: 0 }];
202
+ while (stack.length > 0) {
203
+ const { dir, depth } = stack.pop();
204
+ const skillFile = join(dir, "SKILL.md");
205
+ if (existsSync(skillFile)) {
206
+ try {
207
+ if (!statSync(skillFile).isFile()) continue;
208
+ const frontmatter = parseSkillFrontmatter(readFileSync(skillFile, "utf8"), dir.split(/[\\/]/).pop());
209
+ found.push({
210
+ name: frontmatter.name,
211
+ description: frontmatter.description,
212
+ path: dir,
213
+ });
214
+ } catch {
215
+ // A broken symlink or unreadable skill should not break all discovery.
216
+ }
217
+ continue;
218
+ }
219
+ if (depth >= maxDepth) continue;
220
+ let dirents;
221
+ try {
222
+ dirents = readdirSync(dir, { withFileTypes: true });
223
+ } catch {
224
+ continue;
225
+ }
226
+ for (const dirent of dirents) {
227
+ if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
228
+ if (SKIP_DIRS.has(dirent.name)) continue;
229
+ stack.push({ dir: join(dir, dirent.name), depth: depth + 1 });
230
+ }
231
+ }
232
+ return found;
233
+ }
234
+
235
+ export function discoverSkillsFromDirs(roots = defaultSkillRoots()) {
236
+ const found = [];
237
+ for (const root of roots) {
238
+ found.push(...scanSkillDirs(root));
239
+ }
240
+ return found;
241
+ }
242
+
243
+ function runJsonCommand(candidates) {
244
+ let lastError = null;
245
+ for (const [command, args] of candidates) {
246
+ const result = spawnSync.sync(command, args, {
247
+ encoding: "utf8",
248
+ maxBuffer: DISCOVERY_MAX_BUFFER,
249
+ });
250
+ if (result.status === 0) return { ok: true, stdout: result.stdout };
251
+ lastError = result.error ?? new Error(result.stderr || `status ${result.status}`);
252
+ }
253
+ return { ok: false, error: lastError };
254
+ }
255
+
256
+ export async function discoverMcp(enrichCachePath) {
257
+ const { listInstalledServers } = await import("add-mcp");
258
+ const cache = enrichCachePath ? readEnrichmentCache(enrichCachePath) : {};
259
+ const hosts = await listInstalledServers({ global: true });
260
+ const seen = new Map();
261
+ for (const host of hosts) {
262
+ for (const server of host.servers ?? []) {
263
+ if (seen.has(server.serverName)) continue;
264
+ // `router-cli enrich` (core/enrich.mjs) precomputes real triggers by
265
+ // connecting to the server and asking its own tools/list — a bare
266
+ // server name alone only ever matches a prompt that names the server
267
+ // literally. Falls back to the bare name when nothing's cached yet
268
+ // (unenriched behavior, unchanged).
269
+ const enriched = cache[server.serverName];
270
+ seen.set(server.serverName, {
271
+ id: server.serverName,
272
+ type: "mcp",
273
+ source: server.identity,
274
+ path: null,
275
+ origin: "auto:mcp",
276
+ route: {
277
+ triggers: enriched?.triggers ?? [server.serverName],
278
+ description: enriched?.description ?? `MCP server: ${server.serverName}`,
279
+ when: ["user_prompt"],
280
+ inject: "hint",
281
+ },
282
+ });
283
+ }
284
+ }
285
+ return [...seen.values()];
286
+ }
287
+
288
+ export function discoverSkills() {
289
+ const localAsm = resolve(ROOT, "node_modules", "agent-skill-manager", "dist", "agent-skill-manager.js");
290
+ const candidates = existsSync(localAsm)
291
+ ? [[process.execPath, [localAsm, "list", "--json"]]]
292
+ : [];
293
+ candidates.push(["npx", ["--yes", "agent-skill-manager", "list", "--json"]]);
294
+
295
+ const result = runJsonCommand(candidates);
296
+ let skills = discoverSkillsFromDirs();
297
+ if (!result.ok) {
298
+ // Filesystem discovery above is the fallback source of truth.
299
+ } else {
300
+ try {
301
+ skills = [...JSON.parse(result.stdout), ...skills];
302
+ } catch {
303
+ // Keep filesystem discovery if ASM returns malformed JSON.
304
+ }
305
+ }
306
+ // The same skill is commonly installed under the same name in several
307
+ // hosts (e.g. Claude Code, Codex, Cursor all carry "pdf") — one real
308
+ // capability, so one entry; first-seen wins, same as discoverMcp.
309
+ const seen = new Map();
310
+ for (const s of skills) {
311
+ if (seen.has(s.name)) continue;
312
+ seen.set(s.name, {
313
+ id: s.name,
314
+ type: "skill",
315
+ source: s.path,
316
+ path: s.path ?? null,
317
+ origin: "auto:skill",
318
+ route: {
319
+ triggers: extractKeywords(s.name, s.description),
320
+ description: truncate(s.description),
321
+ when: ["user_prompt"],
322
+ inject: "hint",
323
+ },
324
+ });
325
+ }
326
+ return [...seen.values()];
327
+ }
328
+
329
+ export function discoverCli(enrichCachePath) {
330
+ const result = spawnSync.sync("mise", ["ls", "--json"], { encoding: "utf8" });
331
+ if (result.status !== 0) return [];
332
+ let tools;
333
+ try {
334
+ tools = JSON.parse(result.stdout);
335
+ } catch {
336
+ return [];
337
+ }
338
+ const cache = enrichCachePath ? readEnrichmentCache(enrichCachePath) : {};
339
+ return Object.keys(tools).map((name) => {
340
+ const enriched = cache[name];
341
+ return {
342
+ id: name,
343
+ type: "cli",
344
+ source: name,
345
+ path: null,
346
+ origin: "auto:cli",
347
+ route: {
348
+ triggers: enriched?.triggers ?? [name],
349
+ description: enriched?.description ?? `CLI tool: ${name}`,
350
+ when: ["user_prompt"],
351
+ inject: "hint",
352
+ },
353
+ };
354
+ });
355
+ }
356
+
357
+ export function discoverCommands(roots = defaultCommandRoots()) {
358
+ return discoverMarkdownSurface("command", "commands", roots);
359
+ }
360
+
361
+ export function discoverAgents(roots = defaultAgentRoots()) {
362
+ return discoverMarkdownSurface("agent", "agents", roots);
363
+ }
364
+
365
+ export async function discoverAll({ enrichCachePath } = {}) {
366
+ const [mcp, skills, cli, commands, agents] = await Promise.all([
367
+ discoverMcp(enrichCachePath),
368
+ Promise.resolve(discoverSkills()),
369
+ Promise.resolve(discoverCli(enrichCachePath)),
370
+ Promise.resolve(discoverCommands()),
371
+ Promise.resolve(discoverAgents()),
372
+ ]);
373
+ const byId = new Map();
374
+ for (const entry of [...mcp, ...skills, ...cli, ...commands, ...agents]) {
375
+ if (byId.has(entry.id)) continue;
376
+ byId.set(entry.id, entry);
377
+ }
378
+ return [...byId.values()];
379
+ }
@@ -0,0 +1,213 @@
1
+ // Enriches bare-name auto-discovered MCP/CLI tools with real
2
+ // triggers/descriptions. Runs at init/update time ONLY, via
3
+ // `router-cli enrich` — never inside loadIndex()/discoverAll(), which
4
+ // rebuild and re-discover on every single route() call. Connecting to
5
+ // every installed MCP server (and shelling out to package registries for
6
+ // CLI tools) on every prompt would make the router itself the bottleneck
7
+ // it exists to avoid.
8
+ //
9
+ // Found live 2026-07-06: add-mcp/mise only ever return a bare server/tool
10
+ // NAME with no description, so discover.mjs's auto:mcp/auto:cli entries end
11
+ // up with `triggers:[name]` — they only ever fire when a prompt names the
12
+ // tool literally ("use playwright to..."), and stay dead on the natural
13
+ // phrasing a real task actually uses ("open a browser and click login").
14
+ //
15
+ // Grounded-first ladder, LLM fallback deliberately NOT built yet (see
16
+ // memory: dead-auto-tools-enrichment.md) — spiked and confirmed this covers
17
+ // the large majority without one:
18
+ // MCP: add-mcp's own launch config -> @modelcontextprotocol/sdk listTools()
19
+ // CLI: `--help` first meaningful line -> `pip show` summary for pipx-
20
+ // installed tools only (a bare npm-view-by-name fallback was tried
21
+ // and dropped: no reliable name-to-package mapping without a
22
+ // registry/ecosystem hint mise doesn't expose, and it produced a
23
+ // real wrong-description collision - see enrichCli below) ->
24
+ // (LLM(name), future work, not built)
25
+
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import spawnSync from "cross-spawn";
29
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
31
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
32
+ import { enrichCliLadder } from "./cli-enrich.mjs";
33
+
34
+ export const DEFAULT_CACHE_PATH = join(".hp-state", "tool-descriptions.json");
35
+ const MAX_DESCRIPTION_CHARS = 300;
36
+ const MAX_MCP_SUBTOOL_TRIGGERS = 20;
37
+
38
+ export function readEnrichmentCache(cachePath = DEFAULT_CACHE_PATH) {
39
+ if (!existsSync(cachePath)) return {};
40
+ try {
41
+ return JSON.parse(readFileSync(cachePath, "utf8"));
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function writeEnrichmentCache(entries, cachePath = DEFAULT_CACHE_PATH) {
48
+ mkdirSync(dirname(cachePath), { recursive: true });
49
+ writeFileSync(cachePath, JSON.stringify(entries, null, 2));
50
+ }
51
+
52
+ // --- MCP: connect via the launch config add-mcp already gave us, ask the
53
+ // server itself what its tools do. Fails closed (returns null, leaving the
54
+ // bare-name entry as-is) on anything unreachable/slow/misconfigured — this
55
+ // is best-effort enrichment, never a requirement for the tool to route at
56
+ // all (VISION.md: live-probe, never overclaim).
57
+
58
+ async function enrichMcpServer(server, { timeoutMs = 8000 } = {}) {
59
+ const { serverName, config } = server;
60
+ if (!config) return null;
61
+ let client;
62
+ try {
63
+ const transport = config.url
64
+ ? new StreamableHTTPClientTransport(new URL(config.url))
65
+ : new StdioClientTransport({ command: config.command, args: config.args ?? [] });
66
+ client = new Client({ name: "portawhip-enrich", version: "0.1.0" }, { capabilities: {} });
67
+ await Promise.race([
68
+ client.connect(transport),
69
+ new Promise((_, reject) => setTimeout(() => reject(new Error("enrich timeout")), timeoutMs)),
70
+ ]);
71
+ const { tools } = await client.listTools();
72
+ const names = tools.map((t) => t.name).filter(Boolean);
73
+ const descriptions = tools.map((t) => t.description).filter(Boolean);
74
+ const description = descriptions.length
75
+ ? `MCP server: ${serverName} — ${descriptions.slice(0, 3).join("; ")}`.slice(0, MAX_DESCRIPTION_CHARS)
76
+ : `MCP server: ${serverName}${names.length ? ` (tools: ${names.slice(0, 8).join(", ")})` : ""}`;
77
+ return {
78
+ triggers: [serverName, ...names].slice(0, MAX_MCP_SUBTOOL_TRIGGERS),
79
+ description,
80
+ };
81
+ } catch {
82
+ return null;
83
+ } finally {
84
+ if (client) {
85
+ try {
86
+ await client.close();
87
+ } catch {
88
+ // best-effort teardown only
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ export async function enrichMcp({ timeoutMs = 8000 } = {}) {
95
+ const { listInstalledServers } = await import("add-mcp");
96
+ const hosts = await listInstalledServers({ global: true });
97
+ const seen = new Map();
98
+ for (const host of hosts) {
99
+ for (const server of host.servers ?? []) {
100
+ if (!seen.has(server.serverName)) seen.set(server.serverName, server);
101
+ }
102
+ }
103
+ const results = {};
104
+ for (const server of seen.values()) {
105
+ const enriched = await enrichMcpServer(server, { timeoutMs });
106
+ if (enriched) {
107
+ results[server.serverName] = { type: "mcp", ...enriched, enrichedAt: new Date().toISOString() };
108
+ }
109
+ }
110
+ return results;
111
+ }
112
+
113
+ // --- CLI: `--help` gives a real one-line description for some tools
114
+ // (jq/gitleaks/biome/hyperfine) but for many others the first line is just
115
+ // usage syntax or a version/author banner (pandoc/node/ripgrep) — those
116
+ // patterns are filtered out rather than accepted as a "description".
117
+
118
+ const NOT_A_DESCRIPTION = [
119
+ /^usage[:\s]/i,
120
+ /^\[options?\]/i,
121
+ /^[\w.-]+\.exe\b/i,
122
+ /^[\w.-]+ v?\d+(\.\d+)+/i, // "ripgrep 15.1.0", "tool v2.3"
123
+ /^-[\w-]+[,\s]/, // a bare flag list line
124
+ ];
125
+
126
+ // Exported for unit testing without shelling out — no other module needs it.
127
+ export function firstMeaningfulLine(text) {
128
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
129
+ const line = raw.trim();
130
+ if (!line) continue;
131
+ if (NOT_A_DESCRIPTION.some((pattern) => pattern.test(line))) return null;
132
+ return line;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function runCapture(command, args, { timeout = 6000 } = {}) {
138
+ const result = spawnSync.sync(command, args, { encoding: "utf8", timeout });
139
+ if (result.error || (result.status !== 0 && result.status !== null)) return null;
140
+ return (result.stdout || result.stderr || "").toString();
141
+ }
142
+
143
+ // Exported for unit testing — no other module needs it.
144
+ export function cliBinary(id) {
145
+ // "pipx:markitdown" -> "markitdown"; plain names pass through unchanged.
146
+ return id.includes(":") ? id.split(":").pop() : id;
147
+ }
148
+
149
+ // Same rule core/capability-docs.mjs's pointerFor() already established for
150
+ // resolving a mise-installed CLI at all: a bare binary name only resolves
151
+ // if the invoking shell happens to have `mise activate` wired in. Found
152
+ // live here too — bare `biome --help` failed with ENOENT even though
153
+ // `mise exec -- biome --help` works fine. `mise exec --` always works, on
154
+ // every OS, with zero setup.
155
+ function tryHelp(bin) {
156
+ const out = runCapture("mise", ["exec", "--", bin, "--help"]);
157
+ return out ? firstMeaningfulLine(out) : null;
158
+ }
159
+
160
+ function tryPipShow(bin) {
161
+ const out = runCapture("pip", ["show", bin]);
162
+ const match = out?.match(/^Summary:\s*(.+)$/m);
163
+ return match ? match[1].trim() : null;
164
+ }
165
+
166
+ // No blind npm-view fallback: found live that "rust" (a mise-installed
167
+ // language toolchain) collides with an unrelated npm package literally
168
+ // named "rust" ("Install the rust toolchain using npm"), producing a
169
+ // plausible-looking but WRONG description. `mise ls --json` exposes no
170
+ // ecosystem/backend field to confirm a bare tool name is actually the npm
171
+ // package of the same name, so there's no safe way to gate this — a wrong
172
+ // description actively misleads future matches, which is worse than the
173
+ // bare-name fallback. `pip show` stays safe because pipx installs are
174
+ // always a 1:1 name-to-PyPI-package mapping, never a name collision.
175
+
176
+ export function enrichCli(ids) {
177
+ const results = {};
178
+ for (const id of ids) {
179
+ const bin = cliBinary(id);
180
+ const description = tryHelp(bin) ?? (id.startsWith("pipx:") ? tryPipShow(bin) : null);
181
+ if (!description) continue;
182
+ results[id] = {
183
+ type: "cli",
184
+ triggers: [id, bin],
185
+ description: `CLI tool: ${bin} — ${description}`.slice(0, MAX_DESCRIPTION_CHARS),
186
+ enrichedAt: new Date().toISOString(),
187
+ };
188
+ }
189
+ return results;
190
+ }
191
+
192
+ function discoveredCliIds() {
193
+ const result = spawnSync.sync("mise", ["ls", "--json"], { encoding: "utf8" });
194
+ if (result.status !== 0) return [];
195
+ try {
196
+ return Object.keys(JSON.parse(result.stdout || "{}"));
197
+ } catch {
198
+ return [];
199
+ }
200
+ }
201
+
202
+ export async function runEnrichment({ cachePath = DEFAULT_CACHE_PATH, cliIds = null, timeoutMs = 8000, cliLadder = true } = {}) {
203
+ const existing = readEnrichmentCache(cachePath);
204
+ const mcp = await enrichMcp({ timeoutMs });
205
+ // CLI enrichment ladder (Phase S1b): mise-registry identity -> package
206
+ // registry JSON -> tldr -> --help/subcommands -> pip show. Falls back to the
207
+ // legacy --help/pip-only sync path if the ladder is explicitly disabled.
208
+ const ids = cliIds ?? discoveredCliIds();
209
+ const cli = cliLadder ? await enrichCliLadder(ids, { timeoutMs }) : enrichCli(ids);
210
+ const merged = { ...existing, ...mcp, ...cli };
211
+ writeEnrichmentCache(merged, cachePath);
212
+ return merged;
213
+ }
@@ -0,0 +1,53 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { firstMeaningfulLine, cliBinary, readEnrichmentCache } from "./enrich.mjs";
7
+
8
+ // enrichMcp/enrichCli/runEnrichment do real IO (spawn processes, connect to
9
+ // MCP servers) and are deliberately NOT unit-tested here — same boundary
10
+ // core/discover.mjs's own discoverMcp/discoverCli already draw (router.test.mjs
11
+ // only covers the filesystem-scan discovery functions). Verified live instead
12
+ // via `node core/router-cli.mjs enrich` against real installed tools.
13
+
14
+ test("firstMeaningfulLine: accepts a real one-line description", () => {
15
+ assert.equal(
16
+ firstMeaningfulLine("Biome official CLI. Use it to check the health of your project."),
17
+ "Biome official CLI. Use it to check the health of your project.",
18
+ );
19
+ });
20
+
21
+ test("firstMeaningfulLine: rejects a Usage: line", () => {
22
+ assert.equal(firstMeaningfulLine("Usage: node [options] [script.js]"), null);
23
+ });
24
+
25
+ test("firstMeaningfulLine: rejects a bare version+author banner", () => {
26
+ assert.equal(firstMeaningfulLine("ripgrep 15.1.0 (rev af60c2de9d)\nAndrew Gallant"), null);
27
+ });
28
+
29
+ test("firstMeaningfulLine: rejects a .exe usage line", () => {
30
+ assert.equal(firstMeaningfulLine("pandoc.exe [OPTIONS] [FILES]"), null);
31
+ });
32
+
33
+ test("firstMeaningfulLine: skips blank lines to find the first real content", () => {
34
+ assert.equal(firstMeaningfulLine("\n\nA command-line benchmarking tool.\nUsage: ..."), "A command-line benchmarking tool.");
35
+ });
36
+
37
+ test("cliBinary: strips a pipx: backend prefix", () => {
38
+ assert.equal(cliBinary("pipx:markitdown"), "markitdown");
39
+ });
40
+
41
+ test("cliBinary: passes a plain name through unchanged", () => {
42
+ assert.equal(cliBinary("ripgrep"), "ripgrep");
43
+ });
44
+
45
+ test("readEnrichmentCache: missing file returns empty object, not an error", () => {
46
+ const dir = mkdtempSync(join(tmpdir(), "enrich-cache-test-"));
47
+ try {
48
+ const cache = readEnrichmentCache(join(dir, "does-not-exist.json"));
49
+ assert.deepEqual(cache, {});
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });