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
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Throttle + opt-out registry for cue's outbound PRs.
3
+ *
4
+ * Storage: ~/.cache/cue/pr-opened.json (atomically written).
5
+ *
6
+ * Three responsibilities:
7
+ * 1. **Cooldown per repo** — don't re-PR the same repo within `cooldownDays`
8
+ * (default 90). Cue should *never* PR a repo twice without an invitation.
9
+ * 2. **Daily cap** — GitHub auto-flags accounts that open >30 PRs/day. We
10
+ * cap ourselves at 25/day with headroom for retries.
11
+ * 3. **Opt-out cache** — if a repo's README contains `<!-- cue: ignore -->`
12
+ * we record it once and never check again. Idempotent on every scan.
13
+ */
14
+
15
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { homedir } from "node:os";
18
+
19
+ const DEFAULT_PATH = join(
20
+ process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"),
21
+ "cue",
22
+ "pr-opened.json",
23
+ );
24
+
25
+ export const DEFAULT_COOLDOWN_DAYS = 90;
26
+ export const DAILY_CAP = 25;
27
+
28
+ export type EntryState =
29
+ | "open" // PR open on GitHub
30
+ | "merged" // PR was merged
31
+ | "closed" // PR was closed without merge
32
+ | "opted-out" // skipped — repo README has <!-- cue: ignore -->
33
+ | "skipped"; // skipped for any other reason (e.g. no fixes to apply)
34
+
35
+ export interface ThrottleEntry {
36
+ repo: string; // owner/name
37
+ state: EntryState;
38
+ openedAt: string; // ISO timestamp
39
+ rulesFixed?: string[]; // R001, R005, etc. — only present when state was open
40
+ prNumber?: number;
41
+ prUrl?: string;
42
+ fork?: string; // owner/name of cue's fork (null after cleanup)
43
+ branch?: string; // branch on the fork
44
+ reason?: string; // optional human-readable reason
45
+ cleanedAt?: string; // when fork was deleted (cleanup-forks command)
46
+ }
47
+
48
+ export interface ThrottleDB {
49
+ version: 1;
50
+ history: ThrottleEntry[];
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Read + write
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function emptyDb(): ThrottleDB { return { version: 1, history: [] }; }
58
+
59
+ export function loadDb(path: string = DEFAULT_PATH): ThrottleDB {
60
+ if (!existsSync(path)) return emptyDb();
61
+ try {
62
+ const raw = readFileSync(path, "utf8");
63
+ const parsed = JSON.parse(raw) as ThrottleDB;
64
+ if (parsed.version !== 1 || !Array.isArray(parsed.history)) return emptyDb();
65
+ return parsed;
66
+ } catch {
67
+ return emptyDb();
68
+ }
69
+ }
70
+
71
+ /** Atomic write: temp file + rename. Survives concurrent writes. */
72
+ export function saveDb(db: ThrottleDB, path: string = DEFAULT_PATH): void {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ const tmp = `${path}.tmp.${process.pid}`;
75
+ writeFileSync(tmp, JSON.stringify(db, null, 2) + "\n");
76
+ renameSync(tmp, path);
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Queries
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** Days since `iso` timestamp. */
84
+ function daysSince(iso: string): number {
85
+ const ms = Date.now() - new Date(iso).getTime();
86
+ return ms / (1000 * 60 * 60 * 24);
87
+ }
88
+
89
+ /**
90
+ * Is this repo currently throttled? Returns reason string when throttled,
91
+ * null when free to post. Considers cooldown + opted-out state.
92
+ */
93
+ export function isThrottled(
94
+ db: ThrottleDB,
95
+ repo: string,
96
+ cooldownDays: number = DEFAULT_COOLDOWN_DAYS,
97
+ ): string | null {
98
+ const entries = db.history.filter((e) => e.repo === repo);
99
+ if (entries.length === 0) return null;
100
+
101
+ // Opted out — permanent
102
+ if (entries.some((e) => e.state === "opted-out")) {
103
+ return "repo has <!-- cue: ignore --> in README";
104
+ }
105
+
106
+ // Most recent post-or-skip
107
+ entries.sort((a, b) => b.openedAt.localeCompare(a.openedAt));
108
+ const latest = entries[0]!;
109
+ const days = daysSince(latest.openedAt);
110
+ if (days < cooldownDays) {
111
+ const remaining = Math.ceil(cooldownDays - days);
112
+ return `cooldown: last interaction was ${Math.floor(days)}d ago (state=${latest.state}); ${remaining}d remaining`;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** How many PRs we've opened in the last rolling 24h window. */
118
+ export function todayCount(db: ThrottleDB): number {
119
+ return db.history.filter((e) => e.state === "open" && daysSince(e.openedAt) < 1).length;
120
+ }
121
+
122
+ /** Headroom under DAILY_CAP. Negative means we've already exceeded. */
123
+ export function canPostMore(db: ThrottleDB): { ok: boolean; remaining: number; cap: number } {
124
+ const used = todayCount(db);
125
+ return { ok: used < DAILY_CAP, remaining: DAILY_CAP - used, cap: DAILY_CAP };
126
+ }
127
+
128
+ /** List entries by state — for the cleanup-forks command. */
129
+ export function filterByState(db: ThrottleDB, states: EntryState[]): ThrottleEntry[] {
130
+ const set = new Set(states);
131
+ return db.history.filter((e) => set.has(e.state));
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Mutations — always go through these helpers so audit history is correct
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export function recordOpened(
139
+ db: ThrottleDB,
140
+ e: { repo: string; rulesFixed: string[]; prNumber: number; prUrl: string; fork: string; branch: string },
141
+ ): ThrottleDB {
142
+ db.history.push({
143
+ repo: e.repo,
144
+ state: "open",
145
+ openedAt: new Date().toISOString(),
146
+ rulesFixed: e.rulesFixed,
147
+ prNumber: e.prNumber,
148
+ prUrl: e.prUrl,
149
+ fork: e.fork,
150
+ branch: e.branch,
151
+ });
152
+ return db;
153
+ }
154
+
155
+ export function recordOptOut(db: ThrottleDB, repo: string): ThrottleDB {
156
+ db.history.push({
157
+ repo,
158
+ state: "opted-out",
159
+ openedAt: new Date().toISOString(),
160
+ reason: "README contains <!-- cue: ignore -->",
161
+ });
162
+ return db;
163
+ }
164
+
165
+ export function recordSkipped(db: ThrottleDB, repo: string, reason: string): ThrottleDB {
166
+ db.history.push({
167
+ repo,
168
+ state: "skipped",
169
+ openedAt: new Date().toISOString(),
170
+ reason,
171
+ });
172
+ return db;
173
+ }
174
+
175
+ export function updateEntryState(
176
+ db: ThrottleDB,
177
+ match: { repo: string; prNumber?: number },
178
+ newState: EntryState,
179
+ extra: Partial<ThrottleEntry> = {},
180
+ ): ThrottleDB {
181
+ for (const e of db.history) {
182
+ if (e.repo !== match.repo) continue;
183
+ if (match.prNumber !== undefined && e.prNumber !== match.prNumber) continue;
184
+ e.state = newState;
185
+ Object.assign(e, extra);
186
+ }
187
+ return db;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Opt-out check — fetches README via gh, looks for the marker
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export const OPT_OUT_MARKER = "<!-- cue: ignore -->";
195
+ /**
196
+ * Opposite of OPT_OUT_MARKER: when --opt-in-only mode is active, we only
197
+ * post PRs to repos whose README contains this marker. Lets authors flip
198
+ * the consent model from "assumed yes" to "explicit yes".
199
+ */
200
+ export const OPT_IN_MARKER = "<!-- cue: ok -->";
201
+
202
+ /**
203
+ * Look in the cached DB first; if not seen, caller should fetch the README
204
+ * and call `recordOptOut` if it matches. Keeping the fetch out of this module
205
+ * keeps it dependency-free + easily testable.
206
+ */
207
+ export function hasRecordedOptOut(db: ThrottleDB, repo: string): boolean {
208
+ return db.history.some((e) => e.repo === repo && e.state === "opted-out");
209
+ }
@@ -20,7 +20,7 @@ let profilesRoot: string;
20
20
  let priorProfilesDir: string | undefined;
21
21
 
22
22
  beforeEach(async () => {
23
- scratchRoot = await mkdtemp(join(tmpdir(), "soul-profile-generator-"));
23
+ scratchRoot = await mkdtemp(join(tmpdir(), "cue-profile-generator-"));
24
24
  profilesRoot = join(scratchRoot, "profiles");
25
25
  priorProfilesDir = process.env.CUE_PROFILES_DIR;
26
26
  process.env.CUE_PROFILES_DIR = profilesRoot;
@@ -506,7 +506,7 @@ export function generateProfile(
506
506
  name: options.name,
507
507
  description:
508
508
  options.description ??
509
- `Auto-generated from soul scan (${included.length} skills)`,
509
+ `Auto-generated from cue scan (${included.length} skills)`,
510
510
  agents: ["claude-code", "codex"],
511
511
  };
512
512
  if (options.inheritCore && core.length > 0) {
@@ -1024,7 +1024,7 @@ function renderProfileYaml(
1024
1024
  ): string {
1025
1025
  const skills = buildProfileSkills(included);
1026
1026
  const lines: string[] = [];
1027
- lines.push(`# Auto-generated by soul new ${profile.name} --from-scan.`);
1027
+ lines.push(`# Auto-generated by cue new ${profile.name} --from-scan.`);
1028
1028
  if (core.length > 0) {
1029
1029
  lines.push("# Core candidates were detected as cross-cutting.");
1030
1030
  if (profile.inherits === "core") {
@@ -26,7 +26,7 @@ let configsRoot: string;
26
26
  let npxRepoRoot: string;
27
27
 
28
28
  beforeEach(async () => {
29
- root = await mkdtemp(join(tmpdir(), "soul-profile-linter-"));
29
+ root = await mkdtemp(join(tmpdir(), "cue-profile-linter-"));
30
30
  profilesDir = join(root, "profiles");
31
31
  skillsRoot = join(root, "skills", "skills");
32
32
  pluginsRoot = join(root, "plugins");
@@ -132,7 +132,7 @@ describe("lintProfile", () => {
132
132
 
133
133
  test("reports W1, W2, W3, and W4 warnings", async () => {
134
134
  const localRefs: string[] = [];
135
- for (let i = 0; i < 26; i++) {
135
+ for (let i = 0; i < 41; i++) {
136
136
  const ref = `bulk/skill-${i}`;
137
137
  localRefs.push(ref);
138
138
  await writeLocalSkill(ref);
@@ -217,7 +217,10 @@ describe("lintProfile", () => {
217
217
  );
218
218
 
219
219
  const messages = result.issues.map((issue) => issue.message).join("\n");
220
- expect(rules(result).filter((rule) => rule === "E3")).toHaveLength(4);
220
+ // Plugin-not-installed is demoted to W5 (environmental), so the three
221
+ // genuine resolver errors stay as E3 and the plugin shows up as W5.
222
+ expect(rules(result).filter((rule) => rule === "E3")).toHaveLength(3);
223
+ expect(rules(result).filter((rule) => rule === "W5")).toHaveLength(1);
221
224
  expect(messages).toContain("local skill");
222
225
  expect(messages).toContain("npx skill");
223
226
  expect(messages).toContain("plugin");
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Profile linter for `soul validate`.
2
+ * Profile linter for `cue validate`.
3
3
  *
4
4
  * The rules below are intentionally numbered and centralized so a later
5
5
  * suppression pass can key off stable ids such as `# lint: ignore W1`.
6
6
  *
7
- * W1: profile declares more than 25 skills.
7
+ * W1: profile declares more than 40 skills.
8
8
  * W2: profile declares more than 5 MCP servers.
9
9
  * W3: inheritance chain depth is greater than 2.
10
10
  * W4: a skill slug appears in both `skills.local` and `skills.npx`.
11
+ * W5: a referenced plugin is not installed locally (environmental, not a profile bug).
11
12
  * E1: profile `name:` collides with another profile.
12
13
  * E2: inheritance chain contains a cycle.
13
- * E3: referenced skill, MCP, or plugin cannot be resolved.
14
+ * E3: referenced skill or MCP cannot be resolved.
14
15
  */
15
16
 
16
17
  import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
@@ -38,15 +39,18 @@ import {
38
39
  resolveNpxDetailed,
39
40
  type NpxFetchFn,
40
41
  } from "./resolver-npx";
41
- import { resolvePlugins } from "./resolver-plugins";
42
+ import { PluginNotInstalled, resolvePlugins } from "./resolver-plugins";
42
43
 
43
44
  const HERE = dirname(fileURLToPath(import.meta.url));
44
45
  const REPO_ROOT = process.env.CUE_REPO_ROOT ?? process.env.SOUL_REPO_ROOT ?? resolve(HERE, "..", "..");
45
46
  const DEFAULT_PROFILES_DIR = join(REPO_ROOT, "profiles");
46
47
  const DEFAULT_SKILLS_ROOT = join(REPO_ROOT, "resources", "skills", "skills");
47
48
  const DEFAULT_CONFIGS_ROOT = join(REPO_ROOT, "resources", "mcps", "configs");
49
+ const DEFAULT_RULES_ROOT = join(REPO_ROOT, "resources", "rules");
50
+ const DEFAULT_COMMANDS_ROOT = join(REPO_ROOT, "resources", "commands");
51
+ const DEFAULT_HOOKS_ROOT = join(REPO_ROOT, "resources", "hooks");
48
52
 
49
- export type LintRuleId = "W1" | "W2" | "W3" | "W4" | "E1" | "E2" | "E3";
53
+ export type LintRuleId = "W1" | "W2" | "W3" | "W4" | "W5" | "E1" | "E2" | "E3";
50
54
  export type DiagnosticRuleId = LintRuleId | "SCHEMA" | "LOAD";
51
55
  export type LintSeverity = "warning" | "error";
52
56
 
@@ -60,7 +64,7 @@ export const PROFILE_LINT_RULES: Record<LintRuleId, RuleDoc> = {
60
64
  W1: {
61
65
  severity: "warning",
62
66
  title: "too many skills",
63
- description: "Profile declares more than 25 skills; this can bloat prompt tokens.",
67
+ description: "Profile declares more than 40 skills; this can bloat prompt tokens.",
64
68
  },
65
69
  W2: {
66
70
  severity: "warning",
@@ -77,6 +81,11 @@ export const PROFILE_LINT_RULES: Record<LintRuleId, RuleDoc> = {
77
81
  title: "ambiguous skill source",
78
82
  description: "A skill slug appears in both local and npx sources.",
79
83
  },
84
+ W5: {
85
+ severity: "warning",
86
+ title: "plugin not installed locally",
87
+ description: "A referenced plugin is not installed under ~/.claude/plugins (environmental — install with `/plugin marketplace add <name>`).",
88
+ },
80
89
  E1: {
81
90
  severity: "error",
82
91
  title: "profile name collision",
@@ -201,10 +210,51 @@ export async function lintProfile(
201
210
  await checkNpxSkills(resolved, result, opts);
202
211
  await checkPlugins(resolved, result, opts);
203
212
  await checkMcps(resolved, result, opts);
213
+ await checkResourceRefs(resolved, result, opts);
204
214
 
205
215
  return result;
206
216
  }
207
217
 
218
+ async function checkResourceRefs(
219
+ profile: ResolvedProfile,
220
+ result: ProfileLintResult,
221
+ _opts: ProfileLinterOptions,
222
+ ): Promise<void> {
223
+ const { access } = await import("node:fs/promises");
224
+ const { isAbsolute } = await import("node:path");
225
+
226
+ const buckets: { label: string; refs: string[]; root: string; addExt: boolean }[] = [
227
+ { label: "rules", refs: profile.rules, root: DEFAULT_RULES_ROOT, addExt: true },
228
+ { label: "commands", refs: profile.commands, root: DEFAULT_COMMANDS_ROOT, addExt: true },
229
+ { label: "hooks", refs: profile.hooks, root: DEFAULT_HOOKS_ROOT, addExt: false },
230
+ ];
231
+
232
+ for (const { label, refs, root, addExt } of buckets) {
233
+ if (refs.length === 0) {
234
+ addCheck(result, label, `no ${label} declared`);
235
+ continue;
236
+ }
237
+ let ok = 0;
238
+ for (const ref of refs) {
239
+ const withExt = addExt && !ref.endsWith(".md") ? `${ref}.md` : ref;
240
+ const full = isAbsolute(withExt) ? withExt : join(root, withExt);
241
+ try {
242
+ await access(full);
243
+ ok++;
244
+ } catch {
245
+ addIssue(
246
+ result,
247
+ "E3",
248
+ "error",
249
+ `${label} reference "${ref}" not found at ${full}`,
250
+ { subject: ref },
251
+ );
252
+ }
253
+ }
254
+ if (ok === refs.length) addCheck(result, label, `${ok} resolved`);
255
+ }
256
+ }
257
+
208
258
  async function withProfilesDir<T>(
209
259
  profilesRoot: string | undefined,
210
260
  fn: () => Promise<T>,
@@ -359,7 +409,7 @@ function checkStaticRules(
359
409
  result: ProfileLintResult,
360
410
  ): void {
361
411
  const skillCount = declaredSkillCount(profile);
362
- if (skillCount > 25) {
412
+ if (skillCount > 40) {
363
413
  addIssue(
364
414
  result,
365
415
  "W1",
@@ -520,7 +570,7 @@ async function resolveOneNpxSkill(
520
570
  }
521
571
  }
522
572
 
523
- const tempRepo = await mkdtemp(join(tmpdir(), "soul-validate-npx-"));
573
+ const tempRepo = await mkdtemp(join(tmpdir(), "cue-validate-npx-"));
524
574
  try {
525
575
  await mkdir(join(tempRepo, "profiles", "_cache", "npx"), { recursive: true });
526
576
  await resolveNpxDetailed(single, {
@@ -595,6 +645,19 @@ function addResolverIssue(
595
645
  ref: string,
596
646
  err: unknown,
597
647
  ): void {
648
+ // Plugin-not-installed is environmental state (the profile is correct, the
649
+ // user's machine just doesn't have the plugin yet) — demote to W5 warning
650
+ // rather than failing validation with E3.
651
+ if (err instanceof PluginNotInstalled) {
652
+ addIssue(
653
+ result,
654
+ "W5",
655
+ "warning",
656
+ `plugin "${ref}" is not installed locally — run /plugin marketplace add ${ref.split("@")[0]}`,
657
+ { subject: ref },
658
+ );
659
+ return;
660
+ }
598
661
  addIssue(
599
662
  result,
600
663
  "E3",
@@ -34,7 +34,7 @@ let scratchRoot: string;
34
34
  let priorEnv: string | undefined;
35
35
 
36
36
  beforeEach(async () => {
37
- scratchRoot = await mkdtemp(join(tmpdir(), "soul-profile-loader-"));
37
+ scratchRoot = await mkdtemp(join(tmpdir(), "cue-profile-loader-"));
38
38
  priorEnv = process.env.CUE_PROFILES_DIR;
39
39
  process.env.CUE_PROFILES_DIR = scratchRoot;
40
40
  });
@@ -373,6 +373,15 @@ function foldChain(chain: Profile[]): ResolvedProfile {
373
373
  child.plugins?.map(normalizePluginRef),
374
374
  ),
375
375
  env: mergeEnv(acc.env, child.env),
376
+ rules: dedupePrimitiveArray(acc.rules, child.rules),
377
+ commands: dedupePrimitiveArray(acc.commands, child.commands),
378
+ hooks: dedupePrimitiveArray(acc.hooks, child.hooks),
379
+ // Persona is leaf-wins (child overrides parent fully). Concatenating
380
+ // would produce awkward "you are X. ALSO you are Y" priming.
381
+ persona: child.persona ?? acc.persona,
382
+ playbooks: dedupePrimitiveArray(acc.playbooks, child.playbooks),
383
+ qualityGates: dedupePrimitiveArray(acc.qualityGates, child.qualityGates),
384
+ evals: dedupePrimitiveArray(acc.evals, child.evals),
376
385
  inheritanceChain: [...acc.inheritanceChain, child.name],
377
386
  };
378
387
  }
@@ -401,6 +410,13 @@ function normalizeToResolved(p: Profile, chain: string[]): ResolvedProfile {
401
410
  mcps: (p.mcps ?? []).map(normalizeMCPRef),
402
411
  plugins: (p.plugins ?? []).map(normalizePluginRef),
403
412
  env: { ...(p.env ?? {}) },
413
+ rules: [...(p.rules ?? [])],
414
+ commands: [...(p.commands ?? [])],
415
+ hooks: [...(p.hooks ?? [])],
416
+ persona: p.persona ?? "",
417
+ playbooks: [...(p.playbooks ?? [])],
418
+ qualityGates: [...(p.qualityGates ?? [])],
419
+ evals: [...(p.evals ?? [])],
404
420
  inheritanceChain: chain,
405
421
  };
406
422
  }
@@ -67,7 +67,7 @@ function profile(local: string[]): ResolvedProfile {
67
67
  }
68
68
 
69
69
  beforeEach(async () => {
70
- rootDir = await mkdtemp(join(tmpdir(), "soul-resolver-local-"));
70
+ rootDir = await mkdtemp(join(tmpdir(), "cue-resolver-local-"));
71
71
  skillsRoot = join(rootDir, "skills", "skills");
72
72
  await mkdir(skillsRoot, { recursive: true });
73
73
  await buildTree();
@@ -29,7 +29,7 @@ let repoRoot: string;
29
29
  let calls: Array<{ repo: string; pin: string | undefined; skill: string; destDir: string }>;
30
30
 
31
31
  beforeEach(() => {
32
- repoRoot = mkdtempSync(join(tmpdir(), "soul-test-"));
32
+ repoRoot = mkdtempSync(join(tmpdir(), "cue-test-"));
33
33
  // The resolver writes under <repoRoot>/profiles/_cache/npx/<key>/, so make
34
34
  // sure the parent dirs exist — cache.cachePut calls mkdirSync recursive.
35
35
  mkdirSync(join(repoRoot, "profiles", "_cache", "npx"), { recursive: true });
@@ -307,3 +307,78 @@ describe("resolveNpxDetailed", () => {
307
307
  );
308
308
  });
309
309
  });
310
+
311
+ // --- flattenNpxLayout: post-fetch path relocation --------------------------
312
+
313
+ describe("flattenNpxLayout", () => {
314
+ let staging: string;
315
+
316
+ beforeEach(() => {
317
+ staging = mkdtempSync(join(tmpdir(), "cue-flatten-test-"));
318
+ });
319
+ afterEach(() => {
320
+ try { rmSync(staging, { recursive: true, force: true }); } catch {}
321
+ });
322
+
323
+ it("relocates <staging>/.claude/skills/<skill>/ → <staging>/<skill>/", async () => {
324
+ const { flattenNpxLayout } = await import("./resolver-npx");
325
+
326
+ // Set up the layout the `skills` CLI actually produces.
327
+ const fromPath = join(staging, ".claude", "skills", "my-skill");
328
+ mkdirSync(fromPath, { recursive: true });
329
+ writeFileSync(join(fromPath, "SKILL.md"), "# my-skill\n");
330
+
331
+ flattenNpxLayout(staging, "my-skill");
332
+
333
+ const flatPath = join(staging, "my-skill");
334
+ expect(existsSync(flatPath)).toBe(true);
335
+ expect(existsSync(join(flatPath, "SKILL.md"))).toBe(true);
336
+ expect(existsSync(fromPath)).toBe(false);
337
+ // Cleanup: .claude/ should be gone now (was empty after the move).
338
+ expect(existsSync(join(staging, ".claude"))).toBe(false);
339
+ });
340
+
341
+ it("is a no-op if <staging>/<skill>/ already exists (don't clobber)", async () => {
342
+ const { flattenNpxLayout } = await import("./resolver-npx");
343
+
344
+ const existing = join(staging, "my-skill");
345
+ mkdirSync(existing, { recursive: true });
346
+ writeFileSync(join(existing, "EXISTING.md"), "do not overwrite\n");
347
+
348
+ const fromPath = join(staging, ".claude", "skills", "my-skill");
349
+ mkdirSync(fromPath, { recursive: true });
350
+ writeFileSync(join(fromPath, "NEW.md"), "would clobber existing\n");
351
+
352
+ flattenNpxLayout(staging, "my-skill");
353
+
354
+ // The existing skill is preserved.
355
+ expect(existsSync(join(existing, "EXISTING.md"))).toBe(true);
356
+ expect(existsSync(join(existing, "NEW.md"))).toBe(false);
357
+ });
358
+
359
+ it("is a no-op when source layout is absent (e.g. CLI changed again)", async () => {
360
+ const { flattenNpxLayout } = await import("./resolver-npx");
361
+
362
+ // Nothing in staging — flattening should be silent, not throw.
363
+ expect(() => flattenNpxLayout(staging, "missing-skill")).not.toThrow();
364
+ expect(existsSync(join(staging, "missing-skill"))).toBe(false);
365
+ });
366
+
367
+ it("leaves sibling skills under .claude/skills/ alone when relocating one", async () => {
368
+ const { flattenNpxLayout } = await import("./resolver-npx");
369
+
370
+ const skillsDir = join(staging, ".claude", "skills");
371
+ mkdirSync(join(skillsDir, "alpha"), { recursive: true });
372
+ mkdirSync(join(skillsDir, "beta"), { recursive: true });
373
+ writeFileSync(join(skillsDir, "alpha", "SKILL.md"), "");
374
+ writeFileSync(join(skillsDir, "beta", "SKILL.md"), "");
375
+
376
+ flattenNpxLayout(staging, "alpha");
377
+
378
+ // alpha got moved out, beta stayed in place — the .claude tree is still
379
+ // present because beta is still there.
380
+ expect(existsSync(join(staging, "alpha", "SKILL.md"))).toBe(true);
381
+ expect(existsSync(join(skillsDir, "beta", "SKILL.md"))).toBe(true);
382
+ expect(existsSync(join(skillsDir, "alpha"))).toBe(false);
383
+ });
384
+ });
@@ -118,7 +118,7 @@ export type NpxFetchFn = (
118
118
  * because the tests pass their own fetcher.
119
119
  */
120
120
  export const npxFetch: NpxFetchFn = async (repo, pin, skill, destDir) => {
121
- const args = ["skills", "add", repo, "--skill", skill, "-a", "claude-code", "-y"];
121
+ const args = ["-y", "skills@latest", "add", repo, "--skill", skill, "-a", "claude-code", "-y"];
122
122
  if (pin) {
123
123
  // Pin format from schema: "git@<sha>" or "tag@<version>".
124
124
  // `npx skills add` accepts `--ref <ref>` for both shas and tags.
@@ -139,8 +139,40 @@ export const npxFetch: NpxFetchFn = async (repo, pin, skill, destDir) => {
139
139
  stderr: res.stderr,
140
140
  });
141
141
  }
142
+
143
+ flattenNpxLayout(destDir, skill);
142
144
  };
143
145
 
146
+ /**
147
+ * The `skills` CLI drops fetched skills at `<destDir>/.claude/skills/<skill>/`
148
+ * (it follows Claude Code's runtime layout). Cue's resolver expects a flat
149
+ * `<destDir>/<skill>/` layout — so relocate here. Exported for tests; callers
150
+ * outside this module should not need this.
151
+ */
152
+ export function flattenNpxLayout(destDir: string, skill: string): void {
153
+ const fromClaudeLayout = join(destDir, ".claude", "skills", skill);
154
+ const flatTarget = join(destDir, skill);
155
+ if (!existsSync(fromClaudeLayout) || existsSync(flatTarget)) return;
156
+
157
+ // Lazy import — avoids node:fs/promises overhead in the cached-fetch path.
158
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
159
+ const fs = require("node:fs") as typeof import("node:fs");
160
+ fs.renameSync(fromClaudeLayout, flatTarget);
161
+
162
+ // Clean up the now-empty .claude/skills/ scaffold so the staging dir
163
+ // doesn't accumulate cruft when multiple skills are pulled in sequence.
164
+ try {
165
+ const claudeSkills = join(destDir, ".claude", "skills");
166
+ if (existsSync(claudeSkills) && fs.readdirSync(claudeSkills).length === 0) {
167
+ fs.rmSync(claudeSkills, { recursive: true, force: true });
168
+ }
169
+ const claudeDir = join(destDir, ".claude");
170
+ if (existsSync(claudeDir) && fs.readdirSync(claudeDir).length === 0) {
171
+ fs.rmSync(claudeDir, { recursive: true, force: true });
172
+ }
173
+ } catch { /* cleanup is best-effort */ }
174
+ }
175
+
144
176
  // ---------------------------------------------------------------------------
145
177
  // Public resolver
146
178
  // ---------------------------------------------------------------------------
@@ -156,7 +188,7 @@ export interface ResolveNpxOptions {
156
188
 
157
189
  export interface ResolveNpxResult {
158
190
  plans: LinkPlan[];
159
- /** Per-entry cache key — useful for debugging / `soul doctor`. */
191
+ /** Per-entry cache key — useful for debugging / `cue doctor`. */
160
192
  keys: Record<string, string>;
161
193
  }
162
194
 
@@ -263,7 +295,7 @@ async function fetchInto(
263
295
  // If the slot already exists (partial-hit repair), we merge skill subdirs
264
296
  // into the existing slot rather than nuking it; this keeps already-good
265
297
  // skills warm.
266
- const staging = mkdtempSync(join(tmpdir(), "soul-npx-"));
298
+ const staging = mkdtempSync(join(tmpdir(), "cue-npx-"));
267
299
  try {
268
300
  for (const skill of skills) {
269
301
  await fetcher(entry.repo, entry.pin, skill, staging);
@@ -23,7 +23,7 @@ import {
23
23
  let root: string;
24
24
 
25
25
  beforeEach(async () => {
26
- root = await mkdtemp(join(tmpdir(), "soul-resolver-plugins-"));
26
+ root = await mkdtemp(join(tmpdir(), "cue-resolver-plugins-"));
27
27
  });
28
28
 
29
29
  afterEach(async () => {