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.
- package/README.md +757 -110
- package/package.json +5 -5
- package/profiles/README.md +12 -12
- package/profiles/SCHEMA.md +31 -3
- package/profiles/_cache/README.md +1 -1
- package/profiles/_types.ts +26 -1
- package/profiles/backend/profile.yaml +1 -0
- package/profiles/career/profile.yaml +13 -0
- package/profiles/core/profile.yaml +76 -9
- package/profiles/creative-media/README.md +1 -1
- package/profiles/cybersecurity/profile.yaml +779 -756
- package/profiles/ecc/profile.yaml +39 -0
- package/profiles/event-design/profile.yaml +10 -0
- package/profiles/fleet-control/README.md +1 -1
- package/profiles/frontend/profile.yaml +14 -0
- package/profiles/full/README.md +1 -1
- package/profiles/go-api/profile.yaml +1 -0
- package/profiles/marketing/profile.yaml +12 -1
- package/profiles/predict-everything/profile.yaml +9 -0
- package/profiles/rust/profile.yaml +22 -3
- package/profiles/rust-cli/profile.yaml +14 -0
- package/profiles/rust-core/profile.yaml +35 -0
- package/profiles/rust-embedded/profile.yaml +11 -0
- package/profiles/rust-ffi/profile.yaml +13 -0
- package/profiles/rust-game/profile.yaml +11 -0
- package/profiles/rust-wasm/profile.yaml +11 -0
- package/profiles/rust-web/profile.yaml +17 -0
- package/profiles/schema.json +44 -4
- package/profiles/trendradar/profile.yaml +11 -0
- package/resources/mcps/README.md +39 -164
- package/resources/mcps/configs/claude.sanitized.json +55 -0
- package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
- package/resources/skills/README.md +70 -113
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
- package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
- package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
- package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
- package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
- package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
- package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
- package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
- package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
- package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
- package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
- package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
- package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
- package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
- package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
- package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
- package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
- package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
- package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
- package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
- package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
- package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
- package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
- package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
- package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
- package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
- package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
- package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
- package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
- package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
- package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
- package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
- package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
- package/resources/skills/skills/rust/serde/SKILL.md +30 -0
- package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
- package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
- package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
- package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
- package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
- package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
- package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
- package/src/commands/_index.ts +47 -3
- package/src/commands/cli.test.ts +192 -0
- package/src/commands/cli.ts +303 -0
- package/src/commands/current.ts +1 -1
- package/src/commands/debug.test.ts +62 -0
- package/src/commands/debug.ts +212 -0
- package/src/commands/discover.scoring.test.ts +216 -0
- package/src/commands/discover.test.ts +145 -0
- package/src/commands/discover.ts +2618 -0
- package/src/commands/eval-behavior.test.ts +56 -0
- package/src/commands/eval-behavior.ts +189 -0
- package/src/commands/eval.test.ts +102 -0
- package/src/commands/eval.ts +348 -0
- package/src/commands/evolve.ts +291 -0
- package/src/commands/failures.test.ts +78 -0
- package/src/commands/failures.ts +393 -0
- package/src/commands/feedback.ts +219 -0
- package/src/commands/init.ts +26 -0
- package/src/commands/launch.e2e.test.ts +9 -1
- package/src/commands/launch.ts +174 -11
- package/src/commands/lint-skill.ts +157 -0
- package/src/commands/marketplace.ts +763 -2
- package/src/commands/new.ts +1 -1
- package/src/commands/optimizer.ts +92 -28
- package/src/commands/profile-draft-skill.test.ts +96 -0
- package/src/commands/profile-draft-skill.ts +287 -0
- package/src/commands/profile-evolve.test.ts +126 -0
- package/src/commands/profile-evolve.ts +0 -0
- package/src/commands/profile-suggest.ts +223 -0
- package/src/commands/profile.ts +41 -0
- package/src/commands/quick.ts +2 -17
- package/src/commands/scan.ts +2 -2
- package/src/commands/score.ts +1 -1
- package/src/commands/share.ts +1 -1
- package/src/commands/sources.ts +2 -2
- package/src/commands/submit-profile.ts +262 -0
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/use.ts +35 -5
- package/src/commands/validate.ts +1 -1
- package/src/index.ts +66 -0
- package/src/lib/analytics.ts +48 -2
- package/src/lib/claude-binary.ts +39 -0
- package/src/lib/cli-extractor.ts +77 -0
- package/src/lib/cluster-skills.test.ts +268 -0
- package/src/lib/cluster-skills.ts +290 -0
- package/src/lib/credentials-sync.test.ts +208 -0
- package/src/lib/credentials-sync.ts +205 -0
- package/src/lib/mcp-materializer.test.ts +1 -1
- package/src/lib/persona-playbooks.test.ts +111 -0
- package/src/lib/pr-poster.test.ts +243 -0
- package/src/lib/pr-poster.ts +285 -0
- package/src/lib/pr-throttle.test.ts +148 -0
- package/src/lib/pr-throttle.ts +209 -0
- package/src/lib/profile-generator.test.ts +1 -1
- package/src/lib/profile-generator.ts +2 -2
- package/src/lib/profile-linter.test.ts +6 -3
- package/src/lib/profile-linter.ts +71 -8
- package/src/lib/profile-loader.test.ts +1 -1
- package/src/lib/profile-loader.ts +16 -0
- package/src/lib/resolver-local.test.ts +1 -1
- package/src/lib/resolver-npx.test.ts +76 -1
- package/src/lib/resolver-npx.ts +35 -3
- package/src/lib/resolver-plugins.test.ts +1 -1
- package/src/lib/runtime-materializer.test.ts +191 -7
- package/src/lib/runtime-materializer.ts +310 -42
- package/src/lib/scan-plugins.test.ts +1 -1
- package/src/lib/skill-linter.test.ts +174 -0
- package/src/lib/skill-linter.ts +507 -0
- package/src/lib/skill-subset.test.ts +95 -0
- package/src/lib/skill-subset.ts +166 -0
- package/src/lib/star-prompt.ts +1 -1
- package/src/lib/uvx-installer.test.ts +229 -0
- package/src/lib/uvx-installer.ts +278 -0
package/src/commands/new.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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(`│ │ 📁
|
|
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
|
+
}
|