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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- 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
|
+
});
|