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,316 @@
|
|
|
1
|
+
// CLI enrichment ladder (Phase S1b) — turn a bare-name CLI entry into one
|
|
2
|
+
// with real natural-language triggers + description, using DETERMINISTIC
|
|
3
|
+
// author/maintainer-written sources only. No LLM. Full rationale +
|
|
4
|
+
// evidence: docs/cli-enrichment-research.md.
|
|
5
|
+
//
|
|
6
|
+
// identity: `mise registry` maps short name -> backend:package (kills the
|
|
7
|
+
// name-collision that made enrich.mjs drop npm-view).
|
|
8
|
+
// describe: package-registry JSON (npm/PyPI/crates.io/GitHub) -> tldr-pages
|
|
9
|
+
// raw markdown -> `--help` first line -> `pip show` (pipx).
|
|
10
|
+
// triggers: name + subcommands + registry keywords + tldr example phrases.
|
|
11
|
+
//
|
|
12
|
+
// Pure parsers are separated from IO and exported for unit tests; every
|
|
13
|
+
// network/spawn call is guarded and fail-open (a missing source just drops a
|
|
14
|
+
// rung, never throws) — enrichment is best-effort, never required to route.
|
|
15
|
+
|
|
16
|
+
import spawnSync from "cross-spawn";
|
|
17
|
+
import { cliBinary, firstMeaningfulLine } from "./enrich.mjs";
|
|
18
|
+
|
|
19
|
+
const MAX_DESCRIPTION_CHARS = 300;
|
|
20
|
+
const MAX_TRIGGERS = 24;
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 6000;
|
|
22
|
+
const REGISTRY_MAX_BUFFER = 8 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
// Backends that map to a fetchable package registry, in preference order.
|
|
25
|
+
// vfox/core/ubi and friends have no package-metadata endpoint -> skipped.
|
|
26
|
+
const BACKEND_PRIORITY = ["npm", "pipx", "cargo", "aqua", "github", "go"];
|
|
27
|
+
|
|
28
|
+
// --- identity: mise registry -----------------------------------------------
|
|
29
|
+
|
|
30
|
+
// Pure: parse `mise registry` stdout into name -> [backend:pkg, ...].
|
|
31
|
+
export function parseMiseRegistry(text) {
|
|
32
|
+
const map = new Map();
|
|
33
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
34
|
+
const line = raw.trimEnd();
|
|
35
|
+
if (!line.trim()) continue;
|
|
36
|
+
const parts = line.split(/\s+/);
|
|
37
|
+
const name = parts.shift();
|
|
38
|
+
if (!name || !parts.length) continue;
|
|
39
|
+
map.set(name, parts);
|
|
40
|
+
}
|
|
41
|
+
return map;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Pure: pick the highest-priority backend token that has a fetchable
|
|
45
|
+
// registry, returning { backend, pkg }. Returns null when none qualifies.
|
|
46
|
+
export function pickBackend(tokens) {
|
|
47
|
+
const parsed = (tokens ?? [])
|
|
48
|
+
.map((tok) => {
|
|
49
|
+
const idx = tok.indexOf(":");
|
|
50
|
+
return idx === -1 ? null : { backend: tok.slice(0, idx), pkg: tok.slice(idx + 1) };
|
|
51
|
+
})
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
for (const backend of BACKEND_PRIORITY) {
|
|
54
|
+
const hit = parsed.find((p) => p.backend === backend);
|
|
55
|
+
if (hit) return hit;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- describe: package registry --------------------------------------------
|
|
61
|
+
|
|
62
|
+
// Pure: the JSON endpoint + a `kind` tag telling parsePackageMeta how to read
|
|
63
|
+
// the shape. github/aqua/go all resolve to the GitHub repos API.
|
|
64
|
+
export function packageMetaUrl(backend, pkg) {
|
|
65
|
+
if (backend === "npm") return { url: `https://registry.npmjs.org/${encodeURIComponent(pkg)}`, kind: "npm" };
|
|
66
|
+
if (backend === "pipx") return { url: `https://pypi.org/pypi/${encodeURIComponent(pkg)}/json`, kind: "pypi" };
|
|
67
|
+
if (backend === "cargo") return { url: `https://crates.io/api/v1/crates/${encodeURIComponent(pkg)}`, kind: "crates" };
|
|
68
|
+
const repo = githubRepo(backend, pkg);
|
|
69
|
+
if (repo) return { url: `https://api.github.com/repos/${repo}`, kind: "github" };
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// aqua/github: "org/repo". go: "github.com/org/repo/cmd/x" -> "org/repo".
|
|
74
|
+
function githubRepo(backend, pkg) {
|
|
75
|
+
if (backend === "aqua" || backend === "github") {
|
|
76
|
+
const m = pkg.match(/^([^/]+\/[^/]+)/);
|
|
77
|
+
return m ? m[1] : null;
|
|
78
|
+
}
|
|
79
|
+
if (backend === "go") {
|
|
80
|
+
const m = pkg.match(/github\.com\/([^/]+\/[^/]+)/);
|
|
81
|
+
return m ? m[1] : null;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pure: extract { description, keywords } from each registry's JSON shape.
|
|
87
|
+
export function parsePackageMeta(kind, json) {
|
|
88
|
+
if (!json || typeof json !== "object") return null;
|
|
89
|
+
if (kind === "npm") {
|
|
90
|
+
return clean(json.description, arr(json.keywords));
|
|
91
|
+
}
|
|
92
|
+
if (kind === "pypi") {
|
|
93
|
+
const info = json.info ?? {};
|
|
94
|
+
const kw = typeof info.keywords === "string" ? info.keywords.split(/[,\s]+/).filter(Boolean) : arr(info.keywords);
|
|
95
|
+
return clean(info.summary, kw);
|
|
96
|
+
}
|
|
97
|
+
if (kind === "crates") {
|
|
98
|
+
const crate = json.crate ?? {};
|
|
99
|
+
return clean(crate.description, [...arr(crate.keywords), ...arr(crate.categories)]);
|
|
100
|
+
}
|
|
101
|
+
if (kind === "github") {
|
|
102
|
+
return clean(json.description, arr(json.topics));
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function arr(value) {
|
|
108
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clean(description, keywords) {
|
|
112
|
+
const desc = typeof description === "string" ? description.trim() : "";
|
|
113
|
+
if (!desc && !(keywords && keywords.length)) return null;
|
|
114
|
+
return { description: desc || null, keywords: (keywords ?? []).map((k) => k.toLowerCase()) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- describe: tldr-pages ---------------------------------------------------
|
|
118
|
+
|
|
119
|
+
// Pure: candidate raw-markdown URLs for a command, most-likely platform first.
|
|
120
|
+
export function tldrUrls(cmd, platform = process.platform) {
|
|
121
|
+
const base = "https://raw.githubusercontent.com/tldr-pages/tldr/main/pages";
|
|
122
|
+
const osDir = platform === "win32" ? "windows" : platform === "darwin" ? "osx" : "linux";
|
|
123
|
+
const safe = encodeURIComponent(cmd);
|
|
124
|
+
return [`${base}/common/${safe}.md`, `${base}/${osDir}/${safe}.md`];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Pure: parse a tldr page into { summary, examples }. Summary = the `>` lines
|
|
128
|
+
// (minus "More information:"); examples = the `-` description lines, which are
|
|
129
|
+
// exactly the natural-language phrasing real prompts use.
|
|
130
|
+
export function parseTldr(md) {
|
|
131
|
+
const text = String(md ?? "");
|
|
132
|
+
if (!text.trim()) return null;
|
|
133
|
+
const summary = [];
|
|
134
|
+
const examples = [];
|
|
135
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
136
|
+
const line = raw.trim();
|
|
137
|
+
if (line.startsWith(">")) {
|
|
138
|
+
const s = line.slice(1).trim();
|
|
139
|
+
if (s && !/^more information/i.test(s)) summary.push(s);
|
|
140
|
+
} else if (line.startsWith("-")) {
|
|
141
|
+
const s = line.slice(1).trim().replace(/:$/, "");
|
|
142
|
+
if (s) examples.push(s.toLowerCase());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!summary.length && !examples.length) return null;
|
|
146
|
+
return { summary: summary.join(" ") || null, examples };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- merge -----------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
// Pure: combine ladder sources into one enrichment record. Description takes
|
|
152
|
+
// the first available in quality order; triggers union everything, capped.
|
|
153
|
+
// `sources` records provenance per field so a wrong value is traceable.
|
|
154
|
+
export function buildCliEnrichment(id, bin, { pkgMeta, tldr, helpLine, pipSummary, subcommands } = {}) {
|
|
155
|
+
const descChoice =
|
|
156
|
+
(pkgMeta?.description && { text: pkgMeta.description, source: "package" }) ||
|
|
157
|
+
(tldr?.summary && { text: tldr.summary, source: "tldr" }) ||
|
|
158
|
+
(helpLine && { text: helpLine, source: "help" }) ||
|
|
159
|
+
(pipSummary && { text: pipSummary, source: "pip" }) ||
|
|
160
|
+
null;
|
|
161
|
+
|
|
162
|
+
const triggers = [];
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
const add = (t) => {
|
|
165
|
+
const v = String(t ?? "").trim().toLowerCase();
|
|
166
|
+
if (v && !seen.has(v)) {
|
|
167
|
+
seen.add(v);
|
|
168
|
+
triggers.push(v);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
add(id);
|
|
172
|
+
add(bin);
|
|
173
|
+
for (const s of subcommands ?? []) add(`${bin} ${s}`);
|
|
174
|
+
for (const k of pkgMeta?.keywords ?? []) add(k);
|
|
175
|
+
for (const ex of tldr?.examples ?? []) add(ex);
|
|
176
|
+
|
|
177
|
+
if (!descChoice && triggers.length <= 2) return null; // nothing usable -> hold back (the anti-junk gate)
|
|
178
|
+
|
|
179
|
+
const description = descChoice
|
|
180
|
+
? `CLI tool: ${bin} — ${descChoice.text}`.slice(0, MAX_DESCRIPTION_CHARS)
|
|
181
|
+
: `CLI tool: ${bin}`;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
type: "cli",
|
|
185
|
+
triggers: triggers.slice(0, MAX_TRIGGERS),
|
|
186
|
+
description,
|
|
187
|
+
sources: {
|
|
188
|
+
description: descChoice?.source ?? null,
|
|
189
|
+
triggers: [
|
|
190
|
+
...(subcommands?.length ? ["help-subcommands"] : []),
|
|
191
|
+
...(pkgMeta?.keywords?.length ? ["package-keywords"] : []),
|
|
192
|
+
...(tldr?.examples?.length ? ["tldr-examples"] : []),
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
enrichedAt: new Date().toISOString(),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Pure: harvest subcommand names from `--help` output. Looks for a
|
|
200
|
+
// "Commands:"/"Subcommands:" section and takes the first token of each
|
|
201
|
+
// indented line. Conservative — returns [] when no such section.
|
|
202
|
+
export function parseSubcommands(helpText) {
|
|
203
|
+
const lines = String(helpText ?? "").split(/\r?\n/);
|
|
204
|
+
const out = [];
|
|
205
|
+
let inSection = false;
|
|
206
|
+
for (const raw of lines) {
|
|
207
|
+
if (/^(sub)?commands:/i.test(raw.trim())) {
|
|
208
|
+
inSection = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (inSection) {
|
|
212
|
+
if (!raw.trim()) {
|
|
213
|
+
if (out.length) break;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const m = raw.match(/^\s+([a-z][\w-]*)\b/);
|
|
217
|
+
if (m) out.push(m[1]);
|
|
218
|
+
else if (!/^\s/.test(raw)) break; // dedented -> section ended
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [...new Set(out)].slice(0, 12);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- IO (guarded, fail-open) -----------------------------------------------
|
|
225
|
+
|
|
226
|
+
export async function fetchJsonGuarded(url, { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
227
|
+
const body = await fetchTextGuarded(url, { timeoutMs, fetchImpl, accept: "application/json" });
|
|
228
|
+
if (!body) return null;
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(body);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function fetchTextGuarded(url, { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch, accept } = {}) {
|
|
237
|
+
const controller = new AbortController();
|
|
238
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
239
|
+
try {
|
|
240
|
+
const headers = { "user-agent": "portawhip-enrich" };
|
|
241
|
+
if (accept) headers.accept = accept;
|
|
242
|
+
const res = await fetchImpl(url, { signal: controller.signal, headers });
|
|
243
|
+
if (!res.ok) return null;
|
|
244
|
+
return await res.text();
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
} finally {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// mise registry (spawn) -> parsed identity map. Fail-open to an empty map so a
|
|
253
|
+
// machine without mise still runs the rest of the ladder (help/tldr).
|
|
254
|
+
export function miseRegistryMap(runner = spawnSync.sync) {
|
|
255
|
+
const r = runner("mise", ["registry"], { encoding: "utf8", maxBuffer: REGISTRY_MAX_BUFFER });
|
|
256
|
+
if (!r || r.status !== 0) return new Map();
|
|
257
|
+
return parseMiseRegistry(r.stdout);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// `--help` capture: prefer `mise exec --` (resolves a mise-managed tool on any
|
|
261
|
+
// shell, per enrich.mjs's finding), fall back to a direct spawn for tools
|
|
262
|
+
// installed outside mise (scoop/npm-g/cargo — e.g. rtk, icm).
|
|
263
|
+
function captureHelp(bin) {
|
|
264
|
+
const viaMise = spawnSync.sync("mise", ["exec", "--", bin, "--help"], { encoding: "utf8", timeout: DEFAULT_TIMEOUT_MS });
|
|
265
|
+
if (viaMise.status === 0) return `${viaMise.stdout || ""}${viaMise.stderr || ""}`;
|
|
266
|
+
const direct = spawnSync.sync(bin, ["--help"], { encoding: "utf8", timeout: DEFAULT_TIMEOUT_MS });
|
|
267
|
+
if (direct.error) return "";
|
|
268
|
+
return `${direct.stdout || ""}${direct.stderr || ""}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function pipSummary(bin) {
|
|
272
|
+
const r = spawnSync.sync("pip", ["show", bin], { encoding: "utf8", timeout: DEFAULT_TIMEOUT_MS });
|
|
273
|
+
if (r.error || r.status !== 0) return null;
|
|
274
|
+
const m = (r.stdout || "").match(/^Summary:\s*(.+)$/m);
|
|
275
|
+
return m ? m[1].trim() : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Full ladder for a set of CLI ids. Network (package registry + tldr) is
|
|
279
|
+
// injectable + guarded; a dropped rung just lowers quality, never throws.
|
|
280
|
+
// Runs at enrich time only (router-cli enrich), never on the route hot path.
|
|
281
|
+
export async function enrichCliLadder(ids, { registry, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
282
|
+
const reg = registry ?? miseRegistryMap();
|
|
283
|
+
const results = {};
|
|
284
|
+
for (const id of ids) {
|
|
285
|
+
const bin = cliBinary(id);
|
|
286
|
+
|
|
287
|
+
let pkgMeta = null;
|
|
288
|
+
const backend = pickBackend(reg.get(bin) ?? reg.get(id) ?? []);
|
|
289
|
+
if (backend) {
|
|
290
|
+
const endpoint = packageMetaUrl(backend.backend, backend.pkg);
|
|
291
|
+
if (endpoint) {
|
|
292
|
+
pkgMeta = parsePackageMeta(endpoint.kind, await fetchJsonGuarded(endpoint.url, { fetchImpl, timeoutMs }));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let tldr = null;
|
|
297
|
+
for (const url of tldrUrls(bin)) {
|
|
298
|
+
const md = await fetchTextGuarded(url, { fetchImpl, timeoutMs });
|
|
299
|
+
if (md) {
|
|
300
|
+
tldr = parseTldr(md);
|
|
301
|
+
if (tldr) break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const helpText = captureHelp(bin);
|
|
306
|
+
const enrichment = buildCliEnrichment(id, bin, {
|
|
307
|
+
pkgMeta,
|
|
308
|
+
tldr,
|
|
309
|
+
helpLine: firstMeaningfulLine(helpText),
|
|
310
|
+
subcommands: parseSubcommands(helpText),
|
|
311
|
+
pipSummary: id.startsWith("pipx:") ? pipSummary(bin) : null,
|
|
312
|
+
});
|
|
313
|
+
if (enrichment) results[id] = enrichment;
|
|
314
|
+
}
|
|
315
|
+
return results;
|
|
316
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
buildCliEnrichment,
|
|
5
|
+
fetchJsonGuarded,
|
|
6
|
+
packageMetaUrl,
|
|
7
|
+
parseMiseRegistry,
|
|
8
|
+
parsePackageMeta,
|
|
9
|
+
parseSubcommands,
|
|
10
|
+
parseTldr,
|
|
11
|
+
pickBackend,
|
|
12
|
+
tldrUrls,
|
|
13
|
+
} from "./cli-enrich.mjs";
|
|
14
|
+
|
|
15
|
+
test("parseMiseRegistry: name -> backend tokens, blank/short lines skipped", () => {
|
|
16
|
+
const map = parseMiseRegistry("ripgrep aqua:BurntSushi/ripgrep cargo:ripgrep\n\nrust core:rust\nbad-no-tokens\n");
|
|
17
|
+
assert.deepEqual(map.get("ripgrep"), ["aqua:BurntSushi/ripgrep", "cargo:ripgrep"]);
|
|
18
|
+
assert.deepEqual(map.get("rust"), ["core:rust"]);
|
|
19
|
+
assert.equal(map.has("bad-no-tokens"), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("pickBackend: honors priority (npm>cargo>aqua) and skips unfetchable", () => {
|
|
23
|
+
assert.deepEqual(pickBackend(["aqua:x/y", "cargo:z", "npm:@a/b"]), { backend: "npm", pkg: "@a/b" });
|
|
24
|
+
assert.deepEqual(pickBackend(["core:rust"]), null);
|
|
25
|
+
assert.deepEqual(pickBackend(["aqua:BurntSushi/ripgrep", "cargo:ripgrep"]), {
|
|
26
|
+
backend: "cargo",
|
|
27
|
+
pkg: "ripgrep",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("packageMetaUrl: per-ecosystem endpoints, github for aqua/github/go", () => {
|
|
32
|
+
assert.equal(packageMetaUrl("npm", "amp").kind, "npm");
|
|
33
|
+
assert.equal(packageMetaUrl("pipx", "ruff").url, "https://pypi.org/pypi/ruff/json");
|
|
34
|
+
assert.equal(packageMetaUrl("cargo", "ripgrep").url, "https://crates.io/api/v1/crates/ripgrep");
|
|
35
|
+
assert.equal(packageMetaUrl("aqua", "BurntSushi/ripgrep").url, "https://api.github.com/repos/BurntSushi/ripgrep");
|
|
36
|
+
assert.equal(packageMetaUrl("go", "github.com/rhysd/actionlint/cmd/actionlint").url, "https://api.github.com/repos/rhysd/actionlint");
|
|
37
|
+
assert.equal(packageMetaUrl("core", "rust"), null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("parsePackageMeta: reads each registry shape", () => {
|
|
41
|
+
assert.deepEqual(parsePackageMeta("npm", { description: "Fast CLI", keywords: ["Cli", "fast"] }), {
|
|
42
|
+
description: "Fast CLI",
|
|
43
|
+
keywords: ["cli", "fast"],
|
|
44
|
+
});
|
|
45
|
+
assert.equal(parsePackageMeta("pypi", { info: { summary: "A linter", keywords: "lint,python" } }).keywords.length, 2);
|
|
46
|
+
assert.equal(parsePackageMeta("crates", { crate: { description: "grep", keywords: ["search"] } }).description, "grep");
|
|
47
|
+
assert.equal(parsePackageMeta("github", { description: "tool", topics: ["cli"] }).description, "tool");
|
|
48
|
+
assert.equal(parsePackageMeta("npm", { description: "", keywords: [] }), null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("tldrUrls: common first, then platform dir", () => {
|
|
52
|
+
const urls = tldrUrls("rg", "win32");
|
|
53
|
+
assert.match(urls[0], /pages\/common\/rg\.md$/);
|
|
54
|
+
assert.match(urls[1], /pages\/windows\/rg\.md$/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("parseTldr: extracts summary and lowercased example phrases", () => {
|
|
58
|
+
const md = `# rg
|
|
59
|
+
|
|
60
|
+
> Search for patterns using regex.
|
|
61
|
+
> More information: https://example.com.
|
|
62
|
+
|
|
63
|
+
- Recursively search for a pattern:
|
|
64
|
+
|
|
65
|
+
\`rg {{pattern}}\`
|
|
66
|
+
|
|
67
|
+
- Search a specific file type:
|
|
68
|
+
|
|
69
|
+
\`rg -t {{py}} {{pattern}}\``;
|
|
70
|
+
const out = parseTldr(md);
|
|
71
|
+
assert.equal(out.summary, "Search for patterns using regex.");
|
|
72
|
+
assert.deepEqual(out.examples, ["recursively search for a pattern", "search a specific file type"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("parseSubcommands: harvests a Commands: section, stops at dedent", () => {
|
|
76
|
+
const help = `Usage: tool [cmd]
|
|
77
|
+
|
|
78
|
+
Commands:
|
|
79
|
+
gain show savings
|
|
80
|
+
discover analyze history
|
|
81
|
+
proxy run raw
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
--help`;
|
|
85
|
+
assert.deepEqual(parseSubcommands(help), ["gain", "discover", "proxy"]);
|
|
86
|
+
assert.deepEqual(parseSubcommands("no commands here"), []);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("buildCliEnrichment: merges sources, description quality order, provenance", () => {
|
|
90
|
+
const out = buildCliEnrichment("rg", "rg", {
|
|
91
|
+
pkgMeta: { description: "ripgrep recursively searches", keywords: ["grep", "search"] },
|
|
92
|
+
tldr: { summary: "Search files", examples: ["recursively search for a pattern"] },
|
|
93
|
+
helpLine: "usage banner",
|
|
94
|
+
subcommands: [],
|
|
95
|
+
});
|
|
96
|
+
assert.match(out.description, /ripgrep recursively searches/);
|
|
97
|
+
assert.equal(out.sources.description, "package");
|
|
98
|
+
assert.ok(out.triggers.includes("rg"));
|
|
99
|
+
assert.ok(out.triggers.includes("grep"));
|
|
100
|
+
assert.ok(out.triggers.includes("recursively search for a pattern"));
|
|
101
|
+
assert.ok(out.sources.triggers.includes("tldr-examples"));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("buildCliEnrichment: nothing usable -> null (anti-junk gate)", () => {
|
|
105
|
+
assert.equal(buildCliEnrichment("obscure", "obscure", {}), null);
|
|
106
|
+
// a lone name with no description and no extra triggers is held back
|
|
107
|
+
assert.equal(buildCliEnrichment("x", "x", { subcommands: [] }), null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("fetchJsonGuarded: injected fetch, bad JSON and !ok fail-open to null", async () => {
|
|
111
|
+
const okJson = await fetchJsonGuarded("u", { fetchImpl: async () => ({ ok: true, text: async () => '{"a":1}' }) });
|
|
112
|
+
assert.deepEqual(okJson, { a: 1 });
|
|
113
|
+
const bad = await fetchJsonGuarded("u", { fetchImpl: async () => ({ ok: true, text: async () => "not json" }) });
|
|
114
|
+
assert.equal(bad, null);
|
|
115
|
+
const notOk = await fetchJsonGuarded("u", { fetchImpl: async () => ({ ok: false }) });
|
|
116
|
+
assert.equal(notOk, null);
|
|
117
|
+
const threw = await fetchJsonGuarded("u", { fetchImpl: async () => { throw new Error("net"); } });
|
|
118
|
+
assert.equal(threw, null);
|
|
119
|
+
});
|