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
|
@@ -9,9 +9,22 @@
|
|
|
9
9
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { mkdir, rename, rm, symlink, writeFile, readFile, mkdtemp, readdir, lstat } from "node:fs/promises";
|
|
12
|
-
import { dirname, join } from "node:path";
|
|
12
|
+
import { dirname, join, resolve as resolvePath, basename, isAbsolute } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
13
14
|
|
|
14
15
|
import type { AgentKind, ResolvedProfile } from "../../profiles/_types";
|
|
16
|
+
import { normalizeUvxGitServers } from "./uvx-installer";
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
19
|
+
const RESOURCES_RULES = join(REPO_ROOT, "resources", "rules");
|
|
20
|
+
const RESOURCES_COMMANDS = join(REPO_ROOT, "resources", "commands");
|
|
21
|
+
const RESOURCES_HOOKS = join(REPO_ROOT, "resources", "hooks");
|
|
22
|
+
const RESOURCES_PLAYBOOKS = join(REPO_ROOT, "resources", "playbooks");
|
|
23
|
+
const RESOURCES_QUALITY_GATES = join(REPO_ROOT, "resources", "quality-gates");
|
|
24
|
+
|
|
25
|
+
function resolveResourcePath(ref: string, base: string): string {
|
|
26
|
+
return isAbsolute(ref) ? ref : join(base, ref);
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
/** MCP server configuration as stored in the registry. */
|
|
17
30
|
export interface McpServerConfig {
|
|
@@ -66,23 +79,45 @@ function computeHash(profile: ResolvedProfile, agent: AgentKind): string {
|
|
|
66
79
|
export async function materializeRuntime(input: MaterializeInput): Promise<MaterializeOutput> {
|
|
67
80
|
const { profile, agent, runtimeRoot } = input;
|
|
68
81
|
const runtimeDir = join(runtimeRoot, profile.name, agentSubdir(agent));
|
|
82
|
+
|
|
83
|
+
// Normalize any `uvx --from git+<repo> <bin>` MCP entries: install the
|
|
84
|
+
// package locally with `uv tool install` and rewrite the entry to call the
|
|
85
|
+
// installed binary. Sidesteps both the MCP-startup cold-download race and
|
|
86
|
+
// the auto-mode classifier's "fetch arbitrary code from URL" block.
|
|
87
|
+
// Idempotent — re-runs detect an existing binary and just rewrite.
|
|
88
|
+
const { normalized: normalizedRegistry, report: uvxReport } =
|
|
89
|
+
normalizeUvxGitServers(input.mcpRegistry);
|
|
90
|
+
const effectiveInput: MaterializeInput = { ...input, mcpRegistry: normalizedRegistry };
|
|
91
|
+
if (uvxReport.installed.length > 0) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`[cue] installed uvx MCPs: ${uvxReport.installed.join(", ")}\n`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
const hash = computeHash(profile, agent);
|
|
70
98
|
|
|
99
|
+
// Collect profile MCP entries once — used by both cache-hit and rebuild paths
|
|
100
|
+
// for the .claude.json sync.
|
|
101
|
+
const mcpServers = collectProfileMcps(profile, agent, effectiveInput.mcpRegistry);
|
|
102
|
+
|
|
71
103
|
// Short-circuit if hash matches.
|
|
72
104
|
try {
|
|
73
105
|
const existing = (await readFile(join(runtimeDir, ".cue-hash"), "utf8")).trim();
|
|
74
106
|
if (existing === hash) {
|
|
75
107
|
// Refresh state from credentialsSource even on cache hit so account
|
|
76
108
|
// switches and newly-added source entries are reflected.
|
|
77
|
-
if (
|
|
109
|
+
if (effectiveInput.credentialsSource) {
|
|
78
110
|
// Re-merge settings.json from current credentialsSource.
|
|
79
111
|
if (agent === "claude-code") {
|
|
80
|
-
const merged = await buildClaudeSettings(profile, agent,
|
|
112
|
+
const merged = await buildClaudeSettings(profile, agent, effectiveInput);
|
|
81
113
|
await writeFile(join(runtimeDir, "settings.json"), merged + "\n");
|
|
82
114
|
}
|
|
83
115
|
// Re-overlay any source entries that aren't already present (e.g.
|
|
84
116
|
// user added a new sessions/ entry, plugins/, etc.).
|
|
85
|
-
await overlaySourceState(runtimeDir,
|
|
117
|
+
await overlaySourceState(runtimeDir, effectiveInput.credentialsSource);
|
|
118
|
+
}
|
|
119
|
+
if (agent === "claude-code") {
|
|
120
|
+
await syncMcpsIntoClaudeJson(runtimeDir, mcpServers);
|
|
86
121
|
}
|
|
87
122
|
return { runtimeDir, rebuilt: false, hash };
|
|
88
123
|
}
|
|
@@ -92,26 +127,123 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
92
127
|
await mkdir(dirname(runtimeDir), { recursive: true });
|
|
93
128
|
const tmpDir = await mkdtemp(`${runtimeDir}.tmp.`);
|
|
94
129
|
|
|
95
|
-
// 1. Skills
|
|
130
|
+
// 1. Skills — missing refs are warned + skipped, not fatal. A profile that
|
|
131
|
+
// lists 20 skills and has 1 broken ref shouldn't crash the entire launch.
|
|
132
|
+
// `cue debug` and `cue validate` surface the broken ref clearly so the user
|
|
133
|
+
// can fix it. Behavior matches `cue debug`'s tolerance.
|
|
96
134
|
const skillsDir = join(tmpDir, "skills");
|
|
97
135
|
await mkdir(skillsDir, { recursive: true });
|
|
136
|
+
const skippedSkills: string[] = [];
|
|
98
137
|
for (const skill of profile.skills.local) {
|
|
99
138
|
if (!appliesToAgent(skill, agent)) continue;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
139
|
+
try {
|
|
140
|
+
const src = await input.skillSourceLookup(skill.id);
|
|
141
|
+
const target = join(skillsDir, skill.id);
|
|
142
|
+
await mkdir(dirname(target), { recursive: true });
|
|
143
|
+
await symlink(src, target);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
skippedSkills.push(skill.id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (skippedSkills.length > 0) {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`[cue] skipped ${skippedSkills.length} missing skill(s): ${skippedSkills.slice(0, 5).join(", ")}` +
|
|
151
|
+
(skippedSkills.length > 5 ? `, +${skippedSkills.length - 5} more` : "") +
|
|
152
|
+
` — run \`cue debug ${profile.name}\` for details\n`,
|
|
153
|
+
);
|
|
104
154
|
}
|
|
105
155
|
|
|
106
|
-
//
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
156
|
+
// Defensive defaults — older fixtures may not declare these arrays.
|
|
157
|
+
const profileRules = profile.rules ?? [];
|
|
158
|
+
const profileCommands = profile.commands ?? [];
|
|
159
|
+
const profileHooks = profile.hooks ?? [];
|
|
160
|
+
|
|
161
|
+
// 1b. Commands — symlink each <ref>.md into commands/ (Claude reads .claude/commands/*.md)
|
|
162
|
+
if (agent === "claude-code" && profileCommands.length > 0) {
|
|
163
|
+
const commandsDir = join(tmpDir, "commands");
|
|
164
|
+
await mkdir(commandsDir, { recursive: true });
|
|
165
|
+
for (const ref of profileCommands) {
|
|
166
|
+
const src = resolveResourcePath(ref.endsWith(".md") ? ref : `${ref}.md`, RESOURCES_COMMANDS);
|
|
167
|
+
try {
|
|
168
|
+
await lstat(src);
|
|
169
|
+
await symlink(src, join(commandsDir, basename(src)));
|
|
170
|
+
} catch { /* missing source — skip */ }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 1c. Rules — symlink into rules/. Contents get appended to CLAUDE.md below.
|
|
175
|
+
if (profileRules.length > 0) {
|
|
176
|
+
const rulesDir = join(tmpDir, "rules");
|
|
177
|
+
await mkdir(rulesDir, { recursive: true });
|
|
178
|
+
for (const ref of profileRules) {
|
|
179
|
+
const src = resolveResourcePath(ref.endsWith(".md") ? ref : `${ref}.md`, RESOURCES_RULES);
|
|
180
|
+
try {
|
|
181
|
+
await lstat(src);
|
|
182
|
+
await symlink(src, join(rulesDir, basename(src)));
|
|
183
|
+
} catch { /* missing source — skip */ }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 1d. Hooks — symlink scripts into hooks/. settings.json wiring happens in buildClaudeSettings.
|
|
188
|
+
// A hook ref points at a `.json` config; its referenced script (e.g. `<stem>.sh`)
|
|
189
|
+
// lives next to it in resources/hooks/ and must be symlinked too, otherwise the
|
|
190
|
+
// Stop/PreToolUse/etc. hook fires `bash $CLAUDE_CONFIG_DIR/hooks/<stem>.sh` and
|
|
191
|
+
// dies with "No such file or directory".
|
|
192
|
+
if (agent === "claude-code" && profileHooks.length > 0) {
|
|
193
|
+
const hooksDir = join(tmpDir, "hooks");
|
|
194
|
+
await mkdir(hooksDir, { recursive: true });
|
|
195
|
+
for (const ref of profileHooks) {
|
|
196
|
+
const src = resolveResourcePath(ref, RESOURCES_HOOKS);
|
|
197
|
+
try {
|
|
198
|
+
await lstat(src);
|
|
199
|
+
await symlink(src, join(hooksDir, basename(src)));
|
|
200
|
+
} catch { /* missing source — skip */ }
|
|
201
|
+
const stem = basename(ref).replace(/\.[^.]+$/, "");
|
|
202
|
+
for (const ext of [".sh", ".py", ".js", ".mjs", ".ts"]) {
|
|
203
|
+
const companion = join(RESOURCES_HOOKS, `${stem}${ext}`);
|
|
204
|
+
try {
|
|
205
|
+
await lstat(companion);
|
|
206
|
+
await symlink(companion, join(hooksDir, `${stem}${ext}`));
|
|
207
|
+
} catch { /* no companion at this ext — skip */ }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 1e. Playbooks (Phase 2) — symlink markdown protocols into playbooks/.
|
|
213
|
+
// Indexed in CLAUDE.md so Claude knows to consult them; bodies lazy-loaded.
|
|
214
|
+
const profilePlaybooks = (profile as any).playbooks ?? [];
|
|
215
|
+
if (profilePlaybooks.length > 0) {
|
|
216
|
+
const pbDir = join(tmpDir, "playbooks");
|
|
217
|
+
await mkdir(pbDir, { recursive: true });
|
|
218
|
+
for (const ref of profilePlaybooks) {
|
|
219
|
+
const src = resolveResourcePath(ref.endsWith(".md") ? ref : `${ref}.md`, RESOURCES_PLAYBOOKS);
|
|
220
|
+
try {
|
|
221
|
+
await lstat(src);
|
|
222
|
+
await symlink(src, join(pbDir, basename(src)));
|
|
223
|
+
} catch { /* missing source — skip */ }
|
|
224
|
+
}
|
|
112
225
|
}
|
|
226
|
+
|
|
227
|
+
// 1f. Quality gates (Phase 3) — symlink validator scripts into quality-gates/.
|
|
228
|
+
// The Stop hook (cue-quality-gates.sh, see resources/hooks/) iterates this
|
|
229
|
+
// directory and fails the session if any gate exits non-zero.
|
|
230
|
+
const profileGates = (profile as any).qualityGates ?? [];
|
|
231
|
+
if (agent === "claude-code" && profileGates.length > 0) {
|
|
232
|
+
const gDir = join(tmpDir, "quality-gates");
|
|
233
|
+
await mkdir(gDir, { recursive: true });
|
|
234
|
+
for (const ref of profileGates) {
|
|
235
|
+
const src = resolveResourcePath(ref, RESOURCES_QUALITY_GATES);
|
|
236
|
+
try {
|
|
237
|
+
await lstat(src);
|
|
238
|
+
await symlink(src, join(gDir, basename(src)));
|
|
239
|
+
} catch { /* missing source — skip */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 2. settings.json (Claude) or config.toml (Codex) — Claude-only first cut.
|
|
244
|
+
// mcpServers was already collected above (used by both code paths).
|
|
113
245
|
if (agent === "claude-code") {
|
|
114
|
-
const merged = await buildClaudeSettings(profile, agent,
|
|
246
|
+
const merged = await buildClaudeSettings(profile, agent, effectiveInput);
|
|
115
247
|
await writeFile(join(tmpDir, "settings.json"), merged + "\n");
|
|
116
248
|
} else {
|
|
117
249
|
// Codex equivalent — write config.toml from registry. Caller pre-renders to TOML.
|
|
@@ -130,6 +262,15 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
130
262
|
`# Active Profile: ${iconStr ? iconStr + " " : ""}${profile.name}\n\n` +
|
|
131
263
|
`> ${profile.description}\n\n`;
|
|
132
264
|
|
|
265
|
+
// Phase 1: Persona — multi-line role-priming defining who the agent IS.
|
|
266
|
+
// Goes above the mechanical "Your Role" block so it primes interpretation
|
|
267
|
+
// of everything that follows. Profiles without a persona keep the old
|
|
268
|
+
// generic block (backwards-compatible).
|
|
269
|
+
const profilePersona = (profile as any).persona ?? "";
|
|
270
|
+
if (profilePersona.trim()) {
|
|
271
|
+
stamp += `## Your Expertise\n\n${profilePersona.trim()}\n\n`;
|
|
272
|
+
}
|
|
273
|
+
|
|
133
274
|
// Role identity — tell Claude what it is
|
|
134
275
|
stamp += `## Your Role\n\n` +
|
|
135
276
|
`You are operating as **${profile.name}** — ${profile.description.toLowerCase()}.\n` +
|
|
@@ -176,16 +317,9 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
176
317
|
}
|
|
177
318
|
} catch { /* analytics unavailable — skip */ }
|
|
178
319
|
|
|
179
|
-
// Profile fit monitoring
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
`- You're doing work outside this profile's domain (e.g. backend work in a frontend profile)\n` +
|
|
183
|
-
`- None of the loaded skills are relevant to what the user is asking\n` +
|
|
184
|
-
`- You keep needing tools/skills that aren't in this profile\n\n` +
|
|
185
|
-
`Then after completing the user's immediate request, suggest switching:\n\n` +
|
|
186
|
-
`> 💡 This session has been mostly [backend/infra/docs] work — your current profile is **${profile.name}**.\n` +
|
|
187
|
-
`> A better fit might be **[suggested]**. Switch with: \`/cue switch [name]\` or \`echo [name] > .cue-profile\`\n\n` +
|
|
188
|
-
`Only suggest once per session. Don't interrupt urgent work.\n\n`;
|
|
320
|
+
// Profile fit monitoring — formerly a ~150-token hardcoded block; now a
|
|
321
|
+
// skill (meta/profile-fit-monitor) loaded on demand. Net per-message cost
|
|
322
|
+
// drops to just the skill's description line in "## Available Skills".
|
|
189
323
|
|
|
190
324
|
// #8: Warm-start context — last session summary
|
|
191
325
|
const lastSession = await getLastSessionSummary(profile.name);
|
|
@@ -199,6 +333,40 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
199
333
|
stamp += `## Common Workflows\n\n${chains}\n\n`;
|
|
200
334
|
}
|
|
201
335
|
|
|
336
|
+
// Rules — index only. Symlinks live in rules/; Claude reads on demand instead
|
|
337
|
+
// of paying the full token cost every turn.
|
|
338
|
+
if (profileRules.length > 0) {
|
|
339
|
+
stamp += `## Rules (${profileRules.length})\n\n` +
|
|
340
|
+
`Read on demand from \`rules/\`:\n` +
|
|
341
|
+
profileRules.map((r) => `- \`rules/${basename(r.endsWith(".md") ? r : `${r}.md`)}\``).join("\n") + "\n\n";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Commands — list as a quick reference
|
|
345
|
+
if (profileCommands.length > 0) {
|
|
346
|
+
stamp += `## Available Commands\n\n` +
|
|
347
|
+
profileCommands.map((c) => `- /${basename(c, ".md")}`).join("\n") + "\n\n";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Playbooks (Phase 2) — proven step-by-step protocols for common tasks.
|
|
351
|
+
// Indexed only; bodies are read on demand when the matching task triggers.
|
|
352
|
+
if (profilePlaybooks.length > 0) {
|
|
353
|
+
stamp += `## Playbooks (${profilePlaybooks.length})\n\n` +
|
|
354
|
+
`Read on demand from \`playbooks/\` when the user's request matches:\n` +
|
|
355
|
+
profilePlaybooks.map((p: string) => {
|
|
356
|
+
const stem = basename(p, ".md");
|
|
357
|
+
return `- \`playbooks/${stem}.md\` — use when ${stem.replace(/-/g, " ")}`;
|
|
358
|
+
}).join("\n") + "\n\n" +
|
|
359
|
+
`**Following a playbook beats freestyling.** If a relevant playbook exists, read it first and step through it.\n\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Quality gates (Phase 3) — mention so Claude knows what'll be checked at Stop.
|
|
363
|
+
const profileGatesForStamp = (profile as any).qualityGates ?? [];
|
|
364
|
+
if (profileGatesForStamp.length > 0) {
|
|
365
|
+
stamp += `## Quality Gates\n\nBefore claiming this session complete, these checks run at Stop:\n` +
|
|
366
|
+
profileGatesForStamp.map((g: string) => `- \`${basename(g)}\``).join("\n") + "\n\n" +
|
|
367
|
+
`Don't claim "done" if you haven't met them — they'll fail you publicly.\n\n`;
|
|
368
|
+
}
|
|
369
|
+
|
|
202
370
|
stamp += `---\n*generated ${new Date().toISOString()} — do not hand-edit*\n\n`;
|
|
203
371
|
|
|
204
372
|
await writeFile(join(tmpDir, agent === "claude-code" ? "CLAUDE.md" : "AGENTS.md"), stamp + input.userClaudeMd);
|
|
@@ -252,9 +420,56 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
252
420
|
await rm(runtimeDir, { recursive: true, force: true });
|
|
253
421
|
await rename(tmpDir, runtimeDir);
|
|
254
422
|
|
|
423
|
+
if (agent === "claude-code") {
|
|
424
|
+
await syncMcpsIntoClaudeJson(runtimeDir, mcpServers);
|
|
425
|
+
}
|
|
426
|
+
|
|
255
427
|
return { runtimeDir, rebuilt: true, hash };
|
|
256
428
|
}
|
|
257
429
|
|
|
430
|
+
function collectProfileMcps(
|
|
431
|
+
profile: ResolvedProfile,
|
|
432
|
+
agent: AgentKind,
|
|
433
|
+
registry: Record<string, McpServerConfig>,
|
|
434
|
+
): Record<string, McpServerConfig> {
|
|
435
|
+
const out: Record<string, McpServerConfig> = {};
|
|
436
|
+
for (const m of profile.mcps) {
|
|
437
|
+
if (!appliesToAgent(m, agent)) continue;
|
|
438
|
+
const reg = registry[m.id];
|
|
439
|
+
if (reg !== undefined) out[m.id] = reg;
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Claude Code reads MCP server definitions from .claude.json's top-level
|
|
445
|
+
// `mcpServers` field, not from settings.json. Without this sync, profile MCPs
|
|
446
|
+
// declared in profile.yaml never get started.
|
|
447
|
+
//
|
|
448
|
+
// We dereference any symlink first and write a real file in its place so
|
|
449
|
+
// per-profile MCP additions don't leak back into a shared account-level
|
|
450
|
+
// .claude.json (e.g. multiple cue profiles backed by the same account file).
|
|
451
|
+
async function syncMcpsIntoClaudeJson(
|
|
452
|
+
runtimeDir: string,
|
|
453
|
+
mcpServers: Record<string, McpServerConfig>,
|
|
454
|
+
): Promise<void> {
|
|
455
|
+
const target = join(runtimeDir, ".claude.json");
|
|
456
|
+
let parsed: Record<string, unknown> = {};
|
|
457
|
+
try {
|
|
458
|
+
const raw = await readFile(target, "utf8"); // follows symlink
|
|
459
|
+
parsed = JSON.parse(raw);
|
|
460
|
+
} catch {
|
|
461
|
+
// missing or unreadable — start with an empty doc; claude will fill the
|
|
462
|
+
// rest on next startup. If the file isn't valid JSON we'd lose state, but
|
|
463
|
+
// claude itself would also fail to read it, so a clean rewrite is fine.
|
|
464
|
+
}
|
|
465
|
+
const existing = (parsed.mcpServers as Record<string, unknown> | undefined) ?? {};
|
|
466
|
+
parsed.mcpServers = { ...existing, ...mcpServers };
|
|
467
|
+
|
|
468
|
+
// Replace whatever's there (symlink or stale file) with a real file copy.
|
|
469
|
+
await rm(target, { force: true });
|
|
470
|
+
await writeFile(target, JSON.stringify(parsed, null, 2));
|
|
471
|
+
}
|
|
472
|
+
|
|
258
473
|
// Build the merged Claude Code settings.json content (string).
|
|
259
474
|
// Reads existing settings from credentialsSource (preserves permissions,
|
|
260
475
|
// trustedDirectories, skipAutoPermissionPrompt) and overlays the profile's
|
|
@@ -263,6 +478,9 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
263
478
|
const CUE_MANAGED_ENTRIES = new Set([
|
|
264
479
|
"settings.json",
|
|
265
480
|
"skills",
|
|
481
|
+
"commands",
|
|
482
|
+
"hooks",
|
|
483
|
+
"rules",
|
|
266
484
|
"CLAUDE.md",
|
|
267
485
|
"AGENTS.md",
|
|
268
486
|
".cue-hash",
|
|
@@ -290,10 +508,31 @@ async function overlaySourceState(targetDir: string, sourceDir: string): Promise
|
|
|
290
508
|
return; // source unreadable; nothing to overlay
|
|
291
509
|
}
|
|
292
510
|
|
|
511
|
+
// Legacy home-root .claude.json fallback: older Claude Code put session
|
|
512
|
+
// state at ~/.claude.json (sibling to ~/.claude/), not inside it. If the
|
|
513
|
+
// canonical inside-dir version is missing but the home-root one exists,
|
|
514
|
+
// surface it so the runtime looks fully onboarded — otherwise claude
|
|
515
|
+
// boots into the OAuth flow even with a valid .credentials.json present.
|
|
516
|
+
// Only kicks in when sourceDir is the user's ~/.claude.
|
|
517
|
+
if (!entries.includes(".claude.json") && sourceDir === join(homedir(), ".claude")) {
|
|
518
|
+
const legacy = join(homedir(), ".claude.json");
|
|
519
|
+
try {
|
|
520
|
+
const { existsSync } = await import("node:fs");
|
|
521
|
+
if (existsSync(legacy)) entries.push(".claude.json");
|
|
522
|
+
} catch { /* skip */ }
|
|
523
|
+
}
|
|
524
|
+
|
|
293
525
|
for (const name of entries) {
|
|
294
526
|
if (CUE_MANAGED_ENTRIES.has(name)) continue;
|
|
295
527
|
const targetPath = join(targetDir, name);
|
|
296
|
-
|
|
528
|
+
// Special-case the legacy ~/.claude.json fallback above: source is at the
|
|
529
|
+
// home-root path, not inside sourceDir.
|
|
530
|
+
const isLegacyClaudeJson =
|
|
531
|
+
name === ".claude.json" &&
|
|
532
|
+
sourceDir === join(homedir(), ".claude");
|
|
533
|
+
const sourcePath = isLegacyClaudeJson
|
|
534
|
+
? join(homedir(), ".claude.json")
|
|
535
|
+
: join(sourceDir, name);
|
|
297
536
|
|
|
298
537
|
let existingType: "symlink" | "other" | "missing" = "missing";
|
|
299
538
|
try {
|
|
@@ -301,19 +540,21 @@ async function overlaySourceState(targetDir: string, sourceDir: string): Promise
|
|
|
301
540
|
existingType = st.isSymbolicLink() ? "symlink" : "other";
|
|
302
541
|
} catch { /* missing */ }
|
|
303
542
|
|
|
304
|
-
|
|
543
|
+
// .claude.json gets the same copy-not-symlink treatment as .credentials.json:
|
|
544
|
+
// claude rewrites it atomically and we want per-profile session state, not
|
|
545
|
+
// a shared one that gets clobbered when 2 profiles run concurrently.
|
|
546
|
+
const isCopyFile = name === ".credentials.json" || isLegacyClaudeJson;
|
|
305
547
|
|
|
306
|
-
if (existingType === "
|
|
548
|
+
if (existingType === "other" && !isCopyFile) continue; // cue override — don't touch
|
|
549
|
+
|
|
550
|
+
if (existingType === "symlink" || (existingType === "other" && isCopyFile)) {
|
|
307
551
|
// Replace if it points elsewhere (e.g. previous account on cache hit).
|
|
308
552
|
try {
|
|
309
553
|
await rm(targetPath, { force: true });
|
|
310
554
|
} catch { continue; }
|
|
311
555
|
}
|
|
312
556
|
|
|
313
|
-
|
|
314
|
-
// refreshes tokens via atomic write (write tmp → rename), which replaces
|
|
315
|
-
// symlinks with regular files, leaving the source stale.
|
|
316
|
-
if (name === ".credentials.json") {
|
|
557
|
+
if (isCopyFile) {
|
|
317
558
|
const { copyFile } = await import("node:fs/promises");
|
|
318
559
|
try {
|
|
319
560
|
await copyFile(sourcePath, targetPath);
|
|
@@ -336,12 +577,7 @@ async function buildClaudeSettings(
|
|
|
336
577
|
if (!appliesToAgent(plugin, agent)) continue;
|
|
337
578
|
enabledPlugins[plugin.id] = true;
|
|
338
579
|
}
|
|
339
|
-
const mcpServers
|
|
340
|
-
for (const m of profile.mcps) {
|
|
341
|
-
if (!appliesToAgent(m, agent)) continue;
|
|
342
|
-
const reg = input.mcpRegistry[m.id];
|
|
343
|
-
if (reg !== undefined) mcpServers[m.id] = reg;
|
|
344
|
-
}
|
|
580
|
+
const mcpServers = collectProfileMcps(profile, agent, input.mcpRegistry);
|
|
345
581
|
let baseSettings: Record<string, unknown> = {};
|
|
346
582
|
if (input.credentialsSource) {
|
|
347
583
|
try {
|
|
@@ -349,11 +585,43 @@ async function buildClaudeSettings(
|
|
|
349
585
|
baseSettings = JSON.parse(raw);
|
|
350
586
|
} catch { /* no existing settings — start fresh */ }
|
|
351
587
|
}
|
|
352
|
-
|
|
588
|
+
|
|
589
|
+
// Merge profile hooks. A hook ref points to a JSON file with shape
|
|
590
|
+
// { hooks: { PreToolUse: [...], ... } } — same shape Claude Code expects.
|
|
591
|
+
// Multiple hook files concat their event arrays under each lifecycle key.
|
|
592
|
+
let mergedHooks: Record<string, unknown[]> = {};
|
|
593
|
+
const baseHooks = (baseSettings.hooks as Record<string, unknown[]> | undefined) ?? {};
|
|
594
|
+
for (const [k, v] of Object.entries(baseHooks)) {
|
|
595
|
+
mergedHooks[k] = Array.isArray(v) ? [...v] : [];
|
|
596
|
+
}
|
|
597
|
+
for (const ref of (profile.hooks ?? [])) {
|
|
598
|
+
const src = resolveResourcePath(ref, RESOURCES_HOOKS);
|
|
599
|
+
try {
|
|
600
|
+
const raw = await readFile(src, "utf8");
|
|
601
|
+
const parsed = JSON.parse(raw) as { hooks?: Record<string, unknown[]> };
|
|
602
|
+
for (const [event, entries] of Object.entries(parsed.hooks ?? {})) {
|
|
603
|
+
if (!Array.isArray(entries)) continue;
|
|
604
|
+
mergedHooks[event] = [...(mergedHooks[event] ?? []), ...entries];
|
|
605
|
+
}
|
|
606
|
+
} catch { /* missing or malformed — skip */ }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const settings: Record<string, unknown> = {
|
|
353
610
|
...baseSettings,
|
|
354
|
-
|
|
355
|
-
|
|
611
|
+
// MCPs are profile-scoped — do NOT merge baseSettings.mcpServers in.
|
|
612
|
+
// Otherwise every MCP registered in the user's source ~/.claude/settings.json
|
|
613
|
+
// (or ~/.claude-accounts/<acct>/settings.json) leaks into every profile's
|
|
614
|
+
// runtime, defeating profile isolation. Profiles like `cybersecurity` that
|
|
615
|
+
// declare `mcps: []` would otherwise show whatever the user has globally.
|
|
616
|
+
mcpServers,
|
|
617
|
+
// Same reasoning for plugins: profile is the source of truth. `enabledPlugins`
|
|
618
|
+
// controls Claude Code's plugin marketplace toggles per-profile; merging from
|
|
619
|
+
// baseSettings would re-enable marketing plugins inside a backend profile.
|
|
620
|
+
enabledPlugins,
|
|
356
621
|
};
|
|
622
|
+
if (Object.keys(mergedHooks).length > 0) {
|
|
623
|
+
settings.hooks = mergedHooks;
|
|
624
|
+
}
|
|
357
625
|
return JSON.stringify(settings, null, 2);
|
|
358
626
|
}
|
|
359
627
|
|
|
@@ -10,7 +10,7 @@ let pluginsRoot: string;
|
|
|
10
10
|
let configPath: string;
|
|
11
11
|
|
|
12
12
|
beforeEach(async () => {
|
|
13
|
-
root = await mkdtemp(join(tmpdir(), "
|
|
13
|
+
root = await mkdtemp(join(tmpdir(), "cue-scan-plugins-"));
|
|
14
14
|
pluginsRoot = join(root, "plugins");
|
|
15
15
|
configPath = join(root, "claude.json");
|
|
16
16
|
await mkdir(pluginsRoot, { recursive: true });
|
|
@@ -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
|
+
});
|