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,167 @@
1
+ /**
2
+ * Source-repo provenance for the studio Profiles "Repos" tab — the GitHub
3
+ * repositories a profile's skills, MCPs, plugins and workflows originate from,
4
+ * with live star counts.
5
+ *
6
+ * cue has no machine-readable repo field per component, so the mapping is a
7
+ * curated catalog: each entry declares what it `provides` (namespaces / MCP ids
8
+ * / plugin names), and `reposForProfile` keeps only the entries a given profile
9
+ * actually contains. Every repo is a real GitHub repo so its star count is a
10
+ * live fetch (cached + fail-soft), satisfying the "auto-update the stars" ask.
11
+ */
12
+
13
+ export type RepoKind = "profile" | "skill" | "mcp" | "plugin" | "workflow" | "cli";
14
+
15
+ interface RepoProvides {
16
+ /** Skill namespaces (first path segment) this repo is the source of. */
17
+ ns?: string[];
18
+ /** MCP server ids this repo ships. */
19
+ mcps?: string[];
20
+ /** Plugin names (bare, no @marketplace) this repo ships. */
21
+ plugins?: string[];
22
+ }
23
+
24
+ export interface RepoCatalogEntry {
25
+ /** "owner/name" — the GitHub slug. */
26
+ repo: string;
27
+ desc: string;
28
+ provides: RepoProvides;
29
+ kinds: RepoKind[];
30
+ }
31
+
32
+ export interface RepoEntry {
33
+ repo: string;
34
+ url: string;
35
+ desc: string;
36
+ kinds: RepoKind[];
37
+ /** Live GitHub stargazer count, or null when it couldn't be fetched. */
38
+ stars: number | null;
39
+ }
40
+
41
+ /**
42
+ * Curated provenance catalog. Every `repo` is a real GitHub slug so the star
43
+ * fetch resolves; entries the active profile doesn't contain are filtered out.
44
+ */
45
+ export const REPO_CATALOG: RepoCatalogEntry[] = [
46
+ {
47
+ repo: "opencue/cuecards",
48
+ desc: "The cue runtime — profiles, the meta toolkit and smart-loader.",
49
+ provides: { ns: ["meta", "caveman", "plan", "review", "gstack", "github", "design"] },
50
+ kinds: ["profile", "skill"],
51
+ },
52
+ {
53
+ repo: "anthropics/skills",
54
+ desc: "npx skill bundle — pdf, docx and pptx authoring.",
55
+ provides: { ns: ["npx"] },
56
+ kinds: ["skill"],
57
+ },
58
+ {
59
+ repo: "lightpanda-io/browser",
60
+ desc: "Fast headless browser, exposed as an MCP server.",
61
+ provides: { ns: ["browser"], mcps: ["lightpanda"] },
62
+ kinds: ["mcp", "skill"],
63
+ },
64
+ {
65
+ repo: "upstash/context7",
66
+ desc: "Up-to-date, version-specific library docs as an MCP server.",
67
+ provides: { ns: ["tools"], mcps: ["context7"] },
68
+ kinds: ["mcp", "skill"],
69
+ },
70
+ {
71
+ repo: "thedotmack/claude-mem",
72
+ desc: "Persistent memory plugin — skills under the plugin/ namespace.",
73
+ provides: { plugins: ["claude-mem"] },
74
+ kinds: ["plugin"],
75
+ },
76
+ {
77
+ repo: "coollabsio/coolify",
78
+ desc: "Self-hosted deploy platform — the upstream behind cue's coolify MCP.",
79
+ provides: { mcps: ["coolify"] },
80
+ kinds: ["mcp", "skill"],
81
+ },
82
+ {
83
+ repo: "supabase/supabase",
84
+ desc: "Open-source Postgres backend — exposed as an MCP server.",
85
+ provides: { mcps: ["supabase"] },
86
+ kinds: ["mcp", "skill"],
87
+ },
88
+ {
89
+ repo: "vercel/vercel",
90
+ desc: "Deploy + host web apps — the vercel Claude Code plugin's upstream.",
91
+ provides: { plugins: ["vercel"] },
92
+ kinds: ["plugin"],
93
+ },
94
+ ];
95
+
96
+ /** Strip a plugin id's `@marketplace` suffix → its bare name. */
97
+ function pluginName(id: string): string {
98
+ const at = id.indexOf("@");
99
+ return at > 0 ? id.slice(0, at) : id;
100
+ }
101
+
102
+ /**
103
+ * Keep only catalog entries the profile actually contains: a repo matches when
104
+ * it provides a namespace the profile has, an MCP it connects, or a plugin it
105
+ * wires. Pure + exported so it's unit-testable without a network round-trip.
106
+ */
107
+ export function reposForProfile(opts: {
108
+ namespaces: Iterable<string>;
109
+ mcpIds: Iterable<string>;
110
+ pluginIds: Iterable<string>;
111
+ }): RepoCatalogEntry[] {
112
+ const ns = new Set(opts.namespaces);
113
+ const mcps = new Set(opts.mcpIds);
114
+ const plugins = new Set([...opts.pluginIds].map(pluginName));
115
+ return REPO_CATALOG.filter((r) => {
116
+ const p = r.provides;
117
+ if (p.ns?.some((n) => ns.has(n))) return true;
118
+ if (p.mcps?.some((m) => mcps.has(m))) return true;
119
+ if (p.plugins?.some((pl) => plugins.has(pl))) return true;
120
+ return false;
121
+ });
122
+ }
123
+
124
+ // ── live star counts ────────────────────────────────────────────────────────
125
+ // One GitHub API GET per repo, cached 6h, fail-soft. The unauthenticated rate
126
+ // limit (60/hr) is ample for a handful of repos behind the cache.
127
+
128
+ const STAR_TTL_MS = 6 * 60 * 60 * 1000;
129
+ const starCache = new Map<string, { ts: number; stars: number | null }>();
130
+
131
+ /** Fetch one repo's stargazer count, cached + fail-soft (null on any error). */
132
+ export async function fetchStars(repo: string, now: number): Promise<number | null> {
133
+ const cached = starCache.get(repo);
134
+ if (cached && now - cached.ts < STAR_TTL_MS) return cached.stars;
135
+ let stars: number | null = null;
136
+ try {
137
+ const res = await fetch(`https://api.github.com/repos/${repo}`, {
138
+ headers: { "User-Agent": "cue-studio", Accept: "application/vnd.github+json" },
139
+ signal: AbortSignal.timeout(3000),
140
+ });
141
+ if (res.ok) {
142
+ const body = (await res.json()) as { stargazers_count?: number };
143
+ if (typeof body.stargazers_count === "number") stars = body.stargazers_count;
144
+ }
145
+ } catch {
146
+ // offline / timeout / rate-limited → null this cycle. Keep any prior value
147
+ // so a transient failure doesn't blank a previously-known count.
148
+ if (cached) return cached.stars;
149
+ }
150
+ starCache.set(repo, { ts: now, stars });
151
+ return stars;
152
+ }
153
+
154
+ /** Resolve a matched catalog into display rows with live star counts. */
155
+ export async function resolveRepoStars(matched: RepoCatalogEntry[], now: number): Promise<RepoEntry[]> {
156
+ const settled = await Promise.allSettled(matched.map((r) => fetchStars(r.repo, now)));
157
+ return matched.map((r, i) => {
158
+ const s = settled[i];
159
+ return {
160
+ repo: r.repo,
161
+ url: `https://github.com/${r.repo}`,
162
+ desc: r.desc,
163
+ kinds: r.kinds,
164
+ stars: s && s.status === "fulfilled" ? s.value : null,
165
+ };
166
+ });
167
+ }
@@ -29,7 +29,6 @@ import { ProfileError } from "../../profiles/_types";
29
29
  import {
30
30
  cacheChildren,
31
31
  cacheHit,
32
- cachePath,
33
32
  cachePut,
34
33
  cacheSkillPath,
35
34
  type CacheLayout,
@@ -125,10 +124,20 @@ export const npxFetch: NpxFetchFn = async (repo, pin, skill, destDir) => {
125
124
  const ref = pin.replace(/^git@/, "").replace(/^tag@/, "");
126
125
  args.push("--ref", ref);
127
126
  }
127
+ // Defense-in-depth: a single wedged `npx skills add` must never hang the
128
+ // whole run. On timeout spawnSync sets res.error (ETIMEDOUT), which maps to
129
+ // NpxFetchFailed below. CUE_NPX_TIMEOUT_MS overrides; a non-positive or
130
+ // non-numeric value (incl. "" → 0, which would DISABLE the timeout) falls
131
+ // back to the 45s default rather than silently defeating the guard.
132
+ const envTimeout = Number(process.env.CUE_NPX_TIMEOUT_MS);
133
+ const npxTimeoutMs = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 45000;
128
134
  const res = spawnSync("npx", args, {
129
135
  cwd: destDir,
130
136
  stdio: ["ignore", "pipe", "pipe"],
131
137
  encoding: "utf8",
138
+ timeout: npxTimeoutMs,
139
+ killSignal: "SIGKILL",
140
+ windowsHide: true,
132
141
  });
133
142
  if (res.error) {
134
143
  throw new NpxFetchFailed(repo, res.error.message, res.error);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
- import { mkdtemp, mkdir, writeFile, readFile, stat, lstat, rm, readlink } from "node:fs/promises";
2
+ import { mkdtemp, mkdir, writeFile, readFile, stat, lstat, rm, readlink, symlink } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
@@ -96,10 +96,54 @@ describe("materializeRuntime", () => {
96
96
  mcpRegistry: {},
97
97
  userClaudeMd: "",
98
98
  });
99
- const link = await readlink(join(out.runtimeDir, "skills", "design", "ui-ux-pro-max"));
99
+ // Flat layout (skills/<slug>) so Claude Code's one-level discovery finds it.
100
+ const link = await readlink(join(out.runtimeDir, "skills", "ui-ux-pro-max"));
100
101
  expect(link).toBe("/fake/source/design/ui-ux-pro-max");
101
102
  });
