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
@@ -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 (input.credentialsSource) {
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, input);
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, input.credentialsSource);
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
- const src = await input.skillSourceLookup(skill.id);
101
- const target = join(skillsDir, skill.id);
102
- await mkdir(dirname(target), { recursive: true });
103
- await symlink(src, target);
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
- // 2. settings.json (Claude) or config.toml (Codex) Claude-only first cut.
107
- const mcpServers: Record<string, McpServerConfig> = {};
108
- for (const m of profile.mcps) {
109
- if (!appliesToAgent(m, agent)) continue;
110
- const reg = input.mcpRegistry[m.id];
111
- if (reg !== undefined) mcpServers[m.id] = reg;
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, input);
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
- stamp += `## Profile Fit Monitor\n\n` +
181
- `Track how well this profile matches the work being done. If you notice:\n` +
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
- const sourcePath = join(sourceDir, name);
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
- if (existingType === "other" && name !== ".credentials.json") continue; // cue override — don't touch
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 === "symlink" || (existingType === "other" && name === ".credentials.json")) {
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
- // .credentials.json must be COPIED (not symlinked) because Claude Code
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: Record<string, McpServerConfig> = {};
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
- const settings = {
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
- enabledPlugins: { ...(baseSettings.enabledPlugins as Record<string, unknown> ?? {}), ...enabledPlugins },
355
- mcpServers: { ...(baseSettings.mcpServers as Record<string, unknown> ?? {}), ...mcpServers },
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(), "soul-scan-plugins-"));
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
+ });