cue-ai 0.4.1 → 0.6.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 (179) hide show
  1. package/README.md +926 -62
  2. package/package.json +6 -5
  3. package/profiles/README.md +12 -12
  4. package/profiles/SCHEMA.md +31 -3
  5. package/profiles/_cache/README.md +1 -1
  6. package/profiles/_types.ts +26 -1
  7. package/profiles/affiliate/profile.yaml +67 -0
  8. package/profiles/backend/profile.yaml +1 -0
  9. package/profiles/career/profile.yaml +13 -0
  10. package/profiles/core/profile.yaml +76 -9
  11. package/profiles/creative-media/README.md +1 -1
  12. package/profiles/cybersecurity/profile.yaml +779 -756
  13. package/profiles/ecc/profile.yaml +39 -0
  14. package/profiles/event-design/profile.yaml +10 -0
  15. package/profiles/fleet-control/README.md +1 -1
  16. package/profiles/frontend/profile.yaml +14 -0
  17. package/profiles/full/README.md +1 -1
  18. package/profiles/go-api/profile.yaml +9 -0
  19. package/profiles/marketing/profile.yaml +15 -1
  20. package/profiles/nextjs/profile.yaml +8 -0
  21. package/profiles/predict-everything/profile.yaml +9 -0
  22. package/profiles/python-api/profile.yaml +8 -0
  23. package/profiles/rust/profile.yaml +27 -0
  24. package/profiles/rust-cli/profile.yaml +14 -0
  25. package/profiles/rust-core/profile.yaml +35 -0
  26. package/profiles/rust-embedded/profile.yaml +11 -0
  27. package/profiles/rust-ffi/profile.yaml +13 -0
  28. package/profiles/rust-game/profile.yaml +11 -0
  29. package/profiles/rust-wasm/profile.yaml +11 -0
  30. package/profiles/rust-web/profile.yaml +17 -0
  31. package/profiles/schema.json +44 -4
  32. package/profiles/trendradar/profile.yaml +11 -0
  33. package/profiles/video/profile.yaml +10 -0
  34. package/resources/mcps/README.md +39 -164
  35. package/resources/mcps/configs/claude.sanitized.json +55 -0
  36. package/resources/mcps/configs/claude_runtime.sanitized.json +62 -1
  37. package/resources/skills/README.md +70 -113
  38. package/resources/skills/skills/design/headless-gif-demo/SKILL.md +57 -12
  39. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
  40. package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
  41. package/resources/skills/skills/meta/awesome-list-submit/SKILL.md +463 -0
  42. package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
  43. package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
  44. package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
  45. package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
  46. package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
  47. package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
  48. package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
  49. package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
  50. package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
  51. package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
  52. package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
  53. package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
  54. package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
  55. package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
  56. package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
  57. package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
  58. package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
  59. package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
  60. package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
  61. package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
  62. package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
  63. package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
  64. package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
  65. package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
  66. package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
  67. package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
  68. package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
  69. package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
  70. package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
  71. package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
  72. package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
  73. package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
  74. package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
  75. package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
  76. package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
  77. package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
  78. package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
  79. package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
  80. package/resources/skills/skills/rust/serde/SKILL.md +30 -0
  81. package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
  82. package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
  83. package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
  84. package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
  85. package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
  86. package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
  87. package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
  88. package/src/commands/_index.ts +91 -3
  89. package/src/commands/ai-score.e2e.test.ts +113 -0
  90. package/src/commands/ai.ts +179 -0
  91. package/src/commands/benchmark.ts +258 -0
  92. package/src/commands/clean.ts +109 -0
  93. package/src/commands/cli.test.ts +192 -0
  94. package/src/commands/cli.ts +303 -0
  95. package/src/commands/completions.ts +4 -0
  96. package/src/commands/cost.ts +77 -3
  97. package/src/commands/current.ts +1 -1
  98. package/src/commands/debug.test.ts +62 -0
  99. package/src/commands/debug.ts +212 -0
  100. package/src/commands/diff.ts +105 -0
  101. package/src/commands/discover.scoring.test.ts +216 -0
  102. package/src/commands/discover.test.ts +145 -0
  103. package/src/commands/discover.ts +2618 -0
  104. package/src/commands/eval-behavior.test.ts +56 -0
  105. package/src/commands/eval-behavior.ts +189 -0
  106. package/src/commands/eval.test.ts +102 -0
  107. package/src/commands/eval.ts +348 -0
  108. package/src/commands/evolve.ts +291 -0
  109. package/src/commands/failures.test.ts +78 -0
  110. package/src/commands/failures.ts +393 -0
  111. package/src/commands/feedback.ts +219 -0
  112. package/src/commands/import-profile.ts +28 -5
  113. package/src/commands/init.ts +26 -0
  114. package/src/commands/launch.e2e.test.ts +127 -0
  115. package/src/commands/launch.ts +193 -11
  116. package/src/commands/lint-skill.ts +157 -0
  117. package/src/commands/lock.ts +21 -1
  118. package/src/commands/marketplace.ts +850 -4
  119. package/src/commands/migrate.ts +100 -0
  120. package/src/commands/new.ts +1 -1
  121. package/src/commands/optimizer.ts +94 -30
  122. package/src/commands/profile-draft-skill.test.ts +96 -0
  123. package/src/commands/profile-draft-skill.ts +287 -0
  124. package/src/commands/profile-evolve.test.ts +126 -0
  125. package/src/commands/profile-evolve.ts +0 -0
  126. package/src/commands/profile-suggest.ts +223 -0
  127. package/src/commands/profile.ts +41 -0
  128. package/src/commands/quick.ts +2 -17
  129. package/src/commands/replay-whatif.ts +142 -0
  130. package/src/commands/replay.ts +6 -0
  131. package/src/commands/scan.ts +2 -2
  132. package/src/commands/score.ts +304 -0
  133. package/src/commands/security.ts +47 -7
  134. package/src/commands/share.ts +1 -1
  135. package/src/commands/shell.ts +17 -0
  136. package/src/commands/skills.ts +2 -2
  137. package/src/commands/sources.ts +2 -2
  138. package/src/commands/status.ts +14 -0
  139. package/src/commands/submit-profile.ts +262 -0
  140. package/src/commands/suggest.ts +170 -0
  141. package/src/commands/upgrade.ts +154 -0
  142. package/src/commands/use.ts +35 -5
  143. package/src/commands/validate.ts +1 -1
  144. package/src/index.ts +24 -1
  145. package/src/lib/analytics.ts +121 -3
  146. package/src/lib/auto-detect.ts +38 -5
  147. package/src/lib/cache.ts +47 -6
  148. package/src/lib/claude-binary.ts +39 -0
  149. package/src/lib/cli-extractor.ts +77 -0
  150. package/src/lib/cluster-skills.test.ts +268 -0
  151. package/src/lib/cluster-skills.ts +290 -0
  152. package/src/lib/credentials-sync.test.ts +208 -0
  153. package/src/lib/credentials-sync.ts +205 -0
  154. package/src/lib/mcp-materializer.test.ts +1 -1
  155. package/src/lib/persona-playbooks.test.ts +111 -0
  156. package/src/lib/pr-poster.test.ts +243 -0
  157. package/src/lib/pr-poster.ts +285 -0
  158. package/src/lib/pr-throttle.test.ts +148 -0
  159. package/src/lib/pr-throttle.ts +209 -0
  160. package/src/lib/profile-generator.test.ts +1 -1
  161. package/src/lib/profile-generator.ts +2 -2
  162. package/src/lib/profile-linter.test.ts +6 -3
  163. package/src/lib/profile-linter.ts +71 -8
  164. package/src/lib/profile-loader.test.ts +1 -1
  165. package/src/lib/profile-loader.ts +16 -0
  166. package/src/lib/resolver-local.test.ts +1 -1
  167. package/src/lib/resolver-npx.test.ts +76 -1
  168. package/src/lib/resolver-npx.ts +35 -3
  169. package/src/lib/resolver-plugins.test.ts +1 -1
  170. package/src/lib/runtime-materializer.test.ts +213 -18
  171. package/src/lib/runtime-materializer.ts +364 -53
  172. package/src/lib/scan-plugins.test.ts +1 -1
  173. package/src/lib/skill-linter.test.ts +174 -0
  174. package/src/lib/skill-linter.ts +507 -0
  175. package/src/lib/skill-subset.test.ts +95 -0
  176. package/src/lib/skill-subset.ts +166 -0
  177. package/src/lib/star-prompt.ts +11 -1
  178. package/src/lib/uvx-installer.test.ts +229 -0
  179. package/src/lib/uvx-installer.ts +278 -0
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { lint, applyFixes, buildPrBody } from "./skill-linter";
3
+
4
+ const cleanSkill = `---
5
+ name: example-skill
6
+ description: Use when the user asks to do X with Y. Triggers on phrases like "do x".
7
+ tags: [example, demo]
8
+ allowed-tools: Bash(echo:*)
9
+ ---
10
+
11
+ # Example Skill
12
+
13
+ This is a demo skill body.
14
+
15
+ ## Prerequisites
16
+
17
+ - \`echo\` — built-in
18
+ `;
19
+
20
+ describe("skill-linter rules", () => {
21
+ test("clean skill emits no errors", () => {
22
+ const { diagnostics } = lint(cleanSkill);
23
+ expect(diagnostics.filter((d) => d.severity === "error")).toEqual([]);
24
+ });
25
+
26
+ test("R001: missing name is flagged + fixable from H1", () => {
27
+ const md = `---\ndescription: x\n---\n# My Skill\n`;
28
+ const diags = lint(md).diagnostics;
29
+ const r001 = diags.find((d) => d.rule === "R001");
30
+ expect(r001?.severity).toBe("error");
31
+ expect(typeof r001?.fix).toBe("function");
32
+ const fixed = r001!.fix!(md);
33
+ expect(fixed).toMatch(/name:\s*my-skill/);
34
+ });
35
+
36
+ test("R002: missing description is flagged (not auto-fixable)", () => {
37
+ const md = `---\nname: x\n---\n# X\n`;
38
+ const r002 = lint(md).diagnostics.find((d) => d.rule === "R002");
39
+ expect(r002?.severity).toBe("error");
40
+ expect(r002?.fix).toBeUndefined();
41
+ });
42
+
43
+ test("R003: description >200 chars is flagged", () => {
44
+ const long = "A".repeat(250);
45
+ const md = `---\nname: x\ndescription: ${long}\n---\n`;
46
+ const r003 = lint(md).diagnostics.find((d) => d.rule === "R003");
47
+ expect(r003?.severity).toBe("warning");
48
+ });
49
+
50
+ test("R004: description without trigger phrase is flagged", () => {
51
+ const md = `---\nname: x\ndescription: A library for parsing things.\n---\n`;
52
+ const r004 = lint(md).diagnostics.find((d) => d.rule === "R004");
53
+ expect(r004?.severity).toBe("warning");
54
+ });
55
+
56
+ test("R004: description WITH trigger phrase passes", () => {
57
+ const md = `---\nname: x\ndescription: Use when the user asks for parsing.\n---\n`;
58
+ expect(lint(md).diagnostics.find((d) => d.rule === "R004")).toBeUndefined();
59
+ });
60
+
61
+ test("R005: bare allowed-tools is flagged + fixed to Bash(name:*) form", () => {
62
+ const md = `---\nname: x\ndescription: Use when X.\nallowed-tools: nmap, curl\n---\n# X\n`;
63
+ const r005 = lint(md).diagnostics.find((d) => d.rule === "R005");
64
+ expect(r005?.severity).toBe("error");
65
+ const fixed = r005!.fix!(md);
66
+ expect(fixed).toContain("Bash(nmap:*)");
67
+ expect(fixed).toContain("Bash(curl:*)");
68
+ });
69
+
70
+ test("R006: skill declares CLIs but no Prerequisites — flagged + fixed", () => {
71
+ const md = `---\nname: x\ndescription: Use when X.\nallowed-tools: Bash(nmap:*), Bash(sqlmap:*)\n---\n\n# X\n\nThis does things.\n`;
72
+ const r006 = lint(md).diagnostics.find((d) => d.rule === "R006");
73
+ expect(r006?.severity).toBe("warning");
74
+ const fixed = r006!.fix!(md);
75
+ expect(fixed).toMatch(/^## Prerequisites$/m);
76
+ expect(fixed).toContain("**nmap**");
77
+ expect(fixed).toContain("**sqlmap**");
78
+ });
79
+
80
+ test("R006: skill with existing Prerequisites is not flagged", () => {
81
+ const md = `---\nname: x\ndescription: Use when X.\nallowed-tools: Bash(nmap:*)\n---\n\n# X\n\n## Prerequisites\n\n- nmap\n`;
82
+ expect(lint(md).diagnostics.find((d) => d.rule === "R006")).toBeUndefined();
83
+ });
84
+
85
+ test("R007: no tags/domain/category is info-level (not error)", () => {
86
+ const md = `---\nname: x\ndescription: Use when X.\n---\n`;
87
+ const r007 = lint(md).diagnostics.find((d) => d.rule === "R007");
88
+ expect(r007?.severity).toBe("info");
89
+ });
90
+
91
+ test("R008: broken anchor link is flagged", () => {
92
+ const md = `---\nname: x\ndescription: Use when X.\n---\n\n# X\n\nSee [details](#missing-section).\n`;
93
+ const r008 = lint(md).diagnostics.find((d) => d.rule === "R008");
94
+ expect(r008?.severity).toBe("warning");
95
+ expect(r008?.message).toContain("missing-section");
96
+ });
97
+ });
98
+
99
+ describe("applyFixes round-trip", () => {
100
+ test("fixing a broken skill makes errors disappear (round-trip)", () => {
101
+ const broken = `---\nallowed-tools: nmap, sqlmap\n---\n# Pen Test Helper\n\nDoes stuff.\n`;
102
+ const { fixed, applied } = applyFixes(broken);
103
+ expect(applied).toContain("R001"); // name added
104
+ expect(applied).toContain("R005"); // allowed-tools fixed
105
+ expect(applied).toContain("R006"); // Prerequisites added
106
+ // After fix, those three rules should no longer be flagged
107
+ const remaining = lint(fixed).diagnostics.map((d) => d.rule);
108
+ expect(remaining).not.toContain("R001");
109
+ expect(remaining).not.toContain("R005");
110
+ expect(remaining).not.toContain("R006");
111
+ });
112
+
113
+ test("applyFixes is idempotent — running twice is the same as once", () => {
114
+ const broken = `---\nallowed-tools: nmap\n---\n# X\n`;
115
+ const once = applyFixes(broken).fixed;
116
+ const twice = applyFixes(once).fixed;
117
+ expect(twice).toBe(once);
118
+ });
119
+ });
120
+
121
+ describe("buildPrBody", () => {
122
+ test("emits a title and body referencing the repo and listing fixes", () => {
123
+ const before = `---\nallowed-tools: nmap\n---\n# X\n`;
124
+ const { fixed, applied } = applyFixes(before);
125
+ const fixedDiags = lint(before).diagnostics.filter((d) => applied.includes(d.rule));
126
+ const left = lint(fixed).diagnostics;
127
+ const { title, body } = buildPrBody({
128
+ repo: "demo/skill",
129
+ files: [{ path: "SKILL.md", before, after: fixed, fixedRules: [...new Set(applied)] }],
130
+ diagnosticsFixed: fixedDiags, diagnosticsLeft: left,
131
+ });
132
+ expect(title).toContain("cue:");
133
+ expect(body).toContain("demo/skill");
134
+ expect(body).toContain("`cue`");
135
+ expect(body).toContain("opt out");
136
+ // Title now names the actual fixes (R001 → "add missing name:")
137
+ expect(title).toMatch(/name|prerequisites|allowed-tools/i);
138
+ // Body contains an inline diff
139
+ expect(body).toContain("```diff");
140
+ });
141
+ });
142
+
143
+ describe("buildPrTitle", () => {
144
+ test("0 fixed rules → flagged review title", async () => {
145
+ const { buildPrTitle } = await import("./skill-linter");
146
+ expect(buildPrTitle([], ["R002"])).toMatch(/spec issues need review/);
147
+ });
148
+ test("1 rule → single-clause title", async () => {
149
+ const { buildPrTitle } = await import("./skill-linter");
150
+ expect(buildPrTitle(["R005"], [])).toMatch(/fix `allowed-tools` syntax/);
151
+ });
152
+ test("2 rules → joined with +", async () => {
153
+ const { buildPrTitle } = await import("./skill-linter");
154
+ expect(buildPrTitle(["R005", "R006"], [])).toMatch(/allowed-tools.*\+.*Prerequisites/);
155
+ });
156
+ test("3+ rules → truncates with `+N more`", async () => {
157
+ const { buildPrTitle } = await import("./skill-linter");
158
+ expect(buildPrTitle(["R001", "R005", "R006", "R007"], [])).toMatch(/\+\d+ more/);
159
+ });
160
+ });
161
+
162
+ describe("R006 with cli-recipes", () => {
163
+ test("Prerequisites section uses per-platform install commands from cli-recipes.json", () => {
164
+ const md = `---\nname: x\ndescription: Use when X.\nallowed-tools: Bash(nmap:*)\n---\n\n# X\n\nBody.\n`;
165
+ const { fixed } = applyFixes(md);
166
+ expect(fixed).toContain("sudo apt install -y nmap");
167
+ expect(fixed).toContain("brew install nmap");
168
+ });
169
+ test("snap-only recipe (helm) emits snap command", () => {
170
+ const md = `---\nname: x\ndescription: Use when X.\nallowed-tools: Bash(helm:*)\n---\n\n# X\n\nBody.\n`;
171
+ const { fixed } = applyFixes(md);
172
+ expect(fixed).toContain("sudo snap install helm");
173
+ });
174
+ });
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Pure SKILL.md linter. Validates against the Anthropic SKILL.md spec and
3
+ * emits both diagnostics and fix functions where appropriate.
4
+ *
5
+ * Each rule is independent. Rules return Diagnostic[] (zero diagnostics means
6
+ * the rule passed). A rule can optionally provide a `fix` that transforms the
7
+ * SKILL.md content string; the caller (cue lint-skill --fix) decides whether
8
+ * to apply.
9
+ *
10
+ * No I/O. No network. Callers handle file reads, writes, and PR posting.
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { join, resolve, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { parseCLIsFromContent, parseMetadataFromContent } from "../commands/optimizer";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Per-CLI install command lookup (used by R006).
21
+ // Reads resources/cli-recipes.json so the auto-generated Prerequisites
22
+ // section emits real commands instead of generic "use your package manager".
23
+ // ---------------------------------------------------------------------------
24
+
25
+ interface Recipe { apt?: string; brew?: string; dnf?: string; pacman?: string; snap?: string; pip?: string; pipx?: string; npm?: string; script?: string; manual?: string; needs?: string; }
26
+ let _recipesCache: Record<string, Recipe> | null = null;
27
+ function loadRecipes(): Record<string, Recipe> {
28
+ if (_recipesCache) return _recipesCache;
29
+ try {
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const path = resolve(here, "..", "..", "resources", "cli-recipes.json");
32
+ _recipesCache = JSON.parse(readFileSync(path, "utf8")) as Record<string, Recipe>;
33
+ } catch {
34
+ _recipesCache = {};
35
+ }
36
+ return _recipesCache;
37
+ }
38
+
39
+ /**
40
+ * Render the install line for a single CLI. Prefers per-platform package
41
+ * managers (Linux + macOS), falls back to manual hint. Emits a single
42
+ * Markdown list item that's safe to embed in any SKILL.md.
43
+ */
44
+ function renderInstallLine(cli: string): string {
45
+ const r = loadRecipes()[cli];
46
+ if (!r) return `- \`${cli}\` — install via your package manager`;
47
+ const segments: string[] = [];
48
+ // Linux options (prefer apt as most common, then snap, then dnf/pacman)
49
+ if (r.apt) segments.push(`apt: \`sudo apt install -y ${r.apt}\``);
50
+ else if (r.snap) segments.push(`snap: \`sudo snap install ${r.snap} --classic\``);
51
+ else if (r.dnf) segments.push(`dnf: \`sudo dnf install -y ${r.dnf}\``);
52
+ else if (r.pacman) segments.push(`pacman: \`sudo pacman -S ${r.pacman}\``);
53
+ if (r.brew) segments.push(`brew: \`brew install ${r.brew}\``);
54
+ if (r.pipx) segments.push(`pipx: \`pipx install ${r.pipx}\``);
55
+ else if (r.pip) segments.push(`pip: \`pipx install ${r.pip}\` _(or \`pip install --user ${r.pip}\`)_`);
56
+ if (r.npm) segments.push(`npm: \`npm install -g ${r.npm}\``);
57
+ if (segments.length === 0 && r.manual) return `- \`${cli}\` — ${r.manual}`;
58
+ if (segments.length === 0 && r.script) return `- \`${cli}\` — run: \`${r.script}\``;
59
+ if (segments.length === 0) return `- \`${cli}\` — install via your package manager`;
60
+ const note = r.needs ? ` _Note: ${r.needs}_` : "";
61
+ return `- **${cli}** — ${segments.join(" · ")}${note}`;
62
+ }
63
+
64
+ export type Severity = "error" | "warning" | "info";
65
+
66
+ export interface Diagnostic {
67
+ rule: string; // e.g. "R001"
68
+ severity: Severity;
69
+ message: string;
70
+ line?: number; // 1-based, optional
71
+ /** Pure transform: given current content, return fixed content. Idempotent. */
72
+ fix?: (content: string) => string;
73
+ }
74
+
75
+ export interface LintResult {
76
+ diagnostics: Diagnostic[];
77
+ fixable: number;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Frontmatter helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ function getFrontmatter(content: string): { yaml: string; start: number; end: number } | null {
85
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
86
+ if (!match) return null;
87
+ return { yaml: match[1]!, start: 0, end: match[0].length };
88
+ }
89
+
90
+ function fmField(yaml: string, key: string): string {
91
+ const m = yaml.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"));
92
+ return m ? m[1]!.trim() : "";
93
+ }
94
+
95
+ function bodyAfterFrontmatter(content: string): string {
96
+ const fm = getFrontmatter(content);
97
+ return fm ? content.slice(fm.end).replace(/^\n/, "") : content;
98
+ }
99
+
100
+ /** Insert a new field at the bottom of the frontmatter (just before the closing ---). */
101
+ function insertFrontmatterField(content: string, key: string, value: string): string {
102
+ const fm = getFrontmatter(content);
103
+ if (!fm) {
104
+ // No frontmatter at all — create one
105
+ return `---\n${key}: ${value}\n---\n\n${content}`;
106
+ }
107
+ const newYaml = fm.yaml + `\n${key}: ${value}`;
108
+ return `---\n${newYaml}\n---` + content.slice(fm.end);
109
+ }
110
+
111
+ /** Slugify a string → kebab-case for derived `name:` values. */
112
+ function slugify(s: string): string {
113
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 64);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Rules
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * R001 — frontmatter must declare `name:` (used by Claude's skill discovery
122
+ * for the canonical id). Auto-fix: derive from the first `# Heading` in the
123
+ * body, slugified.
124
+ */
125
+ function ruleR001(content: string): Diagnostic[] {
126
+ const fm = getFrontmatter(content);
127
+ if (fm && fmField(fm.yaml, "name")) return [];
128
+ const body = bodyAfterFrontmatter(content);
129
+ const heading = body.match(/^#\s+(.+)$/m);
130
+ const derived = heading ? slugify(heading[1]!) : "";
131
+ return [{
132
+ rule: "R001",
133
+ severity: "error",
134
+ message: "Frontmatter missing `name:` field — required for skill discovery.",
135
+ fix: derived ? (c) => insertFrontmatterField(c, "name", derived) : undefined,
136
+ }];
137
+ }
138
+
139
+ /**
140
+ * R002 — frontmatter must declare `description:` (the trigger sentence Claude
141
+ * matches against user requests). No auto-fix: the description needs human
142
+ * judgment about *when* the skill should fire.
143
+ */
144
+ function ruleR002(content: string): Diagnostic[] {
145
+ const fm = getFrontmatter(content);
146
+ if (fm && fmField(fm.yaml, "description")) return [];
147
+ return [{
148
+ rule: "R002",
149
+ severity: "error",
150
+ message: "Frontmatter missing `description:` — required so Claude knows when to invoke the skill.",
151
+ }];
152
+ }
153
+
154
+ /**
155
+ * R003 — description ≤ 200 chars. Anthropic's discovery truncates beyond
156
+ * that and you lose the trigger semantics. No auto-fix (needs rewriting).
157
+ */
158
+ function ruleR003(content: string): Diagnostic[] {
159
+ // Read directly from frontmatter, not parseMetadataFromContent (which clips).
160
+ const fm = getFrontmatter(content);
161
+ if (!fm) return [];
162
+ const raw = fmField(fm.yaml, "description");
163
+ if (!raw || raw.length <= 200) return [];
164
+ return [{
165
+ rule: "R003",
166
+ severity: "warning",
167
+ message: `Description is ${raw.length} chars (>200); Claude's discovery may truncate it.`,
168
+ }];
169
+ }
170
+
171
+ /**
172
+ * R004 — description must contain a trigger phrase. The strongest signals
173
+ * for Claude's discovery are second-person verbs ("Use when …", "Triggers …",
174
+ * "When the user …"). Descriptions that are pure noun phrases ("A Python
175
+ * library for X") fire much less reliably.
176
+ */
177
+ function ruleR004(content: string): Diagnostic[] {
178
+ const meta = parseMetadataFromContent(content);
179
+ if (!meta.description) return [];
180
+ const lower = meta.description.toLowerCase();
181
+ const triggers = ["use when", "triggers", "when the user", "when you ", "when asked", "to be used", "used to", "used when"];
182
+ if (triggers.some((t) => lower.includes(t))) return [];
183
+ return [{
184
+ rule: "R004",
185
+ severity: "warning",
186
+ message: 'Description has no trigger phrase (e.g. "Use when ...", "When the user ..."). Without one, Claude may not discover this skill reliably.',
187
+ }];
188
+ }
189
+
190
+ /**
191
+ * R005 — `allowed-tools:` must use Anthropic's `Bash(name:*)` / `Read(path)`
192
+ * syntax. Common mistake: comma-separated bare names like `allowed-tools: nmap, curl`.
193
+ */
194
+ function ruleR005(content: string): Diagnostic[] {
195
+ const fm = getFrontmatter(content);
196
+ if (!fm) return [];
197
+ const raw = fmField(fm.yaml, "allowed-tools");
198
+ if (!raw) return [];
199
+ // Strip array brackets/braces if present.
200
+ const value = raw.replace(/^\[|\]$/g, "").trim();
201
+ // Valid form has at least one Tool(...) wrapper.
202
+ if (/\b(Bash|Read|Write|Edit|Glob|Grep|WebFetch|WebSearch)\s*\(/.test(value)) return [];
203
+
204
+ // Common malformation: comma-separated bare names. Auto-fix by wrapping.
205
+ const bareNames = value.split(/[,\s]+/).filter(Boolean);
206
+ if (bareNames.length === 0) return [];
207
+ const fixed = bareNames.map((n) => `Bash(${n}:*)`).join(", ");
208
+ return [{
209
+ rule: "R005",
210
+ severity: "error",
211
+ message: `\`allowed-tools:\` must use \`Bash(name:*)\` / \`Read(path)\` syntax; got bare names "${value}".`,
212
+ fix: (c) => {
213
+ const fmm = getFrontmatter(c);
214
+ if (!fmm) return c;
215
+ const newYaml = fmm.yaml.replace(/^allowed-tools:.*$/m, `allowed-tools: ${fixed}`);
216
+ return `---\n${newYaml}\n---` + c.slice(fmm.end);
217
+ },
218
+ }];
219
+ }
220
+
221
+ /**
222
+ * R006 — skill declares CLI dependencies but has no `## Prerequisites`
223
+ * section listing them. Auto-fix: synthesize one from the extracted CLI set.
224
+ * This is the single highest-value PR cue can open on a skill repo.
225
+ */
226
+ function ruleR006(content: string): Diagnostic[] {
227
+ const clis = parseCLIsFromContent(content);
228
+ if (clis.length === 0) return [];
229
+ if (/^##\s+Prerequisites\b/m.test(content)) return [];
230
+
231
+ const fix = (c: string): string => {
232
+ const fm = getFrontmatter(c);
233
+ const body = fm ? c.slice(fm.end) : c;
234
+ const block = `\n\n## Prerequisites\n\n` +
235
+ clis.map(renderInstallLine).join("\n") + "\n";
236
+ // Insert after the first heading + any intro paragraph, OR at end of body.
237
+ const firstH = body.search(/^#\s+.+$/m);
238
+ if (firstH === -1) return c + block;
239
+ // Find next blank line after the heading
240
+ const after = body.indexOf("\n\n", firstH);
241
+ if (after === -1) return c + block;
242
+ return (fm ? c.slice(0, fm.end) : "") + body.slice(0, after) + block + body.slice(after);
243
+ };
244
+
245
+ return [{
246
+ rule: "R006",
247
+ severity: "warning",
248
+ message: `Skill uses ${clis.length} CLI tool(s) (${clis.slice(0, 5).join(", ")}${clis.length > 5 ? "…" : ""}) but has no \`## Prerequisites\` section. Users won't know what to install.`,
249
+ fix,
250
+ }];
251
+ }
252
+
253
+ /**
254
+ * R007 — frontmatter has no `tags:` / `domain:` / `category:`. These are what
255
+ * marketplaces and search index against; missing them hurts discoverability.
256
+ * No auto-fix (judgment required), but the message lists the inferred tags
257
+ * for the maintainer to copy in.
258
+ */
259
+ function ruleR007(content: string): Diagnostic[] {
260
+ const fm = getFrontmatter(content);
261
+ if (!fm) return [];
262
+ const hasAny = ["tags", "domain", "category"].some((k) => fmField(fm.yaml, k));
263
+ if (hasAny) return [];
264
+
265
+ // Suggest tags from the body — frequent capitalized nouns / known CLIs.
266
+ const clis = parseCLIsFromContent(content);
267
+ const suggestions = clis.slice(0, 4);
268
+ const hint = suggestions.length > 0 ? ` Suggested tags from your CLI usage: [${suggestions.join(", ")}].` : "";
269
+ return [{
270
+ rule: "R007",
271
+ severity: "info",
272
+ message: `Frontmatter has no \`tags:\`, \`domain:\`, or \`category:\` — hurts discoverability.${hint}`,
273
+ }];
274
+ }
275
+
276
+ /**
277
+ * R008 — markdown links pointing nowhere within the document. Detects
278
+ * `[text](#anchor)` where `#anchor` doesn't correspond to any heading.
279
+ * Pure (no network), so safe in CI. URL links are out of scope.
280
+ */
281
+ function ruleR008(content: string): Diagnostic[] {
282
+ const headings = new Set<string>();
283
+ for (const m of content.matchAll(/^#+\s+(.+)$/gm)) {
284
+ headings.add(slugify(m[1]!));
285
+ }
286
+ const broken: string[] = [];
287
+ for (const m of content.matchAll(/\[([^\]]+)\]\(#([^)]+)\)/g)) {
288
+ if (!headings.has(m[2]!.toLowerCase())) broken.push(m[2]!);
289
+ }
290
+ if (broken.length === 0) return [];
291
+ return [{
292
+ rule: "R008",
293
+ severity: "warning",
294
+ message: `Broken in-document anchor link(s): ${broken.slice(0, 5).join(", ")}${broken.length > 5 ? "…" : ""}`,
295
+ }];
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Public API
300
+ // ---------------------------------------------------------------------------
301
+
302
+ const ALL_RULES = [ruleR001, ruleR002, ruleR003, ruleR004, ruleR005, ruleR006, ruleR007, ruleR008];
303
+
304
+ /** Run every rule against the SKILL.md content. */
305
+ export function lint(content: string): LintResult {
306
+ const diagnostics: Diagnostic[] = [];
307
+ for (const rule of ALL_RULES) {
308
+ diagnostics.push(...rule(content));
309
+ }
310
+ return { diagnostics, fixable: diagnostics.filter((d) => d.fix).length };
311
+ }
312
+
313
+ /** Apply every fixable diagnostic. Idempotent if rules are well-behaved. */
314
+ export function applyFixes(content: string): { fixed: string; applied: string[] } {
315
+ let current = content;
316
+ const applied: string[] = [];
317
+ // Re-lint after each fix so rules see the updated content.
318
+ // Cap iterations to avoid infinite loops if a fix re-triggers another rule.
319
+ for (let i = 0; i < 5; i++) {
320
+ const { diagnostics } = lint(current);
321
+ const next = diagnostics.find((d) => d.fix);
322
+ if (!next) break;
323
+ current = next.fix!(current);
324
+ applied.push(next.rule);
325
+ }
326
+ return { fixed: current, applied };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // PR body generator — meaningful pull request body for the auto-PR flow.
331
+ // Caller is responsible for repo forking, branching, pushing, and `gh pr create`.
332
+ // ---------------------------------------------------------------------------
333
+
334
+ export interface PrFile {
335
+ path: string;
336
+ before: string;
337
+ after: string;
338
+ fixedRules: string[]; // rule ids that touched this file
339
+ }
340
+
341
+ export interface PrBodyInput {
342
+ repo: string; // owner/name
343
+ files: PrFile[]; // every file the PR touches
344
+ diagnosticsFixed: Diagnostic[]; // aggregated across files (deduped by rule)
345
+ diagnosticsLeft: Diagnostic[]; // unfixable ones the maintainer can act on
346
+ }
347
+
348
+ const RULE_SUMMARIES: Record<string, string> = {
349
+ R001: "Added missing `name:` field (derived from first H1)",
350
+ R002: "Flagged missing `description:` for human review",
351
+ R003: "Description exceeds 200 chars — Claude's discovery truncates it",
352
+ R004: "Description lacks a trigger phrase (e.g. \"Use when …\")",
353
+ R005: "Fixed `allowed-tools:` syntax to use `Bash(name:*)` form",
354
+ R006: "Added `## Prerequisites` section listing CLI dependencies",
355
+ R007: "Flagged missing `tags:` / `domain:` (hurts discoverability)",
356
+ R008: "Flagged broken in-document anchor link(s)",
357
+ };
358
+
359
+ const RULE_TITLE_PHRASES: Record<string, string> = {
360
+ R001: "add missing `name:`",
361
+ R002: "flag missing `description:`",
362
+ R003: "shorten over-long description",
363
+ R004: "rewrite description with trigger phrase",
364
+ R005: "fix `allowed-tools` syntax",
365
+ R006: "add `Prerequisites` section",
366
+ R007: "flag missing `tags:`/`domain:`",
367
+ R008: "flag broken anchor links",
368
+ };
369
+
370
+ /**
371
+ * Compose a meaningful PR title from the list of rules actually fixed.
372
+ * Examples:
373
+ * 1 rule → "cue: fix allowed-tools syntax"
374
+ * 2 rules → "cue: fix allowed-tools syntax + add Prerequisites"
375
+ * 3 rules → "cue: fix allowed-tools syntax, add Prerequisites, +1 more"
376
+ * 0 rules → "cue: SKILL.md spec issues need review (R002, R007)"
377
+ */
378
+ export function buildPrTitle(fixedRules: string[], flaggedRules: string[]): string {
379
+ const dedup = [...new Set(fixedRules)];
380
+ if (dedup.length === 0) {
381
+ const flags = [...new Set(flaggedRules)].slice(0, 3);
382
+ return `cue: SKILL.md spec issues need review (${flags.join(", ")})`;
383
+ }
384
+ const phrases = dedup.map((r) => RULE_TITLE_PHRASES[r] ?? r).filter(Boolean);
385
+ if (phrases.length === 1) return `cue: ${phrases[0]}`;
386
+ if (phrases.length === 2) return `cue: ${phrases[0]} + ${phrases[1]}`;
387
+ return `cue: ${phrases.slice(0, 2).join(", ")}, +${phrases.length - 2} more`;
388
+ }
389
+
390
+ /**
391
+ * Render a unified-diff-style block for a single file. Not a true Myers diff
392
+ * — just lines that differ between before and after. Adequate for the small
393
+ * frontmatter/Prerequisites edits cue typically makes.
394
+ */
395
+ function renderInlineDiff(path: string, before: string, after: string): string {
396
+ const beforeLines = before.split("\n");
397
+ const afterLines = after.split("\n");
398
+
399
+ // Simple LCS-free diff: find the first and last differing lines, emit a hunk.
400
+ let firstDiff = 0;
401
+ while (firstDiff < beforeLines.length && firstDiff < afterLines.length && beforeLines[firstDiff] === afterLines[firstDiff]) firstDiff++;
402
+
403
+ let lastDiffBefore = beforeLines.length - 1;
404
+ let lastDiffAfter = afterLines.length - 1;
405
+ while (
406
+ lastDiffBefore > firstDiff && lastDiffAfter > firstDiff &&
407
+ beforeLines[lastDiffBefore] === afterLines[lastDiffAfter]
408
+ ) { lastDiffBefore--; lastDiffAfter--; }
409
+
410
+ // Show a small context window before/after
411
+ const ctx = 2;
412
+ const ctxStart = Math.max(0, firstDiff - ctx);
413
+ const ctxEndBefore = Math.min(beforeLines.length, lastDiffBefore + 1 + ctx);
414
+ const ctxEndAfter = Math.min(afterLines.length, lastDiffAfter + 1 + ctx);
415
+
416
+ const lines: string[] = [];
417
+ for (let i = ctxStart; i < firstDiff; i++) lines.push(" " + beforeLines[i]);
418
+ for (let i = firstDiff; i <= lastDiffBefore; i++) lines.push("- " + beforeLines[i]);
419
+ for (let i = firstDiff; i <= lastDiffAfter; i++) lines.push("+ " + afterLines[i]);
420
+ // Trailing context comes from the after version since lines may have shifted.
421
+ for (let i = lastDiffAfter + 1; i < ctxEndAfter; i++) lines.push(" " + afterLines[i]);
422
+
423
+ return `### \`${path}\`\n\n\`\`\`diff\n${lines.join("\n")}\n\`\`\``;
424
+ }
425
+
426
+ export function buildPrBody(input: PrBodyInput): { title: string; body: string } {
427
+ const fixedRuleIds = [...new Set(input.diagnosticsFixed.map((d) => d.rule))];
428
+ const flaggedRuleIds = [...new Set(input.diagnosticsLeft.map((d) => d.rule))];
429
+
430
+ const fixedList = input.diagnosticsFixed.length > 0
431
+ ? input.diagnosticsFixed.map((d) => `- **${d.rule}** — ${RULE_SUMMARIES[d.rule] ?? d.message}`).join("\n")
432
+ : "_(none — only flags, no automatic fixes)_";
433
+
434
+ const leftList = input.diagnosticsLeft.length > 0
435
+ ? input.diagnosticsLeft.map((d) => `- **${d.rule}** _(${d.severity})_ — ${d.message}`).join("\n")
436
+ : "_(none — the file is clean after this PR)_";
437
+
438
+ const title = buildPrTitle(fixedRuleIds, flaggedRuleIds);
439
+
440
+ // Per-file diff blocks (only for files that actually changed)
441
+ const diffBlocks = input.files
442
+ .filter((f) => f.before !== f.after)
443
+ .map((f) => renderInlineDiff(f.path, f.before, f.after))
444
+ .join("\n\n");
445
+
446
+ const skillPathDesc = input.files.length === 1
447
+ ? input.files[0]!.path
448
+ : `${input.files.filter((f) => f.before !== f.after).length} of ${input.files.length} SKILL.md files`;
449
+
450
+ const body = `# SKILL.md quality fixes from \`cue\`
451
+
452
+ Hi! [\`cue\`](https://github.com/opencue/cue) is an open-source agent profile manager that auto-discovers Claude Code skills via GitHub Code Search. We indexed **${skillPathDesc}** in [${input.repo}](https://github.com/${input.repo}) and ran our SKILL.md linter against it.
453
+
454
+ This PR applies the **safe, mechanical fixes** below. It does **not** add any branding, badges, or marketing — only spec-compliance changes that improve how Claude's skill discovery sees your skill.
455
+
456
+ ## What this PR changes
457
+
458
+ ${fixedList}
459
+
460
+ ${diffBlocks ? `## Inline diff\n\n${diffBlocks}\n` : ""}
461
+ ## What's flagged for your review (no diff)
462
+
463
+ These are issues we won't auto-fix because they need your judgment:
464
+
465
+ ${leftList}
466
+
467
+ ## Why each rule exists
468
+
469
+ | Rule | Source |
470
+ |---|---|
471
+ | R001 \`name:\` | Required for Claude Code's skill registry |
472
+ | R002 \`description:\` | Used as the trigger string by Claude's discovery |
473
+ | R003 desc length | Anthropic's discovery truncates >200 chars |
474
+ | R004 trigger phrase | Verb-leading descriptions fire ~3× more reliably |
475
+ | R005 \`allowed-tools\` syntax | Malformed tool declarations get silently ignored |
476
+ | R006 Prerequisites | Users don't know which CLIs to install otherwise |
477
+ | R007 tags/domain | Required for skill marketplace indexing |
478
+
479
+ ## How to opt out
480
+
481
+ If you'd rather we don't open PRs like this on your repo, add a line to your README:
482
+
483
+ \`\`\`
484
+ <!-- cue: ignore -->
485
+ \`\`\`
486
+
487
+ We'll skip your repo on every future scan. **No follow-up PRs without you re-inviting us.**
488
+
489
+ You can also run the linter yourself by adding our GitHub Action (no PRs needed):
490
+
491
+ \`\`\`yaml
492
+ # .github/workflows/lint-skill-md.yml
493
+ on: [pull_request]
494
+ jobs:
495
+ lint:
496
+ runs-on: ubuntu-latest
497
+ steps:
498
+ - uses: opencue/cue/skill-md-lint-action@main
499
+ \`\`\`
500
+
501
+ ---
502
+
503
+ 🤖 Generated by \`cue\` · [report a bad fix](https://github.com/opencue/cue/issues/new?title=cue+lint+bad+fix:+${encodeURIComponent(input.repo)})
504
+ `;
505
+
506
+ return { title, body };
507
+ }