cue-ai 0.5.0 → 0.7.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 (154) hide show
  1. package/README.md +757 -110
  2. package/package.json +5 -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/backend/profile.yaml +1 -0
  8. package/profiles/career/profile.yaml +13 -0
  9. package/profiles/core/profile.yaml +76 -9
  10. package/profiles/creative-media/README.md +1 -1
  11. package/profiles/cybersecurity/profile.yaml +779 -756
  12. package/profiles/ecc/profile.yaml +39 -0
  13. package/profiles/event-design/profile.yaml +10 -0
  14. package/profiles/fleet-control/README.md +1 -1
  15. package/profiles/frontend/profile.yaml +14 -0
  16. package/profiles/full/README.md +1 -1
  17. package/profiles/go-api/profile.yaml +1 -0
  18. package/profiles/marketing/profile.yaml +12 -1
  19. package/profiles/predict-everything/profile.yaml +9 -0
  20. package/profiles/rust/profile.yaml +22 -3
  21. package/profiles/rust-cli/profile.yaml +14 -0
  22. package/profiles/rust-core/profile.yaml +35 -0
  23. package/profiles/rust-embedded/profile.yaml +11 -0
  24. package/profiles/rust-ffi/profile.yaml +13 -0
  25. package/profiles/rust-game/profile.yaml +11 -0
  26. package/profiles/rust-wasm/profile.yaml +11 -0
  27. package/profiles/rust-web/profile.yaml +17 -0
  28. package/profiles/schema.json +44 -4
  29. package/profiles/trendradar/profile.yaml +11 -0
  30. package/resources/mcps/README.md +39 -164
  31. package/resources/mcps/configs/claude.sanitized.json +55 -0
  32. package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
  33. package/resources/skills/README.md +70 -113
  34. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
  35. package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
  36. package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
  37. package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
  38. package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
  39. package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
  40. package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
  41. package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
  42. package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
  43. package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
  44. package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
  45. package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
  46. package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
  47. package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
  48. package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
  49. package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
  50. package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
  51. package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
  52. package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
  53. package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
  54. package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
  55. package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
  56. package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
  57. package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
  58. package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
  59. package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
  60. package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
  61. package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
  62. package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
  63. package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
  64. package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
  65. package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
  66. package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
  67. package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
  68. package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
  69. package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
  70. package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
  71. package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
  72. package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
  73. package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
  74. package/resources/skills/skills/rust/serde/SKILL.md +30 -0
  75. package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
  76. package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
  77. package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
  78. package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
  79. package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
  80. package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
  81. package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
  82. package/src/commands/_index.ts +47 -3
  83. package/src/commands/cli.test.ts +192 -0
  84. package/src/commands/cli.ts +303 -0
  85. package/src/commands/current.ts +1 -1
  86. package/src/commands/debug.test.ts +62 -0
  87. package/src/commands/debug.ts +212 -0
  88. package/src/commands/discover.scoring.test.ts +216 -0
  89. package/src/commands/discover.test.ts +145 -0
  90. package/src/commands/discover.ts +2618 -0
  91. package/src/commands/eval-behavior.test.ts +56 -0
  92. package/src/commands/eval-behavior.ts +189 -0
  93. package/src/commands/eval.test.ts +102 -0
  94. package/src/commands/eval.ts +348 -0
  95. package/src/commands/evolve.ts +291 -0
  96. package/src/commands/failures.test.ts +78 -0
  97. package/src/commands/failures.ts +393 -0
  98. package/src/commands/feedback.ts +219 -0
  99. package/src/commands/init.ts +26 -0
  100. package/src/commands/launch.e2e.test.ts +9 -1
  101. package/src/commands/launch.ts +174 -11
  102. package/src/commands/lint-skill.ts +157 -0
  103. package/src/commands/marketplace.ts +763 -2
  104. package/src/commands/new.ts +1 -1
  105. package/src/commands/optimizer.ts +92 -28
  106. package/src/commands/profile-draft-skill.test.ts +96 -0
  107. package/src/commands/profile-draft-skill.ts +287 -0
  108. package/src/commands/profile-evolve.test.ts +126 -0
  109. package/src/commands/profile-evolve.ts +0 -0
  110. package/src/commands/profile-suggest.ts +223 -0
  111. package/src/commands/profile.ts +41 -0
  112. package/src/commands/quick.ts +2 -17
  113. package/src/commands/scan.ts +2 -2
  114. package/src/commands/score.ts +1 -1
  115. package/src/commands/share.ts +1 -1
  116. package/src/commands/sources.ts +2 -2
  117. package/src/commands/submit-profile.ts +262 -0
  118. package/src/commands/upgrade.ts +1 -1
  119. package/src/commands/use.ts +35 -5
  120. package/src/commands/validate.ts +1 -1
  121. package/src/index.ts +66 -0
  122. package/src/lib/analytics.ts +48 -2
  123. package/src/lib/claude-binary.ts +39 -0
  124. package/src/lib/cli-extractor.ts +77 -0
  125. package/src/lib/cluster-skills.test.ts +268 -0
  126. package/src/lib/cluster-skills.ts +290 -0
  127. package/src/lib/credentials-sync.test.ts +208 -0
  128. package/src/lib/credentials-sync.ts +205 -0
  129. package/src/lib/mcp-materializer.test.ts +1 -1
  130. package/src/lib/persona-playbooks.test.ts +111 -0
  131. package/src/lib/pr-poster.test.ts +243 -0
  132. package/src/lib/pr-poster.ts +285 -0
  133. package/src/lib/pr-throttle.test.ts +148 -0
  134. package/src/lib/pr-throttle.ts +209 -0
  135. package/src/lib/profile-generator.test.ts +1 -1
  136. package/src/lib/profile-generator.ts +2 -2
  137. package/src/lib/profile-linter.test.ts +6 -3
  138. package/src/lib/profile-linter.ts +71 -8
  139. package/src/lib/profile-loader.test.ts +1 -1
  140. package/src/lib/profile-loader.ts +16 -0
  141. package/src/lib/resolver-local.test.ts +1 -1
  142. package/src/lib/resolver-npx.test.ts +76 -1
  143. package/src/lib/resolver-npx.ts +35 -3
  144. package/src/lib/resolver-plugins.test.ts +1 -1
  145. package/src/lib/runtime-materializer.test.ts +191 -7
  146. package/src/lib/runtime-materializer.ts +310 -42
  147. package/src/lib/scan-plugins.test.ts +1 -1
  148. package/src/lib/skill-linter.test.ts +174 -0
  149. package/src/lib/skill-linter.ts +507 -0
  150. package/src/lib/skill-subset.test.ts +95 -0
  151. package/src/lib/skill-subset.ts +166 -0
  152. package/src/lib/star-prompt.ts +1 -1
  153. package/src/lib/uvx-installer.test.ts +229 -0
  154. package/src/lib/uvx-installer.ts +278 -0