102
103
 
104
+ test("writes a .cue-skills manifest of <category>/<slug> ids for smart-loader", async () => {
105
+ const out = await materializeRuntime({
106
+ profile: sampleProfile,
107
+ agent: "claude-code",
108
+ runtimeRoot: join(root, "runtime"),
109
+ skillSourceLookup: async (id) => `/fake/source/${id}`,
110
+ mcpRegistry: {},
111
+ userClaudeMd: "",
112
+ });
113
+ const manifest = await readFile(join(out.runtimeDir, ".cue-skills"), "utf8");
114
+ expect(manifest.split("\n").filter(Boolean)).toContain("design/ui-ux-pro-max");
115
+ });
116
+
117
+ test("slug collisions resolve last-wins; both ids stay in the manifest", async () => {
118
+ const collide: ResolvedProfile = {
119
+ ...sampleProfile,
120
+ skills: {
121
+ ...sampleProfile.skills,
122
+ local: [
123
+ { id: "plan/investigate" },
124
+ { id: "gstack/investigate" },
125
+ ],
126
+ },
127
+ };
128
+ const out = await materializeRuntime({
129
+ profile: collide,
130
+ agent: "claude-code",
131
+ runtimeRoot: join(root, "runtime"),
132
+ skillSourceLookup: async (id) => `/fake/source/${id}`,
133
+ mcpRegistry: {},
134
+ userClaudeMd: "",
135
+ });
136
+ // The later entry (gstack/investigate) wins the flat /investigate link.
137
+ const link = await readlink(join(out.runtimeDir, "skills", "investigate"));
138
+ expect(link).toBe("/fake/source/gstack/investigate");
139
+ // Both remain in the manifest so smart-loader knows the lean one is loaded too.
140
+ const manifest = (await readFile(join(out.runtimeDir, ".cue-skills"), "utf8"))
141
+ .split("\n")
142
+ .filter(Boolean);
143
+ expect(manifest).toContain("plan/investigate");
144
+ expect(manifest).toContain("gstack/investigate");
145
+ });
146
+
103
147
  test("excludes resources whose agents list does not include current agent", async () => {
104
148
  const filtered: ResolvedProfile = {
105
149
  ...sampleProfile,
@@ -238,7 +282,7 @@ describe("materializeRuntime", () => {
238
282
  test("credentialsSource: refreshes settings on cache hit + repoints symlinks on account switch", async () => {
239
283
  const credSrcA = join(root, "credsA");
240
284
  const credSrcB = join(root, "credsB");
241
- const { mkdir, writeFile, readlink } = await import("node:fs/promises");
285
+ const { mkdir, writeFile } = await import("node:fs/promises");
242
286
  await mkdir(credSrcA, { recursive: true });
243
287
  await mkdir(credSrcB, { recursive: true });
244
288
  await writeFile(join(credSrcA, ".credentials.json"), '{"token":"A"}');
@@ -280,6 +324,78 @@ describe("materializeRuntime", () => {
280
324
  expect(s2.permissions.allow).toEqual(["B"]);
281
325
  });
282
326
 
327
+ test("credentialsSource: rebuild keeps the freshest token (no logged-out-after-relaunch)", async () => {
328
+ // Regression: Anthropic rotates the refresh token on every refresh. The old
329
+ // preserve step blindly resurrected the runtime's own .credentials.json on a
330
+ // rebuild — even when it held a dead, rotated token while the freshly-synced
331
+ // source had the live one — booting the relaunched profile into a logged-out
332
+ // state. The preserve step must keep whichever token has the higher expiresAt.
333
+ const stale = join(root, "src-stale");
334
+ const fresh = join(root, "src-fresh");
335
+ await mkdir(stale, { recursive: true });
336
+ await mkdir(fresh, { recursive: true });
337
+ const STALE = 1_000;
338
+ const FRESH = 9_000_000_000_000;
339
+ await writeFile(join(stale, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: STALE, refreshToken: "dead" } }));
340
+ await writeFile(join(fresh, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: FRESH, refreshToken: "live" } }));
341
+
342
+ const base = {
343
+ agent: "claude-code" as const,
344
+ runtimeRoot: join(root, "runtime"),
345
+ skillSourceLookup: async (id: string) => `/fake/source/${id}`,
346
+ mcpRegistry: { "claude-mem": { command: "claude-mem" } },
347
+ userClaudeMd: "",
348
+ };
349
+ const p1: ResolvedProfile = { ...sampleProfile, name: "rotate" };
350
+ // Extra skill → different hash → forces a REBUILD (exercises the preserve step).
351
+ const p2: ResolvedProfile = { ...sampleProfile, name: "rotate", skills: { local: [{ id: "design/ui-ux-pro-max" }, { id: "design/extra" }], npx: [] } };
352
+
353
+ // First launch: runtime ends up with the (then-current) STALE source token.
354
+ const first = await materializeRuntime({ ...base, profile: p1, credentialsSource: stale });
355
+ expect(first.rebuilt).toBe(true);
356
+
357
+ // syncFreshestToSource has since healed source to the live token. Relaunch
358
+ // with a changed profile → rebuild path runs the preserve step.
359
+ const second = await materializeRuntime({ ...base, profile: p2, credentialsSource: fresh });
360
+ expect(second.rebuilt).toBe(true);
361
+
362
+ const creds = JSON.parse(await readFile(join(second.runtimeDir, ".credentials.json"), "utf8"));
363
+ expect(creds.claudeAiOauth.expiresAt).toBe(FRESH);
364
+ expect(creds.claudeAiOauth.refreshToken).toBe("live");
365
+ });
366
+
367
+ test("credentialsSource: rebuild keeps the runtime token when source is half-logged-out", async () => {
368
+ // Inverse guard: if SOURCE is stale/half-logged-out (no/low expiresAt) but the
369
+ // runtime is logged in (fresh token), a rebuild must NOT clobber the live
370
+ // runtime token with the dead source one. Preserves the original intent.
371
+ const fresh = join(root, "src-fresh");
372
+ const loggedOut = join(root, "src-loggedout");
373
+ await mkdir(fresh, { recursive: true });
374
+ await mkdir(loggedOut, { recursive: true });
375
+ const FRESH = 9_000_000_000_000;
376
+ await writeFile(join(fresh, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: FRESH, refreshToken: "live" } }));
377
+ await writeFile(join(loggedOut, ".credentials.json"), JSON.stringify({ claudeAiOauth: { refreshToken: "" } }));
378
+
379
+ const base = {
380
+ agent: "claude-code" as const,
381
+ runtimeRoot: join(root, "runtime"),
382
+ skillSourceLookup: async (id: string) => `/fake/source/${id}`,
383
+ mcpRegistry: { "claude-mem": { command: "claude-mem" } },
384
+ userClaudeMd: "",
385
+ };
386
+ const p1: ResolvedProfile = { ...sampleProfile, name: "rotate2" };
387
+ const p2: ResolvedProfile = { ...sampleProfile, name: "rotate2", skills: { local: [{ id: "design/ui-ux-pro-max" }, { id: "design/extra" }], npx: [] } };
388
+
389
+ const first = await materializeRuntime({ ...base, profile: p1, credentialsSource: fresh });
390
+ expect(first.rebuilt).toBe(true);
391
+ const second = await materializeRuntime({ ...base, profile: p2, credentialsSource: loggedOut });
392
+ expect(second.rebuilt).toBe(true);
393
+
394
+ const creds = JSON.parse(await readFile(join(second.runtimeDir, ".credentials.json"), "utf8"));
395
+ expect(creds.claudeAiOauth.expiresAt).toBe(FRESH);
396
+ expect(creds.claudeAiOauth.refreshToken).toBe("live");
397
+ });
398
+
283
399
  test("CLAUDE.md stamp uses real ISO timestamp, not literal $(date)", async () => {
284
400
  const out = await materializeRuntime({
285
401
  profile: sampleProfile,
@@ -421,6 +537,31 @@ describe("materializeRuntime", () => {
421
537
  expect(link).toContain("resources/hooks/bash-quality-preflight.json");
422
538
  });
423
539
 
540
+ test("hooks: auto-review Stop hook + its .sh companion both land in the runtime", async () => {
541
+ const profile: ResolvedProfile = {
542
+ ...sampleProfile,
543
+ name: "test-auto-review",
544
+ inheritanceChain: ["test-auto-review"],
545
+ rules: [], commands: [],
546
+ hooks: ["auto-review.json"],
547
+ };
548
+ const out = await materializeRuntime({
549
+ profile, agent: "claude-code",
550
+ runtimeRoot: join(root, "runtime"),
551
+ skillSourceLookup: async (id) => `/fake/source/${id}`,
552
+ mcpRegistry: {},
553
+ userClaudeMd: "",
554
+ });
555
+ const settings = JSON.parse(await readFile(join(out.runtimeDir, "settings.json"), "utf8"));
556
+ expect(settings.hooks.Stop).toBeArray();
557
+ expect(settings.hooks.Stop[0].hooks[0].id).toBe("cue:stop:auto-review");
558
+ expect(settings.hooks.Stop[0].hooks[0].command).toContain("auto-review.sh");
559
+ // The reviewer script companion must be symlinked too, else the hook fires
560
+ // `bash $CLAUDE_CONFIG_DIR/hooks/auto-review.sh` against a missing file.
561
+ const script = await readlink(join(out.runtimeDir, "hooks", "auto-review.sh"));
562
+ expect(script).toContain("resources/hooks/auto-review.sh");
563
+ });
564
+
424
565
  // Claude Code reads MCP servers from .claude.json (top-level `mcpServers`),
425
566
  // NOT from settings.json. The materializer must therefore merge profile MCPs
426
567
  // into .claude.json — and copy (not symlink) it so mutations don't leak back
@@ -675,6 +816,62 @@ describe("isRuntimeStale", () => {
675
816
  await writeFile(join(profilesRoot, "p3", "profile.yaml"), "name: x\n");
676
817
  expect(await isRuntimeStale("p3", "claude-code", runtimeRoot)).toBe(false);
677
818
  });
819
+
820
+ async function writeRuntimeSkill(name: string, runtimeRoot: string, slug: string): Promise<string> {
821
+ const skillDir = join(runtimeRoot, name, "claude", "skills", slug);
822
+ await mkdir(skillDir, { recursive: true });
823
+ const md = join(skillDir, "SKILL.md");
824
+ await writeFile(md, `# ${slug}\n`);
825
+ return md;
826
+ }
827
+
828
+ test("returns true when a resolved SKILL.md is newer than .cue-hash (yaml older)", async () => {
829
+ const runtimeRoot = join(root, "runtime");
830
+ const { yamlPath, hashPath } = await setup("p4", runtimeRoot);
831
+ const mdPath = await writeRuntimeSkill("p4", runtimeRoot, "alpha");
832
+ await utimes(yamlPath, new Date(Date.now() - 120_000), new Date(Date.now() - 120_000));
833
+ await utimes(hashPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
834
+ await utimes(mdPath, new Date(), new Date());
835
+ expect(await isRuntimeStale("p4", "claude-code", runtimeRoot)).toBe(true);
836
+ });
837
+
838
+ test("returns false when every SKILL.md is older than .cue-hash", async () => {
839
+ const runtimeRoot = join(root, "runtime");
840
+ const { yamlPath, hashPath } = await setup("p5", runtimeRoot);
841
+ const mdPath = await writeRuntimeSkill("p5", runtimeRoot, "alpha");
842
+ const old = new Date(Date.now() - 60_000);
843
+ await utimes(yamlPath, old, old);
844
+ await utimes(mdPath, old, old);
845
+ await utimes(hashPath, new Date(), new Date());
846
+ expect(await isRuntimeStale("p5", "claude-code", runtimeRoot)).toBe(false);
847
+ });
848
+
849
+ test("skips a slug dir with no SKILL.md (broken symlink is non-fatal)", async () => {
850
+ const runtimeRoot = join(root, "runtime");
851
+ const { yamlPath, hashPath } = await setup("p6", runtimeRoot);
852
+ await mkdir(join(runtimeRoot, "p6", "claude", "skills", "broken"), { recursive: true });
853
+ await utimes(yamlPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
854
+ await utimes(hashPath, new Date(), new Date());
855
+ expect(await isRuntimeStale("p6", "claude-code", runtimeRoot)).toBe(false);
856
+ });
857
+
858
+ test("detects a newer SKILL.md through a symlinked skill dir (production layout)", async () => {
859
+ const runtimeRoot = join(root, "runtime");
860
+ const { yamlPath, hashPath } = await setup("p7", runtimeRoot);
861
+ // Production materialize symlinks skills/<slug> → the source skill dir; the
862
+ // SKILL.md inside is a real file, so lstat resolves through to its mtime.
863
+ const src = join(profilesRoot, "src-skill-p7");
864
+ await mkdir(src, { recursive: true });
865
+ const srcMd = join(src, "SKILL.md");
866
+ await writeFile(srcMd, "# s\n");
867
+ const skillsDir = join(runtimeRoot, "p7", "claude", "skills");
868
+ await mkdir(skillsDir, { recursive: true });
869
+ await symlink(src, join(skillsDir, "s"));
870
+ await utimes(yamlPath, new Date(Date.now() - 120_000), new Date(Date.now() - 120_000));
871
+ await utimes(hashPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
872
+ await utimes(srcMd, new Date(), new Date());
873
+ expect(await isRuntimeStale("p7", "claude-code", runtimeRoot)).toBe(true);
874
+ });
678
875
  });
679
876
 
680
877
  describe("linkPluginCache", () => {
@@ -74,25 +74,58 @@ function profilesDir(): string {
74
74
 
75
75
  /**
76
76
  * Staleness predicate shared with `cue doctor`'s D5 check: a materialized
77
- * runtime is stale when the profile's source `profile.yaml` was modified more
78
- * recently than the stored `.cue-hash`. Mirror, not duplicate — doctor reports
79
- * it, launch acts on it (auto-rebuild). Returns false when either file is
80
- * absent (no runtime yet, or no source to compare against): the normal
81
- * content-hash path in materializeRuntime handles those cases.
77
+ * runtime is stale when the profile's source `profile.yaml` OR any resolved
78
+ * skill's `SKILL.md` was modified more recently than the stored `.cue-hash`.
79
+ * Mirror, not duplicate — doctor reports it, launch acts on it (auto-rebuild).
80
+ *
81
+ * The SKILL.md check makes "edit a skill → relaunch → see the change" work:
82
+ * the materialized runtime's own `skills/<slug>` entries are symlinks to the
83
+ * source skill dirs, so lstat'ing `skills/<slug>/SKILL.md` resolves through to
84
+ * the real source file's mtime (only the final path component is treated
85
+ * specially by lstat, and SKILL.md is never itself a symlink). This is
86
+ * automatically scoped to the agent and to any conditional/subset pruning.
87
+ *
88
+ * Returns false when there's no runtime yet (no `.cue-hash`): the content-hash
89
+ * path in materializeRuntime handles a fresh build. Fail-open per entry — a
90
+ * deleted skill source (broken symlink) is skipped, not fatal (the profile.yaml
91
+ * edit that removed it already trips the yaml branch).
82
92
  */
83
93
  export async function isRuntimeStale(
84
94
  profileName: string,
85
95
  agent: AgentKind,
86
96
  runtimeRoot: string,
87
97
  ): Promise<boolean> {
88
- const hashFile = join(runtimeRoot, profileName, agentSubdir(agent), ".cue-hash");
89
- const yamlPath = join(profilesDir(), profileName, "profile.yaml");
98
+ const runtimeDir = join(runtimeRoot, profileName, agentSubdir(agent));
99
+ const hashFile = join(runtimeDir, ".cue-hash");
100
+ let hashMtime: number;
90
101
  try {
91
- const [hashStat, yamlStat] = await Promise.all([lstat(hashFile), lstat(yamlPath)]);
92
- return yamlStat.mtimeMs > hashStat.mtimeMs;
102
+ hashMtime = (await lstat(hashFile)).mtimeMs;
93
103
  } catch {
94
- return false;
104
+ return false; // no runtime yet — materializeRuntime's content hash handles it
95
105
  }
106
+
107
+ // (a) Source profile.yaml newer than the hash. Its own try/catch so a missing
108
+ // yaml doesn't short-circuit the skill check below.
109
+ try {
110
+ if ((await lstat(join(profilesDir(), profileName, "profile.yaml"))).mtimeMs > hashMtime) {
111
+ return true;
112
+ }
113
+ } catch { /* no source yaml — fall through to the skill check */ }
114
+
115
+ // (b) Any resolved SKILL.md newer than the hash.
116
+ const skillsDir = join(runtimeDir, "skills");
117
+ let slugs: string[];
118
+ try {
119
+ slugs = await readdir(skillsDir);
120
+ } catch {
121
+ return false; // no skills/ dir → nothing more to compare
122
+ }
123
+ for (const slug of slugs) {
124
+ try {
125
+ if ((await lstat(join(skillsDir, slug, "SKILL.md"))).mtimeMs > hashMtime) return true;
126
+ } catch { /* broken symlink / no SKILL.md under this slug — skip */ }
127
+ }
128
+ return false;
96
129
  }
97
130
 
98
131
  function appliesToAgent(scoped: { agents?: AgentKind[] }, agent: AgentKind): boolean {
@@ -108,8 +141,15 @@ function sortedJson(value: unknown): string {
108
141
  return "{" + keys.map((k) => JSON.stringify(k) + ":" + sortedJson(obj[k])).join(",") + "}";
109
142
  }
110
143
 
144
+ // Bump when the on-disk runtime layout changes in a way the profile content
145
+ // doesn't capture (e.g. flat vs nested skills, new manifest files). Folding it
146
+ // into the hash forces every profile to rebuild once on its next launch, so
147
+ // layout fixes roll out without a manual `--rematerialize` per profile.
148
+ // v2: flat skill layout + .cue-skills manifest (was nested <category>/<slug>)
149
+ const MATERIALIZER_VERSION = 2;
150
+
111
151
  function computeHash(profile: ResolvedProfile, agent: AgentKind): string {
112
- const canonical = sortedJson({ agent, profile });
152
+ const canonical = sortedJson({ v: MATERIALIZER_VERSION, agent, profile });
113
153
  return createHash("sha256").update(canonical).digest("hex");
114
154
  }
115
155
 
@@ -175,19 +215,58 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
175
215
  await mkdir(skillsDir, { recursive: true });
176
216
  const skippedSkills: string[] = [];
177
217
  let attemptedSkills = 0;
218
+ // Skills are linked FLAT — skills/<slug>, not skills/<category>/<slug>.
219
+ // Claude Code (and Codex) only register a personal skill one level deep, by
220
+ // its directory name (skills/<name>/SKILL.md → /<name>); a nested category
221
+ // dir is invisible to that scan. Flattening matches `activate-profile.sh`'s
222
+ // manual installer and lets every profile skill be invoked via the Skill
223
+ // tool — including the slug==category cases (caveman/caveman, github/github,
224
+ // colony/colony) that a nested layout can't expose. smart-loader's dedup no
225
+ // longer reads the dir tree; it reads the `.cue-skills` manifest written
226
+ // below, which preserves the <category>/<slug> identity.
227
+ const loadedSkillIds: string[] = [];
228
+ // Resolve first, link second, so slug collisions resolve by LAST-WINS: when
229
+ // two skills share a slug (e.g. plan/investigate lean vs gstack/investigate
230
+ // full), the later entry wins the flat /<slug> name. Skill lists merge
231
+ // parent→child (core first, profile last), so last-wins = the more-specific
232
+ // profile's choice overrides the inherited one — the standard override rule.
233
+ // The loser is still in the manifest, so smart-loader can surface it.
234
+ const slugToSrc = new Map<string, string>();
235
+ const slugToId = new Map<string, string>();
236
+ const overridden: string[] = [];
178
237
  for (const skill of profile.skills.local) {
179
238
  if (!appliesToAgent(skill, agent)) continue;
180
239
  if (skill.when && !evaluateCondition(skill.when, process.cwd())) continue;
181
240
  attemptedSkills++;
182
241
  try {
183
242
  const src = await input.skillSourceLookup(skill.id);
184
- const target = join(skillsDir, skill.id);
185
- await mkdir(dirname(target), { recursive: true });
186
- await symlink(src, target);
243
+ loadedSkillIds.push(skill.id);
244
+ const slug = basename(skill.id);
245
+ const prevId = slugToId.get(slug);
246
+ if (prevId !== undefined && prevId !== skill.id) {
247
+ overridden.push(`${slug}: ${prevId} → ${skill.id}`);
248
+ }
249
+ slugToSrc.set(slug, src);
250
+ slugToId.set(slug, skill.id);
187
251
  } catch (err) {
188
252
  skippedSkills.push(skill.id);
189
253
  }
190
254
  }
255
+ for (const [slug, src] of slugToSrc) {
256
+ await symlink(src, join(skillsDir, slug));
257
+ }
258
+ if (overridden.length > 0) {
259
+ process.stderr.write(
260
+ `[cue] ${overridden.length} skill slug collision(s) resolved last-wins ` +
261
+ `(loser still smart-loadable): ${overridden.join("; ")}\n`,
262
+ );
263
+ }
264
+ // Manifest for smart-loader's --exclude-loaded: the resolved <category>/<slug>
265
+ // ids, decoupled from the (now flat) on-disk layout.
266
+ await writeFile(
267
+ join(tmpDir, ".cue-skills"),
268
+ loadedSkillIds.length > 0 ? `${loadedSkillIds.join("\n")}\n` : "",
269
+ );
191
270
  if (skippedSkills.length > 0) {
192
271
  process.stderr.write(
193
272
  `[cue] skipped ${skippedSkills.length} missing skill(s): ${skippedSkills.slice(0, 5).join(", ")}` +
@@ -643,12 +722,26 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
643
722
  const newPath = join(tmpDir, name);
644
723
  try {
645
724
  const st = await lstat(oldPath);
646
- if (st.isFile() || st.isDirectory()) {
647
- // Remove whatever overlay put here (likely a symlink for .claude.json
648
- // or a copy for .credentials.json) so rename can replace it cleanly.
649
- await rm(newPath, { force: true, recursive: true });
650
- await rename(oldPath, newPath);
725
+ if (!(st.isFile() || st.isDirectory())) continue;
726
+ if (name === ".credentials.json") {
727
+ // Freshness guard fixes "logged-out after relaunch". Anthropic rotates
728
+ // the refresh token on every refresh, so only the copy with the highest
729
+ // expiresAt still holds a live refresh token. Step 5's overlay already
730
+ // placed the freshly-synced SOURCE creds in tmpDir (and
731
+ // resolveClaudeCredentialsSource healed source from the freshest sibling
732
+ // runtime first). Resurrect the OLD runtime's creds ONLY when they are
733
+ // strictly newer than source — otherwise keep source, so a rebuild can't
734
+ // drag a dead, rotated token back into the runtime. When source is
735
+ // half-logged-out its expiresAt is 0/old, so a logged-in runtime still
736
+ // wins and stays logged in (the original intent of this preserve step).
737
+ const oldExp = await credentialsExpiresAt(oldPath);
738
+ const newExp = await credentialsExpiresAt(newPath);
739
+ if (oldExp <= newExp) continue; // source as-fresh-or-fresher → keep it
651
740
  }
741
+ // Remove whatever overlay put here (likely a symlink for .claude.json
742
+ // or a copy for .credentials.json) so rename can replace it cleanly.
743
+ await rm(newPath, { force: true, recursive: true });
744
+ await rename(oldPath, newPath);
652
745
  } catch { /* doesn't exist — skip */ }
653
746
  }
654
747
  await rm(runtimeDir, { recursive: true, force: true });
@@ -661,6 +754,22 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
661
754
  return { runtimeDir, rebuilt: true, hash };
662
755
  }
663
756
 
757
+ /**
758
+ * Read `claudeAiOauth.expiresAt` (ms epoch) from a `.credentials.json`. Returns
759
+ * 0 when the file is missing, unparseable, or carries no expiry — so anything
760
+ * with a real token sorts as fresher in the rebuild preserve comparison.
761
+ */
762
+ async function credentialsExpiresAt(path: string): Promise<number> {
763
+ try {
764
+ const raw = await readFile(path, "utf8");
765
+ const parsed = JSON.parse(raw) as { claudeAiOauth?: { expiresAt?: number } };
766
+ const exp = parsed?.claudeAiOauth?.expiresAt;
767
+ return typeof exp === "number" ? exp : 0;
768
+ } catch {
769
+ return 0;
770
+ }
771
+ }
772
+
664
773
  function collectProfileMcps(
665
774
  profile: ResolvedProfile,
666
775
  agent: AgentKind,
@@ -970,7 +1079,7 @@ function tomlRender(obj: { mcp_servers: Record<string, unknown> }): string {
970
1079
  // ---------------------------------------------------------------------------
971
1080
 
972
1081
  import { homedir } from "node:os";
973
- import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
1082
+ import { readdirSync, existsSync, statSync } from "node:fs";
974
1083
  import { spawnSync } from "node:child_process";
975
1084
 
976
1085
  async function getLastSessionSummary(profileName: string): Promise<string | null> {
@@ -16,6 +16,7 @@
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
18
  import { dirname, join } from "node:path";
19
+ import { cacheDir } from "./config-paths";
19
20
 
20
21
  export interface SharedRef {
21
22
  /** Originating GitHub user / org. */
@@ -279,9 +280,7 @@ export function registryIndexUrl(): string {
279
280
 
280
281
  /** Path of the cached index.json on disk. */
281
282
  export function indexCachePath(): string {
282
- const xdg = process.env.XDG_CACHE_HOME;
283
- const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".cache");
284
- return join(base, "cue", "registry-index.json");
283
+ return join(cacheDir(), "registry-index.json");
285
284
  }
286
285
 
287
286
  export interface IndexCacheEntry {