cue-ai 0.9.0 → 0.9.2

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 (310) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +82 -33
  3. package/bin/cue-review-progress +107 -0
  4. package/bin/cue-review-watch +98 -0
  5. package/dist/cue.js +7352 -3744
  6. package/package.json +16 -5
  7. package/profiles/_types.ts +9 -0
  8. package/profiles/backend/profile.yaml +2 -0
  9. package/profiles/blog-writer/profile.yaml +10 -0
  10. package/profiles/browser/profile.yaml +9 -2
  11. package/profiles/builder/profile.yaml +3 -6
  12. package/profiles/career/profile.yaml +13 -2
  13. package/profiles/claude-api/profile.yaml +1 -1
  14. package/profiles/commerce/profile.yaml +27 -3
  15. package/profiles/core/logo.png +0 -0
  16. package/profiles/core/profile.yaml +62 -2
  17. package/profiles/dash-merge-test/profile.yaml +109 -0
  18. package/profiles/designer/profile.yaml +2 -0
  19. package/profiles/designer-medusa-next/profile.yaml +4 -1
  20. package/profiles/designer-medusa-vite/profile.yaml +4 -1
  21. package/profiles/docs-writer/profile.yaml +3 -1
  22. package/profiles/eu-tender-research/README.md +48 -0
  23. package/profiles/eu-tender-research/logo.png +0 -0
  24. package/profiles/eu-tender-research/profile.yaml +108 -0
  25. package/profiles/finance/logo.png +0 -0
  26. package/profiles/finance/profile.yaml +46 -0
  27. package/profiles/frontend/profile.yaml +5 -9
  28. package/profiles/growth/profile.yaml +2 -3
  29. package/profiles/gstack/profile.yaml +15 -0
  30. package/profiles/higgsfield/profile.yaml +3 -0
  31. package/profiles/hyperframes/logo.png +0 -0
  32. package/profiles/hyperframes/profile.yaml +59 -0
  33. package/profiles/improver/profile.yaml +88 -0
  34. package/profiles/marketing/profile.yaml +0 -3
  35. package/profiles/medusa-dev/profile.yaml +2 -0
  36. package/profiles/medusa-next/profile.yaml +2 -3
  37. package/profiles/medusa-vite/profile.yaml +2 -3
  38. package/profiles/n8n/logo.png +0 -0
  39. package/profiles/n8n/profile.yaml +50 -0
  40. package/profiles/nextjs/profile.yaml +2 -3
  41. package/profiles/ops/profile.yaml +2 -0
  42. package/profiles/postizz/profile.yaml +13 -3
  43. package/profiles/python/profile.yaml +3 -0
  44. package/profiles/research/profile.yaml +3 -1
  45. package/profiles/schema.json +10 -0
  46. package/profiles/secops/profile.yaml +2 -0
  47. package/profiles/seo/profile.yaml +56 -0
  48. package/profiles/skill-writer/profile.yaml +8 -0
  49. package/profiles/ssh/profile.yaml +32 -0
  50. package/profiles/strapi/logo.png +0 -0
  51. package/profiles/strapi/profile.yaml +45 -0
  52. package/profiles/stripe/logo.png +0 -0
  53. package/profiles/stripe/profile.yaml +1 -0
  54. package/profiles/supabase/logo.png +0 -0
  55. package/profiles/supabase/profile.yaml +85 -0
  56. package/profiles/vercel/logo.png +0 -0
  57. package/profiles/vercel/profile.yaml +25 -1
  58. package/profiles/vite/profile.yaml +4 -3
  59. package/profiles/web-frontend-base/profile.yaml +5 -4
  60. package/profiles/webshop/profile.yaml +23 -5
  61. package/profiles/x-growth-bot/profile.yaml +44 -0
  62. package/resources/icons/generate-icons.py +128 -2
  63. package/resources/mcps/configs/claude.sanitized.json +42 -0
  64. package/resources/mcps/configs/codex.sanitized.json +7 -0
  65. package/resources/skills/skills/career/resume-version-manager/SKILL.md +351 -0
  66. package/resources/skills/skills/career/salary-negotiation-prep/SKILL.md +378 -0
  67. package/resources/skills/skills/content/pdf/SKILL.md +2 -0
  68. package/resources/skills/skills/content/postiz-cards/SKILL.md +48 -0
  69. package/resources/skills/skills/content/postiz-cards/scripts/analytics.sh +38 -0
  70. package/resources/skills/skills/content/postiz-cards/scripts/card.sh +42 -0
  71. package/resources/skills/skills/content/postiz-cards/scripts/lint.py +38 -0
  72. package/resources/skills/skills/design/headless-gif-demo/SKILL.md +1 -1
  73. package/resources/skills/skills/design/readme-svg-design/SKILL.md +1 -1
  74. package/resources/skills/skills/eu-funding/grant-outreach/SKILL.md +70 -0
  75. package/resources/skills/skills/eu-funding/hu-grant-finder/SKILL.md +114 -0
  76. package/resources/skills/skills/eu-funding/hu-grant-finder/evals.md +26 -0
  77. package/resources/skills/skills/eu-funding/ted-tender-search/SKILL.md +80 -0
  78. package/resources/skills/skills/eu-funding/ted-tender-search/evals.md +26 -0
  79. package/resources/skills/skills/eu-funding/ted-tender-search/scripts/ted-search.sh +46 -0
  80. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +1 -1
  81. package/resources/skills/skills/github/gx-agents/SKILL.md +96 -0
  82. package/resources/skills/skills/gstack/design-shotgun/SKILL.md +1 -1
  83. package/resources/skills/skills/marketing/ab-test-analyzer/SKILL.md +1 -1
  84. package/resources/skills/skills/marketing/ab-test-setup-and-analysis/SKILL.md +1 -1
  85. package/resources/skills/skills/marketing/account-structure-review/SKILL.md +1 -1
  86. package/resources/skills/skills/marketing/ad-copy-variant-generator/SKILL.md +1 -1
  87. package/resources/skills/skills/marketing/ad-extension-audit/SKILL.md +1 -1
  88. package/resources/skills/skills/marketing/ad-spend-allocator/SKILL.md +1 -1
  89. package/resources/skills/skills/marketing/anomaly-detection/SKILL.md +1 -1
  90. package/resources/skills/skills/marketing/attribution-model-comparison/SKILL.md +1 -1
  91. package/resources/skills/skills/marketing/audience-overlap-analysis/SKILL.md +7 -1
  92. package/resources/skills/skills/marketing/bid-strategy-recommendations/SKILL.md +7 -1
  93. package/resources/skills/skills/marketing/budget-scenario-planner/SKILL.md +6 -1
  94. package/resources/skills/skills/marketing/campaign-naming-convention-builder/SKILL.md +7 -1
  95. package/resources/skills/skills/marketing/channel-mix-optimizer/SKILL.md +7 -1
  96. package/resources/skills/skills/marketing/client-report-narratives/SKILL.md +6 -1
  97. package/resources/skills/skills/marketing/competitor-creative-analysis/SKILL.md +1 -1
  98. package/resources/skills/skills/marketing/competitor-teardown/SKILL.md +1 -1
  99. package/resources/skills/skills/marketing/content-repurposer/SKILL.md +1 -1
  100. package/resources/skills/skills/marketing/conversion-path-analysis/SKILL.md +1 -1
  101. package/resources/skills/skills/marketing/cpa-diagnostics/SKILL.md +1 -1
  102. package/resources/skills/skills/marketing/creative-fatigue-detection/SKILL.md +1 -1
  103. package/resources/skills/skills/marketing/day-hour-performance-breakdown/SKILL.md +1 -1
  104. package/resources/skills/skills/marketing/device-performance-split/SKILL.md +1 -1
  105. package/resources/skills/skills/marketing/e2e-seo-assistant/SKILL.md +1 -1
  106. package/resources/skills/skills/marketing/email-sequence-writer/SKILL.md +1 -1
  107. package/resources/skills/skills/marketing/frequency-cap-recommendations/SKILL.md +1 -1
  108. package/resources/skills/skills/marketing/geo-performance-analysis/SKILL.md +1 -1
  109. package/resources/skills/skills/marketing/google-ads-audit/SKILL.md +1 -1
  110. package/resources/skills/skills/marketing/icp-research-assistant/SKILL.md +1 -1
  111. package/resources/skills/skills/marketing/keyword-cannibalization-check/SKILL.md +1 -1
  112. package/resources/skills/skills/marketing/landing-page-audit/SKILL.md +1 -1
  113. package/resources/skills/skills/marketing/landing-page-audit-quick/SKILL.md +1 -1
  114. package/resources/skills/skills/marketing/linkedin-ads-audit/SKILL.md +1 -1
  115. package/resources/skills/skills/marketing/meta-ads-audit/SKILL.md +1 -1
  116. package/resources/skills/skills/marketing/pacing-monitor/SKILL.md +1 -1
  117. package/resources/skills/skills/marketing/performance-benchmarking/SKILL.md +1 -1
  118. package/resources/skills/skills/marketing/programmatic-seo-builder/SKILL.md +1 -1
  119. package/resources/skills/skills/marketing/quality-score-breakdown/SKILL.md +1 -1
  120. package/resources/skills/skills/marketing/reddit-ads-audit/SKILL.md +1 -1
  121. package/resources/skills/skills/marketing/retargeting-window-analysis/SKILL.md +1 -1
  122. package/resources/skills/skills/marketing/roas-forecasting/SKILL.md +1 -1
  123. package/resources/skills/skills/marketing/search-term-mining/SKILL.md +1 -1
  124. package/resources/skills/skills/marketing/utm-tracking-generator/SKILL.md +1 -1
  125. package/resources/skills/skills/marketing/wasted-spend-finder/SKILL.md +1 -1
  126. package/resources/skills/skills/marketing/weekly-account-summary/SKILL.md +1 -1
  127. package/resources/skills/skills/meta/awesome-list-submit/SKILL.md +4 -4
  128. package/resources/skills/skills/meta/cue-dashboard/SKILL.md +109 -0
  129. package/resources/skills/skills/meta/cue-developer/SKILL.md +161 -0
  130. package/resources/skills/skills/meta/cue-developer/evals/evals.json +57 -0
  131. package/resources/skills/skills/meta/cue-developer/references/architecture.md +65 -0
  132. package/resources/skills/skills/meta/cue-developer/references/build_and_test.md +72 -0
  133. package/resources/skills/skills/meta/cue-developer/references/contributing.md +75 -0
  134. package/resources/skills/skills/meta/cue-developer/references/conventions.md +57 -0
  135. package/resources/skills/skills/meta/cue-developer/references/first_time_setup.md +51 -0
  136. package/resources/skills/skills/meta/cue-developer/references/skill_and_mcp_authoring.md +84 -0
  137. package/resources/skills/skills/meta/cue-developer/references/troubleshooting.md +42 -0
  138. package/resources/skills/skills/meta/delegation-check/SKILL.md +148 -0
  139. package/resources/skills/skills/meta/delegation-check/specs/scan-algorithm.md +125 -0
  140. package/resources/skills/skills/meta/delegation-check/specs/separation-rules.md +190 -0
  141. package/resources/skills/skills/meta/focus/SKILL.md +62 -0
  142. package/resources/skills/skills/meta/help/SKILL.md +1 -1
  143. package/resources/skills/skills/meta/integrity-tags/SKILL.md +2 -0
  144. package/resources/skills/skills/meta/next-steps/SKILL.md +124 -0
  145. package/resources/skills/skills/meta/next-steps/evals/eval-set.json +92 -0
  146. package/resources/skills/skills/meta/profile-from-docs/SKILL.md +141 -0
  147. package/resources/skills/skills/meta/ralph-loop/SKILL.md +83 -0
  148. package/resources/skills/skills/meta/ralph-loop/scripts/loop.sh +73 -0
  149. package/resources/skills/skills/meta/skill-simplify/SKILL.md +136 -0
  150. package/resources/skills/skills/meta/skill-simplify/phases/01-analysis.md +173 -0
  151. package/resources/skills/skills/meta/skill-simplify/phases/02-optimize.md +104 -0
  152. package/resources/skills/skills/meta/skill-simplify/phases/03-check.md +145 -0
  153. package/resources/skills/skills/meta/smart-loader/scripts/smart-lookup.sh +13 -4
  154. package/resources/skills/skills/meta/verify-council/SKILL.md +182 -0
  155. package/resources/skills/skills/meta/verify-council/references/lane-prompts.md +103 -0
  156. package/resources/skills/skills/meta/verify-council/references/workflow.js +217 -0
  157. package/resources/skills/skills/nvidia/aiq-research/SKILL.md +1 -1
  158. package/resources/skills/skills/nvidia/cuopt-developer/SKILL.md +16 -1
  159. package/resources/skills/skills/nvidia/cuopt-developer/resources/contributing.md +2 -2
  160. package/resources/skills/skills/nvidia/cuopt-developer/resources/numerical_debugging.md +128 -0
  161. package/resources/skills/skills/nvidia/cuopt-developer/resources/python_bindings.md +2 -9
  162. package/resources/skills/skills/nvidia/cuopt-developer/resources/vrp_skills.md +166 -0
  163. package/resources/skills/skills/nvidia/cuopt-install/SKILL.md +2 -10
  164. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/SKILL.md +3 -23
  165. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/resources/examples.md +40 -20
  166. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-python/SKILL.md +5 -1
  167. package/resources/skills/skills/nvidia/skill-evolution/SKILL.md +4 -5
  168. package/resources/skills/skills/research/trendradar/SKILL.md +1 -1
  169. package/resources/skills/skills/ssh/ssh-config/SKILL.md +94 -0
  170. package/resources/skills/skills/ssh/ssh-copy/SKILL.md +92 -0
  171. package/resources/skills/skills/ssh/ssh-harden/SKILL.md +108 -0
  172. package/resources/skills/skills/ssh/ssh-keys/SKILL.md +82 -0
  173. package/resources/skills/skills/ssh/ssh-paste-image/LICENSE +28 -0
  174. package/resources/skills/skills/ssh/ssh-paste-image/SKILL.md +149 -0
  175. package/resources/skills/skills/ssh/ssh-paste-image/scripts/build.sh +29 -0
  176. package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/go.mod +3 -0
  177. package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/main.go +79 -0
  178. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/ccimgd.service +12 -0
  179. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/com.ccimgd.plist +20 -0
  180. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/go.mod +3 -0
  181. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/main.go +98 -0
  182. package/resources/skills/skills/ssh/ssh-tunnel/SKILL.md +96 -0
  183. package/resources/skills/skills/strapi/building-with-strapi/SKILL.md +112 -0
  184. package/resources/skills/skills/strapi/strapi-cli/SKILL.md +93 -0
  185. package/resources/skills/skills/strapi/strapi-content-api/SKILL.md +115 -0
  186. package/resources/skills/skills/strapi/strapi-deploy/SKILL.md +89 -0
  187. package/resources/skills/skills/strapi/strapi-mcp-setup/SKILL.md +101 -0
  188. package/resources/skills/skills/strapi/strapi-plugins/SKILL.md +97 -0
  189. package/resources/skills/skills/tools/context7/SKILL.md +101 -0
  190. package/resources/skills/skills/tools/opensrc/SKILL.md +1 -1
  191. package/resources/skills/skills/tools/portless/SKILL.md +186 -0
  192. package/resources/skills/skills/xbot/operate/SKILL.md +229 -0
  193. package/src/commands/_index.ts +8 -0
  194. package/src/commands/ai-score.e2e.test.ts +11 -4
  195. package/src/commands/ai.ts +3 -4
  196. package/src/commands/auto-detect.ts +1 -1
  197. package/src/commands/cli.test.ts +1 -2
  198. package/src/commands/cli.ts +1 -1
  199. package/src/commands/cloud.ts +1 -1
  200. package/src/commands/current.ts +1 -4
  201. package/src/commands/dash.test.ts +110 -0
  202. package/src/commands/dash.ts +194 -0
  203. package/src/commands/dashboard.ts +26 -0
  204. package/src/commands/diff.ts +1 -1
  205. package/src/commands/discover.test.ts +1 -1
  206. package/src/commands/discover.ts +90 -40
  207. package/src/commands/doctor.test.ts +58 -0
  208. package/src/commands/doctor.ts +79 -3
  209. package/src/commands/eval-behavior.ts +1 -1
  210. package/src/commands/eval.ts +2 -2
  211. package/src/commands/evolve.ts +4 -3
  212. package/src/commands/failures.test.ts +1 -1
  213. package/src/commands/features-batch1.test.ts +6 -1
  214. package/src/commands/icon.ts +1 -5
  215. package/src/commands/import-profile.ts +1 -1
  216. package/src/commands/init.ts +50 -7
  217. package/src/commands/install-sh.e2e.test.ts +65 -0
  218. package/src/commands/launch-handoff.e2e.test.ts +88 -0
  219. package/src/commands/launch.e2e.test.ts +8 -1
  220. package/src/commands/launch.test.ts +29 -0
  221. package/src/commands/launch.ts +185 -131
  222. package/src/commands/lock.ts +0 -1
  223. package/src/commands/marketplace.ts +0 -4
  224. package/src/commands/materialize.ts +1 -1
  225. package/src/commands/mem.ts +341 -0
  226. package/src/commands/optimizer.ts +0 -3
  227. package/src/commands/playground.ts +1 -2
  228. package/src/commands/profile-draft-skill.ts +1 -1
  229. package/src/commands/replay-whatif.ts +1 -6
  230. package/src/commands/score.ts +2 -2
  231. package/src/commands/security.test.ts +88 -0
  232. package/src/commands/security.ts +74 -28
  233. package/src/commands/shell.test.ts +65 -4
  234. package/src/commands/shell.ts +67 -7
  235. package/src/commands/skills-test.ts +0 -1
  236. package/src/commands/skills.ts +28 -2
  237. package/src/commands/sources.ts +1 -2
  238. package/src/commands/status.ts +2 -6
  239. package/src/commands/submit-profile.ts +1 -1
  240. package/src/commands/suggest.ts +35 -10
  241. package/src/commands/trigger-gaps.test.ts +50 -0
  242. package/src/commands/trigger-gaps.ts +63 -29
  243. package/src/commands/update.ts +1 -1
  244. package/src/commands/validate.ts +16 -4
  245. package/src/commands/watch-live.ts +1 -1
  246. package/src/commands/workspace.ts +1 -1
  247. package/src/index.ts +26 -10
  248. package/src/lib/active-sessions.ts +1 -1
  249. package/src/lib/agent-adapters.test.ts +100 -0
  250. package/src/lib/agent-adapters.ts +2 -2
  251. package/src/lib/analytics.test.ts +88 -0
  252. package/src/lib/analytics.ts +82 -1
  253. package/src/lib/auto-detect.test.ts +10 -4
  254. package/src/lib/auto-detect.ts +19 -23
  255. package/src/lib/brand-icons.ts +0 -1
  256. package/src/lib/cache.ts +2 -3
  257. package/src/lib/claude-mem-env.test.ts +148 -0
  258. package/src/lib/claude-mem-env.ts +172 -0
  259. package/src/lib/combo-history.test.ts +53 -0
  260. package/src/lib/combo-history.ts +83 -0
  261. package/src/lib/companion-detect.test.ts +108 -0
  262. package/src/lib/companion-detect.ts +140 -0
  263. package/src/lib/companion-fetch.ts +4 -6
  264. package/src/lib/conditional-skills.test.ts +1 -1
  265. package/src/lib/config-paths.test.ts +53 -0
  266. package/src/lib/config-paths.ts +33 -0
  267. package/src/lib/dashboard-server.test.ts +351 -0
  268. package/src/lib/dashboard-server.ts +1476 -27
  269. package/src/lib/debug-log.test.ts +66 -0
  270. package/src/lib/debug-log.ts +45 -0
  271. package/src/lib/mcp-catalog.test.ts +102 -0
  272. package/src/lib/mcp-catalog.ts +193 -0
  273. package/src/lib/pair-suggestions.test.ts +111 -0
  274. package/src/lib/pair-suggestions.ts +98 -5
  275. package/src/lib/permissions.test.ts +76 -0
  276. package/src/lib/permissions.ts +125 -0
  277. package/src/lib/picker.test.ts +1106 -1
  278. package/src/lib/picker.ts +1230 -142
  279. package/src/lib/plugin-discovery.ts +126 -0
  280. package/src/lib/pr-poster.ts +1 -1
  281. package/src/lib/pr-throttle.ts +2 -6
  282. package/src/lib/profile-linter.test.ts +67 -1
  283. package/src/lib/profile-linter.ts +59 -14
  284. package/src/lib/profile-loader.test.ts +21 -0
  285. package/src/lib/profile-loader.ts +22 -3
  286. package/src/lib/profile-metrics.ts +2 -6
  287. package/src/lib/profile-names.test.ts +58 -0
  288. package/src/lib/repos.test.ts +57 -0
  289. package/src/lib/repos.ts +167 -0
  290. package/src/lib/resolver-npx.ts +10 -1
  291. package/src/lib/runtime-materializer.test.ts +200 -3
  292. package/src/lib/runtime-materializer.ts +129 -20
  293. package/src/lib/shared-profiles.ts +2 -3
  294. package/src/lib/skill-clis.test.ts +113 -0
  295. package/src/lib/skill-clis.ts +232 -0
  296. package/src/lib/skill-dependencies.ts +9 -1
  297. package/src/lib/skill-deps.ts +1 -1
  298. package/src/lib/skill-linter.ts +1 -1
  299. package/src/lib/skill-quality.ts +0 -1
  300. package/src/lib/skill-sandbox.test.ts +1 -1
  301. package/src/lib/skills-lock.test.ts +1 -1
  302. package/src/lib/telemetry-consent.ts +3 -5
  303. package/src/lib/telemetry-report.test.ts +2 -2
  304. package/src/lib/token-budget.ts +111 -0
  305. package/src/lib/trigger-gaps.test.ts +70 -0
  306. package/src/lib/trigger-gaps.ts +48 -6
  307. package/src/lib/tui/data.ts +1 -5
  308. package/src/lib/workflow-store.ts +150 -0
  309. package/src/lib/workspace-secrets.ts +0 -4
  310. package/src/lib/workspaces.ts +1 -1
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Discover Claude Code plugins installed on this machine, independent of the
3
+ * active cue profile. Reads Claude Code's real plugin store:
4
+ *
5
+ * <claude-home>/plugins/installed_plugins.json — the install registry
6
+ * <claude-home>/settings.json → enabledPlugins — the on/off map
7
+ *
8
+ * and enriches each entry with its plugin.json description + bundled-skill
9
+ * count when the install path is present on disk.
10
+ *
11
+ * This backs the studio Plugins page's "all installed" view, which is a
12
+ * superset of the profile's declared `plugins:` (that list is a curated
13
+ * subset cue wires into one profile). claude-home defaults to ~/.claude;
14
+ * override with CUE_CLAUDE_HOME for tests or a non-standard install.
15
+ */
16
+
17
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ export interface DiscoveredPlugin {
22
+ /** "name@marketplace" — the id Claude Code keys plugins by. */
23
+ id: string;
24
+ name: string;
25
+ marketplace: string;
26
+ version: string;
27
+ /** settings.enabledPlugins[id] === true */
28
+ enabled: boolean;
29
+ /** appears anywhere in the enabledPlugins map (known to Claude Code). */
30
+ known: boolean;
31
+ installedAt: string | null;
32
+ installPath: string | null;
33
+ /** From the plugin's .claude-plugin/plugin.json, capped. */
34
+ description: string;
35
+ /** Skills the plugin bundles (SKILL.md count under its install path). */
36
+ skills: number;
37
+ }
38
+
39
+ function claudeHome(): string {
40
+ return process.env.CUE_CLAUDE_HOME ?? join(homedir(), ".claude");
41
+ }
42
+
43
+ function readJson<T>(path: string): T | null {
44
+ try {
45
+ return JSON.parse(readFileSync(path, "utf8")) as T;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Skills live at <installPath>/skills/<name>/SKILL.md in the plugin layout. */
52
+ function countSkills(installPath: string): number {
53
+ const skillsDir = join(installPath, "skills");
54
+ if (!existsSync(skillsDir)) return 0;
55
+ try {
56
+ return readdirSync(skillsDir, { withFileTypes: true }).filter(
57
+ (d) => d.isDirectory() && existsSync(join(skillsDir, d.name, "SKILL.md")),
58
+ ).length;
59
+ } catch {
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ function manifestDescription(installPath: string): string {
65
+ const m = readJson<{ description?: string }>(
66
+ join(installPath, ".claude-plugin", "plugin.json"),
67
+ );
68
+ return (m?.description ?? "").slice(0, 200);
69
+ }
70
+
71
+ interface InstalledEntry {
72
+ version?: string;
73
+ installPath?: string;
74
+ installedAt?: string;
75
+ }
76
+
77
+ /**
78
+ * Enumerate every plugin Claude Code knows about on this machine, enabled or
79
+ * not. Returns enabled-first, then alphabetical. Missing files degrade to an
80
+ * empty list rather than throwing — the dashboard must render even on a fresh
81
+ * machine with no plugins.
82
+ */
83
+ export function discoverInstalledPlugins(): DiscoveredPlugin[] {
84
+ const home = claudeHome();
85
+ const installed = readJson<{ plugins: Record<string, InstalledEntry[]> }>(
86
+ join(home, "plugins", "installed_plugins.json"),
87
+ );
88
+ const settings = readJson<{ enabledPlugins?: Record<string, boolean> }>(
89
+ join(home, "settings.json"),
90
+ );
91
+ const enabledMap = settings?.enabledPlugins ?? {};
92
+ const installedMap = installed?.plugins ?? {};
93
+
94
+ // Union: a plugin can be installed-but-not-in-the-map, or in the map but
95
+ // its install cache pruned. Show both so nothing silently disappears.
96
+ const ids = new Set<string>([
97
+ ...Object.keys(installedMap),
98
+ ...Object.keys(enabledMap),
99
+ ]);
100
+
101
+ const out: DiscoveredPlugin[] = [];
102
+ for (const id of ids) {
103
+ const at = id.lastIndexOf("@");
104
+ const name = at > 0 ? id.slice(0, at) : id;
105
+ const marketplace = at > 0 ? id.slice(at + 1) : "";
106
+ const entry = installedMap[id]?.[0];
107
+ const installPath = entry?.installPath ?? null;
108
+ const onDisk = installPath != null && existsSync(installPath);
109
+ out.push({
110
+ id,
111
+ name,
112
+ marketplace,
113
+ version: entry?.version ?? "unknown",
114
+ enabled: enabledMap[id] === true,
115
+ known: id in enabledMap,
116
+ installedAt: entry?.installedAt ?? null,
117
+ installPath,
118
+ description: onDisk ? manifestDescription(installPath) : "",
119
+ skills: onDisk ? countSkills(installPath) : 0,
120
+ });
121
+ }
122
+
123
+ return out.sort(
124
+ (a, b) => Number(b.enabled) - Number(a.enabled) || a.id.localeCompare(b.id),
125
+ );
126
+ }
@@ -23,7 +23,7 @@ import { spawn } from "node:child_process";
23
23
  import { createHash } from "node:crypto";
24
24
  import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
25
25
  import { tmpdir } from "node:os";
26
- import { join, dirname } from "node:path";
26
+ import { join, } from "node:path";
27
27
 
28
28
  /**
29
29
  * Build a deterministic branch name keyed by today's date + a hash of the
@@ -14,13 +14,9 @@
14
14
 
15
15
  import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
16
16
  import { join, dirname } from "node:path";
17
- import { homedir } from "node:os";
17
+ import { cacheDir } from "./config-paths";
18
18
 
19
- const DEFAULT_PATH = join(
20
- process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"),
21
- "cue",
22
- "pr-opened.json",
23
- );
19
+ const DEFAULT_PATH = join(cacheDir(), "pr-opened.json");
24
20
 
25
21
  export const DEFAULT_COOLDOWN_DAYS = 90;
26
22
  export const DAILY_CAP = 25;
@@ -140,9 +140,38 @@ describe("lintProfile", () => {
140
140
  expect(result.checks.map((check) => check.name)).toContain("MCPs");
141
141
  });
142
142
 
143
+ test("demotes a local-only MCP (source dir present, absent from sanitized registry) to W9, not E3", async () => {
144
+ // An MCP with a source manifest dir but no sanitized-registry entry is a
145
+ // private/local-only server (wired into the user's own ~/.claude.json), not
146
+ // a dangling reference — warn (W9), like W5 demotes an uninstalled plugin.
147
+ await mkdir(join(root, "mcps", "mcps", "envoult"), { recursive: true });
148
+ await writeFile(join(root, "mcps", "mcps", "envoult", "skills.md"), "# envoult\n", "utf8");
149
+ await writeProfile(
150
+ "priv",
151
+ ["name: priv", "description: private mcp profile", "mcps: [envoult]", ""].join("\n"),
152
+ );
153
+
154
+ const result = await lintProfile("priv", opts());
155
+
156
+ expect(rules(result).filter((rule) => rule === "W9")).toHaveLength(1);
157
+ expect(rules(result).filter((rule) => rule === "E3")).toHaveLength(0);
158
+ });
159
+
160
+ test("still reports E3 for an MCP with neither a registry entry nor a source dir", async () => {
161
+ await writeProfile(
162
+ "dangling",
163
+ ["name: dangling", "description: dangling mcp ref", "mcps: [no-such-mcp]", ""].join("\n"),
164
+ );
165
+
166
+ const result = await lintProfile("dangling", opts());
167
+
168
+ expect(rules(result).filter((rule) => rule === "E3")).toHaveLength(1);
169
+ expect(rules(result).filter((rule) => rule === "W9")).toHaveLength(0);
170
+ });
171
+
143
172
  test("reports W1, W2, W3, and W4 warnings", async () => {
144
173
  const localRefs: string[] = [];
145
- for (let i = 0; i < 41; i++) {
174
+ for (let i = 0; i < 121; i++) {
146
175
  const ref = `bulk/skill-${i}`;
147
176
  localRefs.push(ref);
148
177
  await writeLocalSkill(ref);
@@ -237,6 +266,43 @@ describe("lintProfile", () => {
237
266
  expect(messages).toContain("MCP");
238
267
  expect(hasLintErrors(result)).toBe(true);
239
268
  });
269
+
270
+ test("offline mode skips uncached npx skills instead of erroring", async () => {
271
+ await writeProfile(
272
+ "npxoffline",
273
+ [
274
+ "name: npxoffline",
275
+ "description: npx offline test",
276
+ "skills:",
277
+ " npx:",
278
+ " - repo: anthropics/skills",
279
+ " skills: [ghost-uncached]",
280
+ "",
281
+ ].join("\n"),
282
+ );
283
+
284
+ let fetched = false;
285
+ const trackingFetch = async () => {
286
+ fetched = true;
287
+ };
288
+
289
+ // Offline (the validate default): an uncached npx skill is a benign skip,
290
+ // not an E3, and the network fetcher is never invoked.
291
+ const offline = await lintProfile(
292
+ "npxoffline",
293
+ opts({ npxOffline: true, npxFetch: trackingFetch }),
294
+ );
295
+ expect(fetched).toBe(false);
296
+ expect(rules(offline).filter((rule) => rule === "E3")).toHaveLength(0);
297
+ const npxCheck = offline.checks.find((check) => check.name === "npx skills");
298
+ expect(npxCheck?.message).toContain("not cached (offline");
299
+ expect(hasLintErrors(offline)).toBe(false);
300
+
301
+ // Online (--online): the fetcher IS invoked, preserving fetchability checks.
302
+ fetched = false;
303
+ await lintProfile("npxoffline", opts({ npxOffline: false, npxFetch: trackingFetch }));
304
+ expect(fetched).toBe(true);
305
+ });
240
306
  });
241
307
 
242
308
  describe("lintAllProfiles", () => {
@@ -4,7 +4,7 @@
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 40 skills.
7
+ * W1: profile declares more than 120 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`.
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
21
- import type { Dirent } from "node:fs";
21
+ import { existsSync, type Dirent } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { dirname, join, resolve } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
@@ -36,7 +36,7 @@ import {
36
36
  SchemaViolation,
37
37
  } from "../../profiles/_types";
38
38
  import { listProfiles, loadProfile } from "./profile-loader";
39
- import { materializeMcp, type MaterializeOptions } from "./mcp-materializer";
39
+ import { materializeMcp, McpNotFound, type MaterializeOptions } from "./mcp-materializer";
40
40
  import { resolveLocal } from "./resolver-local";
41
41
  import {
42
42
  NpxFetchFailed,
@@ -57,7 +57,7 @@ const DEFAULT_HOOKS_ROOT = join(REPO_ROOT, "resources", "hooks");
57
57
  const DEFAULT_SUBAGENTS_ROOT = join(REPO_ROOT, "resources", "subagents");
58
58
 
59
59
  export type LintRuleId =
60
- | "W1" | "W2" | "W3" | "W4" | "W5" | "W6" | "W7" | "W8"
60
+ | "W1" | "W2" | "W3" | "W4" | "W5" | "W6" | "W7" | "W8" | "W9"
61
61
  | "E1" | "E2" | "E3";
62
62
  export type DiagnosticRuleId = LintRuleId | "SCHEMA" | "LOAD";
63
63
  export type LintSeverity = "warning" | "error";
@@ -72,7 +72,7 @@ export const PROFILE_LINT_RULES: Record<LintRuleId, RuleDoc> = {
72
72
  W1: {
73
73
  severity: "warning",
74
74
  title: "too many skills",
75
- description: "Profile declares more than 40 skills; this can bloat prompt tokens.",
75
+ description: "Profile declares more than 120 skills; this can bloat prompt tokens.",
76
76
  },
77
77
  W2: {
78
78
  severity: "warning",
@@ -109,6 +109,11 @@ export const PROFILE_LINT_RULES: Record<LintRuleId, RuleDoc> = {
109
109
  title: "skill missing when_to_invoke",
110
110
  description: "Skill has a capability but no explicit `when_to_invoke:` frontmatter — proactive routing falls back to a single generic row. Add `when_to_invoke:` with task-shape bullets for richer routing.",
111
111
  },
112
+ W9: {
113
+ severity: "warning",
114
+ title: "local-only MCP",
115
+ description: "Profile references an MCP that has a source dir but isn't in the sanitized public registry — a private/local server the user wires into their own ~/.claude.json. Not a profile bug, so it's a warning, not E3.",
116
+ },
112
117
  E1: {
113
118
  severity: "error",
114
119
  title: "profile name collision",
@@ -498,7 +503,7 @@ function checkStaticRules(
498
503
  result: ProfileLintResult,
499
504
  ): void {
500
505
  const skillCount = declaredSkillCount(profile);
501
- if (skillCount > 40) {
506
+ if (skillCount > 120) {
502
507
  addIssue(
503
508
  result,
504
509
  "W1",
@@ -614,19 +619,27 @@ async function checkNpxSkills(
614
619
  }
615
620
 
616
621
  let resolvedCount = 0;
622
+ let skippedOffline = 0;
617
623
  for (const entry of entries) {
618
624
  for (const skill of entry.skills) {
619
625
  try {
620
- await resolveOneNpxSkill(entry, skill, opts);
621
- resolvedCount += 1;
626
+ const status = await resolveOneNpxSkill(entry, skill, opts);
627
+ if (status === "skipped-offline") skippedOffline += 1;
628
+ else resolvedCount += 1;
622
629
  } catch (err) {
623
630
  addResolverIssue(result, "npx skill", `${entry.repo}:${skill}`, err);
624
631
  }
625
632
  }
626
633
  }
627
634
 
628
- if (resolvedCount === total) {
629
- addCheck(result, "npx skills", `${resolvedCount} resolved or fetchable`);
635
+ if (resolvedCount + skippedOffline === total) {
636
+ addCheck(
637
+ result,
638
+ "npx skills",
639
+ skippedOffline > 0
640
+ ? `${resolvedCount} resolved, ${skippedOffline} not cached (offline; run --online to fetch)`
641
+ : `${resolvedCount} resolved or fetchable`,
642
+ );
630
643
  }
631
644
  }
632
645
 
@@ -634,7 +647,7 @@ async function resolveOneNpxSkill(
634
647
  entry: NpxSkillRef,
635
648
  skill: string,
636
649
  opts: ProfileLinterOptions,
637
- ): Promise<void> {
650
+ ): Promise<"resolved" | "skipped-offline"> {
638
651
  const single: Profile = {
639
652
  name: "lint-npx",
640
653
  description: "single npx resolver check",
@@ -649,9 +662,13 @@ async function resolveOneNpxSkill(
649
662
  fetch: opts.npxFetch,
650
663
  offline: true,
651
664
  });
652
- return;
665
+ return "resolved";
653
666
  } catch (err) {
654
- if (opts.npxOffline || (process.env.CUE_OFFLINE ?? process.env.SOUL_OFFLINE) === "1") {
667
+ const offline = opts.npxOffline || (process.env.CUE_OFFLINE ?? process.env.SOUL_OFFLINE) === "1";
668
+ if (offline) {
669
+ // Offline mode: a cache miss is a benign "not cached", not a lint error.
670
+ // Real problems (PinNotFound, schema, etc.) still propagate to E3.
671
+ if (err instanceof NpxFetchFailed) return "skipped-offline";
655
672
  throw err;
656
673
  }
657
674
  if (!(err instanceof NpxFetchFailed || err instanceof ProfileError)) {
@@ -670,6 +687,7 @@ async function resolveOneNpxSkill(
670
687
  } finally {
671
688
  await rm(tempRepo, { recursive: true, force: true });
672
689
  }
690
+ return "resolved";
673
691
  }
674
692
 
675
693
  async function checkPlugins(
@@ -719,7 +737,22 @@ async function checkMcps(
719
737
  });
720
738
  resolvedCount += 1;
721
739
  } catch (err) {
722
- addResolverIssue(result, "MCP", ref.id, err);
740
+ // A ref to an MCP that has a source dir but is absent from the sanitized
741
+ // public registry is a private/local-only server (configured in the
742
+ // user's own ~/.claude.json), not a profile bug — demote to W9, the same
743
+ // way PluginNotInstalled demotes to W5. A ref with no source dir is a
744
+ // genuine dangling reference and still fails with E3.
745
+ if (err instanceof McpNotFound && mcpHasLocalSource(ref.id, opts)) {
746
+ addIssue(
747
+ result,
748
+ "W9",
749
+ "warning",
750
+ `MCP "${ref.id}" is local-only — it has a source dir but isn't in the sanitized public registry; configure it in ~/.claude.json`,
751
+ { subject: ref.id },
752
+ );
753
+ } else {
754
+ addResolverIssue(result, "MCP", ref.id, err);
755
+ }
723
756
  }
724
757
  }
725
758
 
@@ -778,6 +811,18 @@ function profileWithMcp(profile: ResolvedProfile, ref: ResolvedProfile["mcps"][n
778
811
  };
779
812
  }
780
813
 
814
+ /**
815
+ * True when an MCP id has a source manifest dir under `resources/mcps/mcps/<id>`
816
+ * even though it's absent from the sanitized public registry — i.e. a private /
817
+ * local-only server, not a dangling reference. Keyed off the same registry root
818
+ * the resolver uses (configsRoot's parent) so test overrides stay consistent.
819
+ */
820
+ function mcpHasLocalSource(id: string, opts: ProfileLinterOptions): boolean {
821
+ if (!/^[A-Za-z0-9._-]+$/.test(id)) return false;
822
+ const configsRoot = opts.configsRoot ?? DEFAULT_CONFIGS_ROOT;
823
+ return existsSync(join(dirname(configsRoot), "mcps", id));
824
+ }
825
+
781
826
  function formatErrorMessage(err: unknown): string {
782
827
  if (err instanceof ProfileError) {
783
828
  return `${err.code}: ${err.message}`;
@@ -32,10 +32,15 @@ const EXAMPLES_DIR = join(REPO_ROOT, "profiles", "_examples");
32
32
 
33
33
  let scratchRoot: string;
34
34
  let priorEnv: string | undefined;
35
+ // SOUL_PROFILES_DIR is a fallback the loader honors; one test below sets it.
36
+ // Capture + restore it too so it never leaks into sibling test files (a dead
37
+ // SOUL_PROFILES_DIR would make their loadProfile() calls fail).
38
+ let priorSoulEnv: string | undefined;
35
39
 
36
40
  beforeEach(async () => {
37
41
  scratchRoot = await mkdtemp(join(tmpdir(), "cue-profile-loader-"));
38
42
  priorEnv = process.env.CUE_PROFILES_DIR;
43
+ priorSoulEnv = process.env.SOUL_PROFILES_DIR;
39
44
  process.env.CUE_PROFILES_DIR = scratchRoot;
40
45
  });
41
46
 
@@ -45,6 +50,11 @@ afterEach(() => {
45
50
  } else {
46
51
  process.env.CUE_PROFILES_DIR = priorEnv;
47
52
  }
53
+ if (priorSoulEnv === undefined) {
54
+ delete process.env.SOUL_PROFILES_DIR;
55
+ } else {
56
+ process.env.SOUL_PROFILES_DIR = priorSoulEnv;
57
+ }
48
58
  });
49
59
 
50
60
  /** Write a profile.yaml at `<scratchRoot>/<name>/profile.yaml`. */
@@ -413,6 +423,17 @@ describe("parseProfileSelector", () => {
413
423
  expect(() => parseProfileSelector("+++")).toThrow(/empty/i);
414
424
  });
415
425
 
426
+ test("duplicate parts collapse, first-occurrence order preserved", () => {
427
+ // A stale pin / Recent row can carry repeats — loading a profile twice
428
+ // bloats the materialized CLAUDE.md, so the parser de-dupes.
429
+ expect(parseProfileSelector("gstack+higgsfield+postizz+higgsfield+postizz+gstack")).toEqual([
430
+ "gstack",
431
+ "higgsfield",
432
+ "postizz",
433
+ ]);
434
+ expect(parseProfileSelector("a+a+a")).toEqual(["a"]);
435
+ });
436
+
416
437
  test("isCompositeSelector recognizes ≥2 parts", () => {
417
438
  expect(isCompositeSelector("postizz")).toBe(false);
418
439
  expect(isCompositeSelector("postizz+trendradar")).toBe(true);
@@ -33,7 +33,6 @@ import {
33
33
  type ResolvedPlugin,
34
34
  type ResolvedProfile,
35
35
  type ResolvedSkill,
36
- type SkillCondition,
37
36
  type SkillRef,
38
37
  SchemaViolation,
39
38
  } from "../../profiles/_types";
@@ -480,6 +479,7 @@ function foldChain(chain: Profile[]): ResolvedProfile {
480
479
  qualityGates: dedupePrimitiveArray(acc.qualityGates, child.qualityGates),
481
480
  evals: dedupePrimitiveArray(acc.evals, child.evals),
482
481
  recommends: dedupePrimitiveArray(acc.recommends, child.recommends),
482
+ autoSelect: dedupePrimitiveArray(acc.autoSelect, child.autoSelect),
483
483
  conflicts: dedupePrimitiveArray(acc.conflicts, child.conflicts),
484
484
  // bundles is a display hint, leaf-wins: a child that declares its own
485
485
  // list overrides the parent; a child that omits it inherits the parent's.
@@ -523,6 +523,7 @@ function normalizeToResolved(p: Profile, chain: string[]): ResolvedProfile {
523
523
  qualityGates: [...(p.qualityGates ?? [])],
524
524
  evals: [...(p.evals ?? [])],
525
525
  recommends: [...(p.recommends ?? [])],
526
+ autoSelect: [...(p.autoSelect ?? [])],
526
527
  conflicts: [...(p.conflicts ?? [])],
527
528
  bundles: p.bundles && p.bundles.length > 0 ? [...p.bundles] : undefined,
528
529
  personaRouting: [...(p.persona_routing ?? [])],
@@ -549,7 +550,19 @@ export function parseProfileSelector(selector: string): string[] {
549
550
  `Profile selector "${selector}" is empty after parsing`,
550
551
  );
551
552
  }
552
- return parts;
553
+ // Collapse duplicate parts, order preserved. A selector can accumulate
554
+ // repeats — a stale pin, a Recent/Featured row built from an already-duped
555
+ // selector, or a composite primary whose part is also offered as a companion.
556
+ // Loading the same profile twice bloats the materialized CLAUDE.md and the
557
+ // token count for no benefit (skills/mcps fold to a set anyway).
558
+ const seen = new Set<string>();
559
+ const unique: string[] = [];
560
+ for (const part of parts) {
561
+ if (seen.has(part)) continue;
562
+ seen.add(part);
563
+ unique.push(part);
564
+ }
565
+ return unique;
553
566
  }
554
567
 
555
568
  /** True when the selector names two or more profiles to merge. */
@@ -603,6 +616,7 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf
603
616
  qualityGates: [...head.qualityGates],
604
617
  evals: [...head.evals],
605
618
  recommends: [...head.recommends],
619
+ autoSelect: [...head.autoSelect],
606
620
  conflicts: [...head.conflicts],
607
621
  // persona_includes is additive across a composite too — policy snippets
608
622
  // (Integrity Protocol, voice rules) from every stacked profile survive.
@@ -639,6 +653,7 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf
639
653
  qualityGates: dedupePrimitiveArray(acc.qualityGates, next.qualityGates),
640
654
  evals: dedupePrimitiveArray(acc.evals, next.evals),
641
655
  recommends: dedupePrimitiveArray(acc.recommends, next.recommends),
656
+ autoSelect: dedupePrimitiveArray(acc.autoSelect, next.autoSelect),
642
657
  conflicts: dedupePrimitiveArray(acc.conflicts, next.conflicts),
643
658
  personaIncludes: dedupePrimitiveArray(acc.personaIncludes, next.personaIncludes),
644
659
  personaRouting: [...acc.personaRouting, ...next.personaRouting],
@@ -676,7 +691,11 @@ export async function loadProfile(name: string): Promise<ResolvedProfile> {
676
691
  const chain = await buildInheritanceChain(part);
677
692
  resolved.push(foldChain(chain));
678
693
  }
679
- return foldComposite(name, resolved);
694
+ // Name the composite from the deduped parts, not the raw selector: a duped
695
+ // selector ("a+b+a") loads correct (once-each) resources but must not name
696
+ // a redundant runtime dir / CLAUDE.md path — that splits the materializer
697
+ // cache and resurrects the duplicated-profile display bug downstream.
698
+ return foldComposite(parts.join("+"), resolved);
680
699
  }
681
700
 
682
701
  /**
@@ -20,10 +20,10 @@
20
20
  * `core+skill-writer` sessions count toward the same skill.
21
21
  */
22
22
 
23
- import { readFileSync, existsSync, statSync } from "node:fs";
24
- import { homedir } from "node:os";
23
+ import { readFileSync, existsSync, } from "node:fs";
25
24
  import { join, resolve, dirname } from "node:path";
26
25
  import { fileURLToPath } from "node:url";
26
+ import { configDir } from "./config-paths";
27
27
 
28
28
  const REPO_ROOT = process.env.CUE_REPO_ROOT ?? process.env.SOUL_REPO_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
29
29
  export const SKILLS_ROOT = join(REPO_ROOT, "resources", "skills", "skills");
@@ -32,10 +32,6 @@ export function estimateTokens(text: string): number {
32
32
  return Math.ceil(text.length / 4);
33
33
  }
34
34
 
35
- function configDir(): string {
36
- const xdg = process.env.XDG_CONFIG_HOME;
37
- return join(xdg && xdg.length > 0 ? xdg : join(homedir(), ".config"), "cue");
38
- }
39
35
 
40
36
  /** Split a SKILL.md into its YAML frontmatter and body. */
41
37
  function splitFrontmatter(text: string): { frontmatter: string; body: string } {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Guard: every profile name referenced by the auto-detect signals and the
3
+ * discover/ai keyword maps must correspond to a real profile directory under
4
+ * profiles/.
5
+ *
6
+ * Phantom names used to slip in and silently no-op: `launch.ts` filters
7
+ * detections down to known profile names (so a phantom suggestion is dropped
8
+ * with no feedback), and `cue discover install` skips writing skills into a
9
+ * profile.yaml that doesn't exist. This test fails fast if any suggestion ever
10
+ * points at a profile that isn't on disk — it caught `python-api`, `rust-cli`,
11
+ * `ecc`, and a bare `medusa` when it was written.
12
+ */
13
+ import { describe, expect, test } from "bun:test";
14
+ import { readdirSync, existsSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+
17
+ import { SIGNALS } from "./auto-detect";
18
+ import { STACK_PROFILES, PROFILE_KEYWORDS as DISCOVER_KEYWORDS } from "../commands/discover";
19
+ import { PROFILE_KEYWORDS as AI_KEYWORDS } from "../commands/ai";
20
+
21
+ const PROFILES_DIR = resolve(import.meta.dir, "../../profiles");
22
+
23
+ function realProfiles(): Set<string> {
24
+ return new Set(
25
+ readdirSync(PROFILES_DIR, { withFileTypes: true })
26
+ .filter((e) => e.isDirectory() && !e.name.startsWith("_"))
27
+ .map((e) => e.name)
28
+ .filter((name) => existsSync(join(PROFILES_DIR, name, "profile.yaml"))),
29
+ );
30
+ }
31
+
32
+ describe("profile-name references resolve to real profiles", () => {
33
+ const real = realProfiles();
34
+
35
+ test("profiles/ fixture is populated", () => {
36
+ expect(real.size).toBeGreaterThan(10);
37
+ });
38
+
39
+ test("auto-detect SIGNALS reference only real profiles", () => {
40
+ const missing = [...new Set(SIGNALS.map((s) => s.profile))].filter((p) => !real.has(p));
41
+ expect(missing).toEqual([]);
42
+ });
43
+
44
+ test("discover STACK_PROFILES reference only real profiles", () => {
45
+ const missing = [...STACK_PROFILES].filter((p) => !real.has(p));
46
+ expect(missing).toEqual([]);
47
+ });
48
+
49
+ test("discover PROFILE_KEYWORDS keys are real profiles", () => {
50
+ const missing = Object.keys(DISCOVER_KEYWORDS).filter((p) => !real.has(p));
51
+ expect(missing).toEqual([]);
52
+ });
53
+
54
+ test("ai PROFILE_KEYWORDS keys are real profiles", () => {
55
+ const missing = Object.keys(AI_KEYWORDS).filter((p) => !real.has(p));
56
+ expect(missing).toEqual([]);
57
+ });
58
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tests for the repo provenance matcher — the pure logic behind the studio
3
+ * Profiles "Repos" tab. No network: only `reposForProfile` (the catalog filter)
4
+ * is exercised; the live star fetch is integration-tested via the live endpoint.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ import { reposForProfile, REPO_CATALOG } from "./repos";
10
+
11
+ const slugs = (rs: { repo: string }[]) => rs.map((r) => r.repo).sort();
12
+
13
+ describe("reposForProfile", () => {
14
+ test("matches a repo by a namespace the profile has", () => {
15
+ const got = reposForProfile({ namespaces: ["meta"], mcpIds: [], pluginIds: [] });
16
+ expect(slugs(got)).toContain("opencue/cuecards");
17
+ });
18
+
19
+ test("matches a repo by an MCP id the profile connects", () => {
20
+ const got = reposForProfile({ namespaces: [], mcpIds: ["lightpanda"], pluginIds: [] });
21
+ expect(slugs(got)).toEqual(["lightpanda-io/browser"]);
22
+ });
23
+
24
+ test("matches a plugin by its bare name, ignoring the @marketplace suffix", () => {
25
+ const got = reposForProfile({ namespaces: [], mcpIds: [], pluginIds: ["claude-mem@thedotmack"] });
26
+ expect(slugs(got)).toEqual(["thedotmack/claude-mem"]);
27
+ });
28
+
29
+ test("npx skills map to the anthropics/skills bundle", () => {
30
+ const got = reposForProfile({ namespaces: ["npx"], mcpIds: [], pluginIds: [] });
31
+ expect(slugs(got)).toContain("anthropics/skills");
32
+ });
33
+
34
+ test("profile-specific MCPs/plugins map to their upstream repos", () => {
35
+ // The coolify MCP → the real Coolify repo (the user's pointed-at case).
36
+ expect(slugs(reposForProfile({ namespaces: [], mcpIds: ["coolify"], pluginIds: [] }))).toEqual(["coollabsio/coolify"]);
37
+ expect(slugs(reposForProfile({ namespaces: [], mcpIds: ["supabase"], pluginIds: [] }))).toEqual(["supabase/supabase"]);
38
+ expect(slugs(reposForProfile({ namespaces: [], mcpIds: [], pluginIds: ["vercel@claude-plugins-official"] }))).toEqual(["vercel/vercel"]);
39
+ });
40
+
41
+ test("returns nothing for a profile that contains none of the catalog's sources", () => {
42
+ const got = reposForProfile({ namespaces: ["nonexistent-ns"], mcpIds: ["nope"], pluginIds: ["nope@x"] });
43
+ expect(got).toHaveLength(0);
44
+ });
45
+
46
+ test("dedupes — a repo matched by both ns and mcp appears once", () => {
47
+ const got = reposForProfile({ namespaces: ["browser"], mcpIds: ["lightpanda"], pluginIds: [] });
48
+ expect(got.filter((r) => r.repo === "lightpanda-io/browser")).toHaveLength(1);
49
+ });
50
+
51
+ test("every catalog repo is a real owner/name slug (no spaces, exactly one slash)", () => {
52
+ for (const r of REPO_CATALOG) {
53
+ expect(r.repo).toMatch(/^[^/\s]+\/[^/\s]+$/);
54
+ expect(r.kinds.length).toBeGreaterThan(0);
55
+ }
56
+ });
57
+ });