@@ -181,7 +181,7 @@ async function createFromSeed(args: NewArgs): Promise<number> {
181
181
  }
182
182
 
183
183
  async function createEmpty(args: NewArgs): Promise<number> {
184
- const description = `Custom soul profile ${args.name}`;
184
+ const description = `Custom cue profile ${args.name}`;
185
185
  const generated = {
186
186
  profile: { name: args.name!, description, agents: ["claude-code", "codex"] },
187
187
  yaml: [
@@ -59,7 +59,17 @@ const KNOWN_CLIS = new Set([
59
59
  "aws", "gcloud", "az", "curl", "wget", "jq", "yq",
60
60
  "git", "gh", "glab", "npm", "npx", "bun", "pnpm", "yarn",
61
61
  "python", "pip", "uv", "uvx", "node", "deno",
62
- "go", "cargo", "rustc", "gcc", "make", "cmake",
62
+ "go", "cargo", "rustc", "rustup", "clippy", "rustfmt", "gcc", "make", "cmake",
63
+ "cargo-watch", "cargo-nextest", "cargo-edit", "cargo-expand", "cargo-machete",
64
+ "cargo-outdated", "cargo-udeps", "cargo-audit", "cargo-deny", "cargo-geiger",
65
+ "cargo-vet", "cargo-crev", "cargo-flamegraph", "cargo-criterion", "cargo-bloat",
66
+ "bacon", "sccache", "wasm-pack", "trunk", "dioxus", "tauri",
67
+ "sqlx", "sea-orm-cli", "diesel", "mdbook", "cross", "just",
68
+ "tokio-console", "cargo-insta", "cargo-fuzz", "cargo-hack", "cargo-mutants",
69
+ "release-plz", "typos", "cargo-chef", "cargo-msrv", "cargo-readme",
70
+ "maturin", "napi", "uniffi-bindgen", "bindgen", "cbindgen",
71
+ "probe-rs", "cargo-embed", "cargo-binutils", "chisel",
72
+ "chromium", "chrome", "google-chrome", "microsoft-edge",
63
73
  "openssl", "ssh", "ncat", "netcat", "socat",
64
74
  "splunk", "elastic", "kibana", "logstash",
65
75
  "peepdf", "pdfid", "pdf-parser", "olevba", "oletools",
@@ -80,59 +90,92 @@ interface ProfileReport {
80
90
  clis: Map<string, string[]>; // cli → [skills that use it]
81
91
  }
82
92
 
83
- function extractCLIsFromSkill(skillSlug: string): string[] {
84
- const clis: Set<string> = new Set();
93
+ /**
94
+ * Pure parser: extract metadata (domain, tags, one-line description) from a
95
+ * SKILL.md's frontmatter. Used by marketplace discover to suggest which cue
96
+ * profile a repo would fit and what it's good for.
97
+ */
98
+ export interface SkillMetadata {
99
+ description: string; // one-line summary
100
+ domain: string; // top-level category (e.g. "cybersecurity", "marketing")
101
+ tags: string[]; // free-form tag list
102
+ category: string; // legacy alias for domain
103
+ name: string;
104
+ }
85
105
 
86
- // Try to read SKILL.md from resources or ~/.claude/skills
87
- let content = "";
88
- const paths = [
89
- join(HOME_SKILLS, skillSlug, "SKILL.md"),
90
- // Try all category subdirs in resources
91
- ...(() => {
92
- try {
93
- return readdirSync(SKILLS_ROOT)
94
- .map((cat) => join(SKILLS_ROOT, cat, skillSlug, "SKILL.md"));
95
- } catch { return []; }
96
- })(),
97
- ];
106
+ export function parseMetadataFromContent(content: string): SkillMetadata {
107
+ const empty: SkillMetadata = { description: "", domain: "", tags: [], category: "", name: "" };
108
+ if (!content) return empty;
109
+ const fm = content.match(/^---\n([\s\S]*?)\n---/);
110
+ if (!fm) return empty;
111
+ const yaml = fm[1]!;
98
112
 
99
- for (const p of paths) {
100
- try { content = readFileSync(p, "utf8"); break; } catch {}
113
+ const get = (key: string): string => {
114
+ const m = yaml.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"));
115
+ return m ? m[1]!.replace(/^["'>|]\s*/, "").replace(/["']$/, "").trim() : "";
116
+ };
117
+
118
+ // Tags can be either inline `tags: [a, b]` or YAML list `tags:\n - a\n - b`.
119
+ let tags: string[] = [];
120
+ const inline = yaml.match(/^tags:\s*\[([^\]]*)\]/m);
121
+ if (inline) {
122
+ tags = inline[1]!.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
123
+ } else {
124
+ const block = yaml.match(/^tags:\s*\n((?:\s+-\s+.+\n?)+)/m);
125
+ if (block) {
126
+ tags = block[1]!.split("\n").map((l) => l.replace(/^\s*-\s*/, "").trim().replace(/^["']|["']$/g, "")).filter(Boolean);
127
+ }
101
128
  }
102
129
 
103
- if (!content) {
104
- return [...clis];
130
+ // Multi-line description: handle `description: >-\n text\n more text` and `description: |`.
131
+ let description = get("description");
132
+ if (!description) {
133
+ const multi = yaml.match(/^description:\s*[>|]-?\s*\n((?:\s+.+\n?)+)/m);
134
+ if (multi) description = multi[1]!.split("\n").map((l) => l.trim()).filter(Boolean).join(" ");
105
135
  }
106
136
 
137
+ return {
138
+ description: description.slice(0, 200),
139
+ domain: get("domain") || get("category"),
140
+ category: get("category") || get("domain"),
141
+ tags,
142
+ name: get("name"),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Pure parser: extract CLI names from a SKILL.md's raw text. Used by both
148
+ * disk-loading callers and network-fetched content (marketplace discover).
149
+ */
150
+ export function parseCLIsFromContent(content: string): string[] {
151
+ const clis: Set<string> = new Set();
152
+ if (!content) return [];
153
+
107
154
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
108
155
  if (fmMatch) {
109
156
  const fm = fmMatch[1]!;
110
-
111
- // Extract from allowed-tools (actual runtime CLI dependencies)
112
157
  const toolsMatch = fm.match(/^allowed-tools:\s*(.+)$/m);
113
158
  if (toolsMatch) {
114
159
  const tools = toolsMatch[1]!;
115
- // Parse Bash(aws:*), Bash(curl:*), Bash(npx medusa ...) etc.
116
160
  const bashTools = tools.match(/Bash\(([^:)]+)/g);
117
161
  if (bashTools) {
118
162
  for (const bt of bashTools) {
119
- const cli = bt.replace("Bash(", "").trim().split(" ")[0]!.toLowerCase();
120
- if (cli && cli !== "bash") clis.add(cli);
163
+ // Preserve case Linux binary names are case-sensitive (e.g. `Xvfb`,
164
+ // not `xvfb`). Lowercasing here used to surface false-negatives.
165
+ const cli = bt.replace("Bash(", "").trim().split(" ")[0]!;
166
+ if (cli && cli.toLowerCase() !== "bash") clis.add(cli);
121
167
  }
122
168
  }
123
169
  }
124
170
  }
125
171
 
126
- // Extract from Prerequisites section (lists what needs to be installed)
127
172
  const prereqMatch = content.match(/## Prerequisites\n([\s\S]*?)(?=\n##|\n$)/);
128
173
  if (prereqMatch) {
129
174
  const prereqs = prereqMatch[1]!;
130
- // Match lines like "- Nmap 7.90+ installed" or "- apktool (for ...)"
131
175
  const lines = prereqs.split("\n").filter((l) => l.startsWith("-") || l.startsWith("*"));
132
176
  for (const line of lines) {
133
177
  const lower = line.toLowerCase();
134
178
  for (const cli of KNOWN_CLIS) {
135
- // Only match if CLI appears as a word boundary (not substring of another word)
136
179
  const re = new RegExp(`\\b${cli}\\b`);
137
180
  if (re.test(lower)) clis.add(cli);
138
181
  }
@@ -142,6 +185,27 @@ function extractCLIsFromSkill(skillSlug: string): string[] {
142
185
  return [...clis];
143
186
  }
144
187
 
188
+ export function extractCLIsFromSkill(skillSlug: string): string[] {
189
+ // Try to read SKILL.md from resources or ~/.claude/skills
190
+ let content = "";
191
+ const paths = [
192
+ join(HOME_SKILLS, skillSlug, "SKILL.md"),
193
+ // Try all category subdirs in resources
194
+ ...(() => {
195
+ try {
196
+ return readdirSync(SKILLS_ROOT)
197
+ .map((cat) => join(SKILLS_ROOT, cat, skillSlug, "SKILL.md"));
198
+ } catch { return []; }
199
+ })(),
200
+ ];
201
+
202
+ for (const p of paths) {
203
+ try { content = readFileSync(p, "utf8"); break; } catch {}
204
+ }
205
+
206
+ return parseCLIsFromContent(content);
207
+ }
208
+
145
209
  function parseProfile(name: string): ProfileReport {
146
210
  const yamlPath = join(PROFILES_DIR, name, "profile.yaml");
147
211
  let icon = " ";
@@ -543,7 +607,7 @@ Examples:
543
607
  if (repoSkillMap.size > 0 || localCount > 0) {
544
608
  process.stdout.write(`│\n│ ┌─ 🐙 Sources\n`);
545
609
  if (localCount > 0) {
546
- process.stdout.write(`│ │ 📁 recodeee/cue (${localCount} skills)\n`);
610
+ process.stdout.write(`│ │ 📁 opencue/cue (${localCount} skills)\n`);
547
611
  }
548
612
  for (const [repo, count] of [...repoSkillMap.entries()].sort((a, b) => b[1] - a[1])) {
549
613
  let repoIconStr = "";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Tests for `cue profile draft-skill` — covers the help path and the
3
+ * empty-corpus path. The Claude-call path is exercised through the underlying
4
+ * cluster-skills lib and skill-subset (already tested) — no point mocking
5
+ * claude --print here since the fail-open behavior is the contract that
6
+ * matters.
7
+ */
8
+
9
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const TMP = join(tmpdir(), `cue-draft-skill-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
15
+ const ORIG_XDG = process.env.XDG_CONFIG_HOME;
16
+
17
+ beforeAll(() => {
18
+ mkdirSync(join(TMP, "cue"), { recursive: true });
19
+ process.env.XDG_CONFIG_HOME = TMP;
20
+ });
21
+
22
+ afterAll(() => {
23
+ if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
24
+ else process.env.XDG_CONFIG_HOME = ORIG_XDG;
25
+ try { rmSync(TMP, { recursive: true, force: true }); } catch {}
26
+ });
27
+
28
+ function captureStdout<T>(fn: () => Promise<T>): Promise<{ result: T; out: string }> {
29
+ const orig = process.stdout.write.bind(process.stdout);
30
+ let buf = "";
31
+ (process.stdout as any).write = (chunk: string | Uint8Array) => { buf += String(chunk); return true; };
32
+ return fn()
33
+ .then(result => ({ result, out: buf }))
34
+ .finally(() => { (process.stdout as any).write = orig; });
35
+ }
36
+
37
+ describe("cue profile draft-skill", () => {
38
+ test("--help prints usage", async () => {
39
+ const { run } = await import("./profile-draft-skill");
40
+ const { result, out } = await captureStdout(() => run(["--help"]));
41
+ expect(result).toBe(0);
42
+ expect(out).toContain("cue profile draft-skill");
43
+ expect(out).toContain("first-prompts");
44
+ expect(out).toContain("--dry-run");
45
+ });
46
+
47
+ test("with no captured prompts → reports nothing to draft (non-error)", async () => {
48
+ const { run } = await import("./profile-draft-skill");
49
+ const { result, out } = await captureStdout(() => run([]));
50
+ expect(result).toBe(0);
51
+ expect(out).toContain("No first-prompts captured");
52
+ });
53
+
54
+ test("with synthetic captured prompts < minSize → reports insufficient data", async () => {
55
+ const dir = join(TMP, "cue", "first-prompts");
56
+ mkdirSync(dir, { recursive: true });
57
+ const ts = new Date().toISOString();
58
+ writeFileSync(join(dir, "aaa.json"), JSON.stringify({
59
+ ts, cwd: "/x", session_id: "s1", prompt: "fix the auth bug in the OAuth flow",
60
+ }));
61
+ writeFileSync(join(dir, "bbb.json"), JSON.stringify({
62
+ ts, cwd: "/y", session_id: "s2", prompt: "add unit tests for the rust crate",
63
+ }));
64
+ // Only 2 prompts — below the default minSize of 3.
65
+
66
+ const { run } = await import("./profile-draft-skill");
67
+ const { result, out } = await captureStdout(() => run([]));
68
+ expect(result).toBe(0);
69
+ expect(out).toContain("need ≥3");
70
+ });
71
+
72
+ test("with ≥3 similar captured prompts + --no-claude + --dry-run → reports clusters without writing", async () => {
73
+ const dir = join(TMP, "cue", "first-prompts");
74
+ mkdirSync(dir, { recursive: true });
75
+ const ts = new Date().toISOString();
76
+ // Three prompts sharing "deploy" + "coolify" → one cluster
77
+ const prompts = [
78
+ { id: "c", prompt: "deploy this app via coolify to my VPS" },
79
+ { id: "d", prompt: "deploy the new build to coolify production" },
80
+ { id: "e", prompt: "set up coolify deploy with environment variables" },
81
+ ];
82
+ for (const p of prompts) {
83
+ writeFileSync(join(dir, `${p.id}.json`), JSON.stringify({
84
+ ts, cwd: `/x-${p.id}`, session_id: `s-${p.id}`, prompt: p.prompt,
85
+ }));
86
+ }
87
+
88
+ const { run } = await import("./profile-draft-skill");
89
+ const { result, out } = await captureStdout(() => run(["--no-claude", "--dry-run"]));
90
+ expect(result).toBe(0);
91
+ expect(out).toContain("session pattern cluster");
92
+ expect(out).toContain("[dry-run]");
93
+ // The cluster term should be a content word from the shared prompts.
94
+ expect(out.toLowerCase()).toMatch(/coolify|deploy/);
95
+ });
96
+ });
@@ -0,0 +1,287 @@
1
+ /**
2
+ * `cue profile draft-skill [<profile>]` — propose new SKILL.md files based on
3
+ * recurring session patterns.
4
+ *
5
+ * Data sources (all populated by hooks installed in profiles/core):
6
+ * • ~/.config/cue/first-prompts/<cwd-hash>.json first prompt per cwd
7
+ * • ~/.config/cue/analytics.jsonl session start/end/skill_hit
8
+ *
9
+ * Pipeline:
10
+ * 1. For each captured first-prompt, look up the session's profile via
11
+ * analytics (cwd/time-window match).
12
+ * 2. Cluster first-prompts by keyword similarity (reuses cluster-skills lib).
13
+ * 3. For each cluster of ≥3 similar sessions, ask Claude to draft a SKILL.md
14
+ * describing the pattern. (Fail-open: cluster name used if claude is
15
+ * unavailable.)
16
+ * 4. Write drafts under .cue-skill-drafts/<slug>/SKILL.md for review.
17
+ *
18
+ * Closes the learning loop opened by `cue profile evolve`: detect the gap,
19
+ * suggest the skill, let the user adopt it.
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
24
+ import { join, resolve, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { homedir } from "node:os";
27
+
28
+ import { clusterByKeywords, type ClusterItem } from "../lib/cluster-skills";
29
+ import { readEvents, type SessionEvent } from "../lib/analytics";
30
+ import { findRealClaudeBin } from "../lib/claude-binary";
31
+
32
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
33
+ const FIRST_PROMPTS_DIR = join(
34
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
35
+ "cue", "first-prompts",
36
+ );
37
+
38
+ interface CapturedPrompt {
39
+ ts: string;
40
+ cwd: string;
41
+ session_id: string;
42
+ prompt: string;
43
+ }
44
+
45
+ interface SessionPattern {
46
+ prompt: string;
47
+ cwd: string;
48
+ ts: string;
49
+ profile: string | null;
50
+ }
51
+
52
+ function loadCapturedPrompts(): CapturedPrompt[] {
53
+ if (!existsSync(FIRST_PROMPTS_DIR)) return [];
54
+ const out: CapturedPrompt[] = [];
55
+ for (const f of readdirSync(FIRST_PROMPTS_DIR)) {
56
+ if (!f.endsWith(".json")) continue;
57
+ try {
58
+ const doc = JSON.parse(readFileSync(join(FIRST_PROMPTS_DIR, f), "utf8")) as CapturedPrompt;
59
+ if (doc?.prompt && doc.cwd) out.push(doc);
60
+ } catch { /* skip malformed */ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Match each captured prompt to a profile by finding the analytics `start`
67
+ * event with the same cwd whose ts is within 60s of the prompt's ts.
68
+ */
69
+ function attributeProfiles(prompts: CapturedPrompt[], since: Date): SessionPattern[] {
70
+ const events = readEvents(since).filter(e => e.event === "start");
71
+ return prompts.map(p => {
72
+ const promptTs = new Date(p.ts).getTime();
73
+ let bestProfile: string | null = null;
74
+ let bestDelta = Infinity;
75
+ for (const e of events) {
76
+ if (e.cwd !== p.cwd) continue;
77
+ const delta = Math.abs(new Date(e.ts).getTime() - promptTs);
78
+ if (delta < bestDelta && delta < 60 * 60 * 1000) {
79
+ bestDelta = delta;
80
+ bestProfile = e.profile;
81
+ }
82
+ }
83
+ return { prompt: p.prompt, cwd: p.cwd, ts: p.ts, profile: bestProfile };
84
+ });
85
+ }
86
+
87
+ interface DraftedSkill {
88
+ slug: string;
89
+ description: string;
90
+ pattern: string;
91
+ examplePrompts: string[];
92
+ }
93
+
94
+ function draftSkillWithClaude(clusterTerm: string, examples: string[]): DraftedSkill {
95
+ const exemplars = examples.slice(0, 6).map(s => `- ${s.slice(0, 200)}`).join("\n");
96
+ const prompt = `You draft Claude Code SKILL.md files. Given a cluster of recurring user prompts that the agent currently has NO dedicated skill for, propose a new skill.
97
+
98
+ Cluster keyword: "${clusterTerm}"
99
+ Example prompts:
100
+ ${exemplars}
101
+
102
+ Output EXACTLY this format (no other text):
103
+ SLUG: <lowercase-kebab, 1-3 words>
104
+ DESCRIPTION: <one line, under 100 chars, what this skill is for>
105
+ PATTERN: <2-3 short sentences describing the workflow the skill should encode>`;
106
+
107
+ const tryOne = (bin: string) => spawnSync(bin, ["--print", "-p", prompt], {
108
+ encoding: "utf8", timeout: 30_000, env: { ...process.env, CUE_BYPASS: "1" },
109
+ });
110
+ let res = tryOne("claude");
111
+ if (res.status !== 0 || !res.stdout?.trim()) {
112
+ const fallback = findRealClaudeBin();
113
+ if (fallback) res = tryOne(fallback);
114
+ }
115
+
116
+ const fallbackSlug = clusterTerm.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
117
+ if (res.status !== 0 || !res.stdout?.trim()) {
118
+ return {
119
+ slug: fallbackSlug,
120
+ description: `Skill for sessions about "${clusterTerm}" (Claude unavailable — name from cluster term)`,
121
+ pattern: "Pattern detected from recurring user prompts. Fill this in by hand.",
122
+ examplePrompts: examples,
123
+ };
124
+ }
125
+ const out = res.stdout.trim();
126
+ const slugMatch = out.match(/SLUG:\s*([a-z0-9][a-z0-9-]*)/i);
127
+ const descMatch = out.match(/DESCRIPTION:\s*(.+)/i);
128
+ const patternMatch = out.match(/PATTERN:\s*([\s\S]+?)(?:\n[A-Z]+:|$)/i);
129
+ return {
130
+ slug: (slugMatch?.[1] ?? fallbackSlug).toLowerCase(),
131
+ description: (descMatch?.[1] ?? `Pattern around "${clusterTerm}"`).trim().slice(0, 120),
132
+ pattern: (patternMatch?.[1] ?? "").trim().slice(0, 500) || `Pattern: recurring prompts about ${clusterTerm}`,
133
+ examplePrompts: examples,
134
+ };
135
+ }
136
+
137
+ function buildSkillMd(draft: DraftedSkill): string {
138
+ return `---
139
+ name: ${draft.slug}
140
+ description: ${draft.description}
141
+ domain: drafted
142
+ tags: [drafted, from-sessions]
143
+ ---
144
+
145
+ # ${draft.slug}
146
+
147
+ > Drafted by \`cue profile draft-skill\` from ${draft.examplePrompts.length} recurring session prompts.
148
+ > Review, refine, then move to \`resources/skills/skills/<category>/${draft.slug}/SKILL.md\` to adopt.
149
+
150
+ ## When to use
151
+
152
+ ${draft.description}
153
+
154
+ ## Pattern
155
+
156
+ ${draft.pattern}
157
+
158
+ ## Example prompts that triggered this draft
159
+
160
+ ${draft.examplePrompts.slice(0, 8).map(p => `- ${p}`).join("\n")}
161
+
162
+ ## TODO
163
+
164
+ - [ ] Sharpen the description (current one is auto-generated).
165
+ - [ ] Decide the right \`category/slug\` location.
166
+ - [ ] Add concrete steps the agent should follow.
167
+ - [ ] Pick \`allowed-tools\`, \`model\` overrides, etc., if needed.
168
+ - [ ] Delete this TODO section before adopting.
169
+ `;
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Tiny ANSI helpers
174
+ // ---------------------------------------------------------------------------
175
+
176
+ const noColor = !process.stdout.isTTY || !!process.env.NO_COLOR;
177
+ const bold = (s: string) => noColor ? s : `\x1b[1m${s}\x1b[0m`;
178
+ const dim = (s: string) => noColor ? s : `\x1b[2m${s}\x1b[0m`;
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Entry point
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export async function run(args: string[]): Promise<number> {
185
+ if (args.includes("-h") || args.includes("--help")) {
186
+ process.stdout.write(`cue profile draft-skill — propose new SKILL.md files from recurring session prompts
187
+
188
+ Usage:
189
+ cue profile draft-skill All profiles
190
+ cue profile draft-skill <profile> One profile
191
+
192
+ Options:
193
+ --since <days> Only consider prompts in the last N days (default: 90)
194
+ --min-size <n> Cluster size threshold (default: 3)
195
+ --out <dir> Output directory (default: .cue-skill-drafts/)
196
+ --no-claude Skip naming step; use cluster term verbatim
197
+ --dry-run Preview without writing files
198
+
199
+ Data source: ~/.config/cue/first-prompts/ (populated by first-prompt-capture hook)
200
+ ~/.config/cue/analytics.jsonl (populated by cue launch + Stop hooks)
201
+
202
+ Output: draft SKILL.md files under .cue-skill-drafts/<slug>/ for manual review.
203
+ `);
204
+ return 0;
205
+ }
206
+
207
+ const sinceIdx = args.indexOf("--since");
208
+ const sinceDays = sinceIdx >= 0 ? parseInt(args[sinceIdx + 1] ?? "90", 10) : 90;
209
+ const minSizeIdx = args.indexOf("--min-size");
210
+ const minSize = minSizeIdx >= 0 ? parseInt(args[minSizeIdx + 1] ?? "3", 10) : 3;
211
+ const outIdx = args.indexOf("--out");
212
+ const outDir = outIdx >= 0 && args[outIdx + 1] ? args[outIdx + 1]!
213
+ : join(REPO_ROOT, ".cue-skill-drafts");
214
+ const dryRun = args.includes("--dry-run");
215
+ const noClaude = args.includes("--no-claude");
216
+
217
+ const positional = args.filter((a, i) =>
218
+ !a.startsWith("--") && (i === 0 || !["--since", "--min-size", "--out"].includes(args[i - 1] ?? "")),
219
+ );
220
+ const targetProfile = positional[0];
221
+
222
+ const since = new Date(Date.now() - sinceDays * 86_400_000);
223
+ const captured = loadCapturedPrompts();
224
+
225
+ if (captured.length === 0) {
226
+ process.stdout.write(`\n ${dim("No first-prompts captured yet.")}\n`);
227
+ process.stdout.write(` ${dim("Set CUE_SMART_SUBSET=1 in your shell and run a few cue launch sessions.")}\n`);
228
+ process.stdout.write(` ${dim("The first-prompt-capture hook (in profiles/core) will populate ~/.config/cue/first-prompts/.")}\n\n`);
229
+ return 0;
230
+ }
231
+
232
+ let patterns = attributeProfiles(captured, since);
233
+ if (targetProfile) patterns = patterns.filter(p => p.profile === targetProfile);
234
+
235
+ if (patterns.length < minSize) {
236
+ process.stdout.write(`\n Only ${patterns.length} captured prompt(s)${targetProfile ? ` for profile ${targetProfile}` : ""} — need ≥${minSize} to cluster.\n\n`);
237
+ return 0;
238
+ }
239
+
240
+ const items: ClusterItem[] = patterns.map((p, i) => ({
241
+ id: `prompt-${i}`,
242
+ text: p.prompt,
243
+ }));
244
+
245
+ const clusters = clusterByKeywords(items, { minSize, maxClusters: 6 });
246
+ if (clusters.length === 0) {
247
+ process.stdout.write(`\n No clusters of ≥${minSize} similar prompts found among ${patterns.length} captures.\n`);
248
+ process.stdout.write(` ${dim("Try a wider --since window once you've used cue more.")}\n\n`);
249
+ return 0;
250
+ }
251
+
252
+ process.stdout.write(`\n ${bold(`Found ${clusters.length} session pattern cluster(s)`)} ${dim(`from ${patterns.length} captured prompts`)}\n\n`);
253
+
254
+ const drafts: DraftedSkill[] = [];
255
+ for (const cluster of clusters) {
256
+ const examples = cluster.items.map(i => patterns[parseInt(i.id.replace("prompt-", ""), 10)]!.prompt);
257
+ process.stdout.write(` ▸ "${cluster.term}" (${cluster.items.length} sessions)\n`);
258
+ for (const ex of examples.slice(0, 3)) {
259
+ process.stdout.write(` · ${ex.slice(0, 80)}${ex.length > 80 ? "…" : ""}\n`);
260
+ }
261
+ const draft = noClaude
262
+ ? {
263
+ slug: cluster.term.replace(/\s+/g, "-").toLowerCase(),
264
+ description: `Pattern around "${cluster.term}"`,
265
+ pattern: "Recurring user prompts share this keyword.",
266
+ examplePrompts: examples,
267
+ }
268
+ : draftSkillWithClaude(cluster.term, examples);
269
+ process.stdout.write(` → drafting skill: ${bold(draft.slug)} — ${draft.description}\n\n`);
270
+ drafts.push(draft);
271
+ }
272
+
273
+ if (dryRun) {
274
+ process.stdout.write(` [dry-run] Would write ${drafts.length} draft SKILL.md file(s) under ${outDir}\n`);
275
+ return 0;
276
+ }
277
+
278
+ mkdirSync(outDir, { recursive: true });
279
+ for (const draft of drafts) {
280
+ const skillDir = join(outDir, draft.slug);
281
+ mkdirSync(skillDir, { recursive: true });
282
+ writeFileSync(join(skillDir, "SKILL.md"), buildSkillMd(draft));
283
+ }
284
+ process.stdout.write(` 📝 Wrote ${drafts.length} draft SKILL.md file(s) to ${outDir}\n`);
285
+ process.stdout.write(` Review, refine, then mv <draft>/ resources/skills/skills/<category>/<slug>/ to adopt.\n`);
286
+ return 0;
287
+ }