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,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
+ });