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
@@ -11,11 +11,16 @@
11
11
  * public interface would be a privacy footgun.
12
12
  */
13
13
 
14
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
16
+ import { readFile, stat as statAsync } from "node:fs/promises";
15
17
  import { homedir } from "node:os";
16
- import { join, resolve } from "node:path";
18
+ import { join, resolve, sep } from "node:path";
17
19
 
18
- import { computeStats } from "./analytics";
20
+ import { configDir } from "./config-paths";
21
+ import { computeStats, computeDailyActivity, sessionDurationSummary } from "./analytics";
22
+ import { discoverInstalledPlugins } from "./plugin-discovery";
23
+ import { listWorkflows, loadWorkflow, saveWorkflow } from "./workflow-store";
19
24
  import { listActiveSessions, supportsProcScan } from "./active-sessions";
20
25
  import { readGateStatus, readAllGateStatus } from "./gate-status";
21
26
  import { computeAffinityMap, suggestionsByProfile } from "./pair-suggestions";
@@ -31,7 +36,11 @@ import {
31
36
  type MergeMode,
32
37
  } from "./profile-merge";
33
38
  import { validateProfileName } from "./profile-generator";
34
- import { parseSkillFromDir } from "./skill-router";
39
+ import { loadMcpCatalog, addMcpToProfile } from "./mcp-catalog";
40
+ import { aggregateProfileClis, type ProfileCli } from "./skill-clis";
41
+ import { collectPermissions } from "./permissions";
42
+ import { reposForProfile, resolveRepoStars } from "./repos";
43
+ import { parseSkillFromContent, parseSkillFromDir } from "./skill-router";
35
44
  import { resolveLocalSkill } from "./resolver-local";
36
45
  import { resolveProfileForCwd } from "./cwd-resolver";
37
46
  import { quickDiagnose } from "../commands/status";
@@ -44,11 +53,6 @@ const WEB_DIST = join(REPO_ROOT, "web", "dist");
44
53
  /** Standard envelope so the UI doesn't have to special-case per-endpoint shape. */
45
54
  export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
46
55
 
47
- function configDir(): string {
48
- return process.env.XDG_CONFIG_HOME
49
- ? join(process.env.XDG_CONFIG_HOME, "cue")
50
- : join(homedir(), ".config", "cue");
51
- }
52
56
 
53
57
  /**
54
58
  * Resolve a `?profile=...` query against precedence: explicit → cwd pin →
@@ -161,6 +165,7 @@ export async function handleStatus(): Promise<ApiResult<unknown>> {
161
165
  : null,
162
166
  totalProfiles: (await listProfiles()).length,
163
167
  totalSessions: stats.reduce((a, s) => a + s.sessions, 0),
168
+ durations: sessionDurationSummary(),
164
169
  telemetryEnabled: telemetryEnabled(),
165
170
  },
166
171
  };
@@ -185,6 +190,96 @@ function mergeProfilesDir(): string {
185
190
  return process.env.CUE_PROFILES_DIR ?? join(REPO_ROOT, "profiles");
186
191
  }
187
192
 
193
+ /**
194
+ * Serve a profile's logo image (`profiles/<name>/<iconImage>`) as raw bytes.
195
+ * Read-only and path-traversal-safe: the profile name is restricted to a
196
+ * single dir segment, the iconImage must be a bare filename, and the resolved
197
+ * path must stay inside the profiles dir. 404 when the profile has no logo.
198
+ */
199
+ async function serveProfileIcon(rawName: string | null): Promise<Response> {
200
+ if (!rawName) return new Response("missing profile", { status: 400 });
201
+ // Composite selectors (a+b) → use the first part's logo.
202
+ const name = rawName.split("+")[0]!.trim();
203
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name.includes("..")) {
204
+ return new Response("bad profile", { status: 400 });
205
+ }
206
+ let iconImage: string | undefined;
207
+ try {
208
+ iconImage = (await loadProfile(name)).iconImage;
209
+ } catch {
210
+ return new Response("not found", { status: 404 });
211
+ }
212
+ if (!iconImage || iconImage.includes("/") || iconImage.includes("\\") || iconImage.includes("..")) {
213
+ return new Response("no icon", { status: 404 });
214
+ }
215
+ const dir = mergeProfilesDir();
216
+ const file = resolve(join(dir, name, iconImage));
217
+ if (!file.startsWith(resolve(dir))) return new Response("forbidden", { status: 403 });
218
+ if (!existsSync(file) || !statSync(file).isFile()) return new Response("not found", { status: 404 });
219
+ return new Response(readFileSync(file), {
220
+ headers: { "Content-Type": contentTypeFor(file), "Cache-Control": "max-age=3600" },
221
+ });
222
+ }
223
+
224
+ /** Directory holding generated plugin logos, keyed by `<plugin-name>.png`. */
225
+ function pluginLogosDir(): string {
226
+ return process.env.CUE_PLUGIN_LOGOS_DIR ?? join(REPO_ROOT, "resources", "plugin-logos");
227
+ }
228
+
229
+ /** Directory holding playbook markdown docs, keyed by `<slug>.md`. */
230
+ function playbooksDir(): string {
231
+ return process.env.CUE_PLAYBOOKS_DIR ?? join(REPO_ROOT, "resources", "playbooks");
232
+ }
233
+
234
+ /** Directory holding slash-command markdown docs, keyed by `<ref>.md`. */
235
+ function commandsDir(): string {
236
+ return process.env.CUE_COMMANDS_DIR ?? join(REPO_ROOT, "resources", "commands");
237
+ }
238
+
239
+ /**
240
+ * Serve a plugin's logo image as raw bytes. Two sources, tried in order:
241
+ * 1. Reuse — a cue profile sharing the plugin's bare name that ships its own
242
+ * logo (the `resend` / `vercel` / `stripe` plugins map straight onto
243
+ * profiles/<name>/<iconImage>).
244
+ * 2. Generated — a PNG under the plugin-logos dir keyed by the plugin name.
245
+ * Path-traversal-safe exactly like serveProfileIcon. 404 when neither exists.
246
+ */
247
+ async function servePluginIcon(rawId: string | null): Promise<Response> {
248
+ if (!rawId) return new Response("missing plugin", { status: 400 });
249
+ // Plugin ids are "name@marketplace" — the logo keys off the bare name.
250
+ const name = rawId.split("@")[0]!.trim();
251
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name.includes("..")) {
252
+ return new Response("bad plugin", { status: 400 });
253
+ }
254
+
255
+ // 1. Reuse a same-named profile's logo when it ships one.
256
+ try {
257
+ const iconImage = (await loadProfile(name)).iconImage;
258
+ if (iconImage && !iconImage.includes("/") && !iconImage.includes("\\") && !iconImage.includes("..")) {
259
+ const pdir = mergeProfilesDir();
260
+ const pfile = resolve(join(pdir, name, iconImage));
261
+ if (pfile.startsWith(resolve(pdir)) && existsSync(pfile) && statSync(pfile).isFile()) {
262
+ return new Response(readFileSync(pfile), {
263
+ headers: { "Content-Type": contentTypeFor(pfile), "Cache-Control": "max-age=3600" },
264
+ });
265
+ }
266
+ }
267
+ } catch {
268
+ // No same-named profile — fall through to generated art.
269
+ }
270
+
271
+ // 2. Generated logo under the plugin-logos dir.
272
+ const gdir = pluginLogosDir();
273
+ const gfile = resolve(join(gdir, name + ".png"));
274
+ if (!gfile.startsWith(resolve(gdir))) return new Response("forbidden", { status: 403 });
275
+ if (existsSync(gfile) && statSync(gfile).isFile()) {
276
+ return new Response(readFileSync(gfile), {
277
+ headers: { "Content-Type": "image/png", "Cache-Control": "max-age=3600" },
278
+ });
279
+ }
280
+ return new Response("not found", { status: 404 });
281
+ }
282
+
188
283
  /**
189
284
  * Full profile inventory for the Merge Studio source list: every profile's
190
285
  * resolved skill/MCP/plugin counts plus its `bundles`/`conflicts` hints.
@@ -200,24 +295,620 @@ export async function handleProfilesFull(): Promise<ApiResult<unknown>> {
200
295
  return {
201
296
  name,
202
297
  icon: p.icon ?? null,
298
+ // Filename (relative to the profile dir) of a real logo, when set —
299
+ // served by GET /api/v1/profile-icon?profile=<name>. null = emoji only.
300
+ // iconImage is inheritable, so a base like core would otherwise leak
301
+ // its logo to every child; only report it when the file actually
302
+ // exists in THIS profile's own dir (also hides dangling refs).
303
+ iconImage:
304
+ p.iconImage && existsSync(join(mergeProfilesDir(), name, p.iconImage))
305
+ ? p.iconImage
306
+ : null,
203
307
  description: p.description,
204
308
  skills: p.skills.local.length,
205
309
  npx: p.skills.npx.length,
206
310
  mcps: p.mcps.length,
207
311
  plugins: p.plugins.length,
312
+ subagents: p.subagents?.length ?? 0,
208
313
  bundles: p.bundles ?? [],
209
314
  conflicts: p.conflicts ?? [],
210
315
  inheritsCore: p.inheritanceChain.includes("core"),
211
316
  error: null as string | null,
212
317
  };
213
318
  } catch (err) {
214
- return { name, error: (err as Error).message };
319
+ // Degraded row: keep the full ProfileRow shape so consumers can rely
320
+ // on array fields (e.g. conflicts.some(...)) without guarding.
321
+ return {
322
+ name,
323
+ icon: null,
324
+ iconImage: null as string | null,
325
+ description: "",
326
+ skills: 0,
327
+ npx: 0,
328
+ mcps: 0,
329
+ plugins: 0,
330
+ subagents: 0,
331
+ bundles: [] as string[],
332
+ conflicts: [] as string[],
333
+ inheritsCore: false,
334
+ error: (err as Error).message,
335
+ };
215
336
  }
216
337
  }),
217
338
  );
218
339
  return { ok: true, data: rows };
219
340
  }
220
341
 
342
+ // ---------------------------------------------------------------------------
343
+ // Profile detail — the explorer/search/mcps data source for cue studio.
344
+ //
345
+ // Returns a profile's full skill catalogue grouped by namespace, each skill's
346
+ // SKILL.md body + byte size + connected-MCP hints, plus the profile's MCP,
347
+ // plugin, and command refs. Reuses the same loader + resolver + parser the CLI
348
+ // uses so the studio reads real on-disk data, never a mock.
349
+ //
350
+ // Reading 50+ SKILL.md files per request is the heaviest read on the server,
351
+ // so the result is cached briefly (keyed by profile selector).
352
+ // ---------------------------------------------------------------------------
353
+
354
+ interface StudioSkill {
355
+ id: string;
356
+ ns: string;
357
+ name: string;
358
+ desc: string;
359
+ sizeK: number;
360
+ body: string;
361
+ uses: string[];
362
+ missing: boolean;
363
+ }
364
+
365
+ /** One `##` heading of a playbook, rendered as a step chip in the studio. */
366
+ interface PlaybookStep {
367
+ name: string;
368
+ detail: string;
369
+ }
370
+
371
+ /** A profile's playbook, shaped for the studio Workflows page. */
372
+ interface PlaybookWorkflow {
373
+ id: string;
374
+ name: string;
375
+ title: string;
376
+ emoji: string;
377
+ trigger: string;
378
+ est: string;
379
+ desc: string;
380
+ steps: PlaybookStep[];
381
+ }
382
+
383
+ /** A delegatable subagent ref, split into its division + slug for grouping. */
384
+ interface SubagentRef {
385
+ /** The raw ref, e.g. "design/design-ui-designer". */
386
+ id: string;
387
+ /** First path segment — the agency division (design, finance, sales…). */
388
+ division: string;
389
+ /** Trailing slug — the agent name. */
390
+ name: string;
391
+ }
392
+
393
+ /** A profile slash-command, resolved from its on-disk markdown source. */
394
+ interface StudioCommand {
395
+ /** Display name with the leading slash, e.g. "/goal". */
396
+ name: string;
397
+ /** Bare ref / file stem used to resolve the source, e.g. "goal". */
398
+ ref: string;
399
+ /** One-line `description:` from frontmatter ("" when absent / unresolved). */
400
+ desc: string;
401
+ /** Optional `argument-hint:` from frontmatter. */
402
+ argHint: string | null;
403
+ /** Full markdown body, rendered in the studio editor preview. */
404
+ body: string;
405
+ /** KB size of the source file (0 when unresolved). */
406
+ sizeK: number;
407
+ /** True when no source .md resolved (built-in / plugin-provided command). */
408
+ missing: boolean;
409
+ }
410
+
411
+ interface ProfileDetail {
412
+ profile: string;
413
+ parts: string[];
414
+ counts: { skills: number; mcps: number; plugins: number; commands: number; subagents: number; clis: number };
415
+ skills: StudioSkill[];
416
+ mcps: { id: string; status: string }[];
417
+ plugins: { id: string; name: string; marketplace: string; status: string }[];
418
+ commands: StudioCommand[];
419
+ /** Real on-disk workflows: the playbooks this profile declares, parsed. */
420
+ playbooks: PlaybookWorkflow[];
421
+ /** Delegatable specialists this profile wires into `.claude/agents/`. */
422
+ subagents: SubagentRef[];
423
+ /** External CLI tools the profile's skills declare (frontmatter Bash refs). */
424
+ clis: ProfileCli[];
425
+ }
426
+
427
+ /** Pull a `uses:` (or `mcps:`) frontmatter list out of a SKILL.md, if present. */
428
+ function parseUsesFromFrontmatter(content: string): string[] {
429
+ const lines = content.split("\n");
430
+ if (lines[0] !== "---") return [];
431
+ for (let i = 1; i < lines.length; i++) {
432
+ if (lines[i] === "---") break;
433
+ const m = lines[i]!.match(/^(?:uses|mcps):\s*\[([^\]]*)\]/);
434
+ if (m) {
435
+ return m[1]!
436
+ .split(",")
437
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
438
+ .filter(Boolean);
439
+ }
440
+ }
441
+ return [];
442
+ }
443
+
444
+ /** Strip a single matched pair of wrapping quotes — never an unbalanced lone quote. */
445
+ function stripWrappingQuotes(s: string): string {
446
+ return s.replace(/^(['"])([\s\S]*)\1$/, "$2");
447
+ }
448
+
449
+ /** Pull `description:` and `argument-hint:` out of a command markdown's frontmatter. */
450
+ function parseCommandFrontmatter(content: string): { desc: string; argHint: string | null } {
451
+ // Normalize CRLF so an externally-authored (Windows) command file's "---\r"
452
+ // fence still matches the delimiter check below.
453
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
454
+ if (lines[0] !== "---") return { desc: "", argHint: null };
455
+ let desc = "";
456
+ let argHint: string | null = null;
457
+ for (let i = 1; i < lines.length; i++) {
458
+ if (lines[i] === "---") break;
459
+ const d = lines[i]!.match(/^description:\s*(.+)$/);
460
+ if (d) desc = stripWrappingQuotes(d[1]!.trim());
461
+ const a = lines[i]!.match(/^argument-hint:\s*(.+)$/);
462
+ if (a) argHint = stripWrappingQuotes(a[1]!.trim());
463
+ }
464
+ return { desc, argHint };
465
+ }
466
+
467
+ /** Placeholder body for a command with no on-disk source (plugin / built-in). */
468
+ function stubCommandBody(name: string): string {
469
+ return `---
470
+ command: ${name}
471
+ source: plugin or built-in
472
+ ---
473
+
474
+ # ${name}
475
+
476
+ This command has no markdown source in \`resources/commands/\` — it is contributed by an installed plugin or built in to the agent, so its body isn't stored locally. Invoke it by typing \`${name}\` in the prompt.`;
477
+ }
478
+
479
+ /** Map a playbook slug/title to a stable emoji for its workflow card. */
480
+ function playbookEmoji(hint: string): string {
481
+ const s = hint.toLowerCase();
482
+ // Specific themes first — generic "ship/deploy" verbs appear in many titles.
483
+ if (/bug|triage|debug/.test(s)) return "🐛";
484
+ if (/sprint/.test(s)) return "🏃";
485
+ if (/improve|clean|refactor|health/.test(s)) return "🔧";
486
+ if (/skill/.test(s)) return "🧪";
487
+ if (/research|analyze|investigate/.test(s)) return "🔍";
488
+ if (/security|secops|cso/.test(s)) return "🛡";
489
+ if (/doc|write/.test(s)) return "📝";
490
+ if (/growth|market/.test(s)) return "📈";
491
+ if (/vite|web|frontend|design/.test(s)) return "🎨";
492
+ if (/medusa|shop|commerce/.test(s)) return "🛒";
493
+ if (/ship|deploy|release|land|canary/.test(s)) return "🚀";
494
+ return "📋";
495
+ }
496
+
497
+ /** Rough token estimate (~4 chars/token) → compact "~1.2K" label. */
498
+ function estTokensLabel(chars: number): string {
499
+ const t = Math.max(1, Math.round(chars / 4));
500
+ if (t < 1000) return `~${t}`;
501
+ return `~${(t / 1000).toFixed(1).replace(/\.0$/, "")}K`;
502
+ }
503
+
504
+ /** Collapse whitespace and cap a string with an ellipsis. */
505
+ function collapse(s: string, cap = 160): string {
506
+ const t = s.replace(/\s+/g, " ").trim();
507
+ return t.length > cap ? t.slice(0, cap - 1).trimEnd() + "…" : t;
508
+ }
509
+
510
+ /** Strip leading markdown markers (bullets, numbers, bold) from a line. */
511
+ function cleanMdLine(s: string): string {
512
+ return s.replace(/^[-*]\s+/, "").replace(/^\d+\.\s+/, "").replace(/\*\*/g, "").trim();
513
+ }
514
+
515
+ /**
516
+ * Parse a playbook markdown doc into a workflow-card model:
517
+ * - `title` from the `# Playbook: …` H1 (falls back to the prettified slug),
518
+ * - `desc` from the first "Use when …" paragraph (falls back to the first
519
+ * non-heading paragraph),
520
+ * - one `step` per `##` heading, numbered prefix stripped, with a one-line
521
+ * `detail` pulled from that section's first body line.
522
+ */
523
+ function parsePlaybook(slug: string, content: string): PlaybookWorkflow {
524
+ const lines = content.split("\n");
525
+
526
+ let title = slug.replace(/[-_]/g, " ");
527
+ const h1Idx = lines.findIndex((l) => /^#\s+/.test(l));
528
+ if (h1Idx >= 0) {
529
+ const m = lines[h1Idx]!.match(/^#\s+(?:Playbook:\s*)?(.+)$/);
530
+ if (m) title = m[1]!.trim();
531
+ }
532
+
533
+ let desc = "";
534
+ const gatherParagraph = (start: number): string => {
535
+ const para: string[] = [];
536
+ for (let i = start; i < lines.length && lines[i]!.trim() !== ""; i++) para.push(lines[i]!);
537
+ return collapse(para.join(" "));
538
+ };
539
+ const useIdx = lines.findIndex((l) => /use when/i.test(l));
540
+ if (useIdx >= 0) {
541
+ desc = gatherParagraph(useIdx);
542
+ } else {
543
+ for (let i = h1Idx + 1; i < lines.length; i++) {
544
+ const l = lines[i]!.trim();
545
+ if (!l || l.startsWith("#")) continue;
546
+ desc = gatherParagraph(i);
547
+ break;
548
+ }
549
+ }
550
+
551
+ const steps: PlaybookStep[] = [];
552
+ for (let i = 0; i < lines.length; i++) {
553
+ const m = lines[i]!.match(/^##\s+(.+)$/);
554
+ if (!m) continue;
555
+ const name = collapse(m[1]!.replace(/^\d+\.\s*/, ""), 60);
556
+ let detail = "";
557
+ for (let j = i + 1; j < lines.length; j++) {
558
+ if (/^##\s+/.test(lines[j]!)) break;
559
+ const c = cleanMdLine(lines[j]!);
560
+ if (c) { detail = collapse(c, 140); break; }
561
+ }
562
+ steps.push({ name, detail });
563
+ }
564
+
565
+ return {
566
+ id: slug,
567
+ name: slug,
568
+ title,
569
+ emoji: playbookEmoji(`${slug} ${title}`),
570
+ trigger: "playbook",
571
+ est: estTokensLabel(content.length),
572
+ desc: desc || `Playbook: ${title}`,
573
+ steps,
574
+ };
575
+ }
576
+
577
+ const profileDetailCache = new Map<string, { ts: number; data: ProfileDetail }>();
578
+ const PROFILE_DETAIL_TTL_MS = 60_000;
579
+
580
+ export async function handleProfileDetail(params: URLSearchParams): Promise<ApiResult<unknown>> {
581
+ // Default to the profile resolved for the server's cwd (same precedence as
582
+ // /status), so the studio opens on the active profile with no query param.
583
+ let name = resolveProfileQuery(params.get("profile"));
584
+ if (!name) {
585
+ const resolved = await resolveProfileForCwd({
586
+ cwd: process.cwd(),
587
+ homeDir: homedir(),
588
+ configDir: configDir(),
589
+ });
590
+ if (resolved.source !== "none") name = (resolved as { profile: string }).profile;
591
+ }
592
+ if (!name) return { ok: false, error: "no-profile" };
593
+
594
+ const cached = profileDetailCache.get(name);
595
+ if (cached && Date.now() - cached.ts < PROFILE_DETAIL_TTL_MS) {
596
+ return { ok: true, data: cached.data };
597
+ }
598
+
599
+ let profile;
600
+ try {
601
+ profile = await loadProfile(name);
602
+ } catch (err) {
603
+ return { ok: false, error: (err as Error).message };
604
+ }
605
+
606
+ // Resolve + read each local skill. Failures degrade to a stub entry (kept in
607
+ // the list so tree counts stay honest) rather than failing the whole call.
608
+ const localSkills: StudioSkill[] = await Promise.all(
609
+ profile.skills.local
610
+ .map((s) => s.id)
611
+ .filter((id) => !id.includes("*"))
612
+ .map(async (id): Promise<StudioSkill> => {
613
+ const ns = id.includes("/") ? id.split("/")[0]! : "skills";
614
+ const slug = id.slice(id.lastIndexOf("/") + 1);
615
+ try {
616
+ const dir = await resolveLocalSkill(id);
617
+ const path = join(dir, "SKILL.md");
618
+ const content = await readFile(path, "utf8");
619
+ const sizeK = +((await statAsync(path)).size / 1024).toFixed(1);
620
+ const parsed = parseSkillFromContent(id, content, slug);
621
+ const desc = parsed.capability || parsed.rawDescription || "";
622
+ return { id, ns, name: parsed.name || slug, desc, sizeK, body: content, uses: parseUsesFromFrontmatter(content), missing: false };
623
+ } catch {
624
+ return { id, ns, name: slug, desc: "(SKILL.md could not be read)", sizeK: 0, body: `---\nname: ${slug}\n---\n\n# ${slug}\n\nThis skill could not be resolved on disk.`, uses: [], missing: true };
625
+ }
626
+ }),
627
+ );
628
+
629
+ // npx skills: referenced by repo + slug; bodies live in a remote package, so
630
+ // surface them as catalogue entries under the "npx" namespace with a stub body.
631
+ const npxSkills: StudioSkill[] = profile.skills.npx.flatMap((ref) =>
632
+ (ref.skills ?? []).map((slug): StudioSkill => {
633
+ const id = `${ref.repo}#${slug}`;
634
+ const body = `---\nname: ${slug}\nnamespace: npx\nrepo: ${ref.repo}\n---\n\n# ${slug}\n\nProvided on demand via \`npx ${ref.repo}\`. The body is fetched at activation time, so it is not stored locally.`;
635
+ return { id, ns: "npx", name: slug, desc: `npx skill from ${ref.repo}`, sizeK: 0, body, uses: [], missing: false };
636
+ }),
637
+ );
638
+
639
+ const skills = [...localSkills, ...npxSkills];
640
+
641
+ const plugins = profile.plugins.map((p) => {
642
+ const at = p.id.lastIndexOf("@");
643
+ const pname = at > 0 ? p.id.slice(0, at) : p.id;
644
+ const marketplace = at > 0 ? p.id.slice(at + 1) : "";
645
+ return { id: p.id, name: pname, marketplace, status: "loaded" };
646
+ });
647
+
648
+ // Playbooks the profile declares → real on-disk workflows for the studio's
649
+ // Workflows page. Read + parse each; unreadable / oddly-named ones are skipped
650
+ // so one bad file never fails the whole call.
651
+ const pbDir = playbooksDir();
652
+ const playbooks: PlaybookWorkflow[] = (
653
+ await Promise.all(
654
+ (profile.playbooks ?? []).map(async (slug): Promise<PlaybookWorkflow | null> => {
655
+ if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null;
656
+ try {
657
+ const content = await readFile(join(pbDir, `${slug}.md`), "utf8");
658
+ return parsePlaybook(slug, content);
659
+ } catch {
660
+ return null;
661
+ }
662
+ }),
663
+ )
664
+ ).filter((p): p is PlaybookWorkflow => p !== null);
665
+
666
+ // Subagents — the delegatable specialists the profile wires into agents/.
667
+ // Refs are "<division>/<slug>"; split for grouped display in the studio.
668
+ const subagents: SubagentRef[] = (profile.subagents ?? []).map((ref) => {
669
+ const slash = ref.indexOf("/");
670
+ const division = slash > 0 ? ref.slice(0, slash) : "other";
671
+ const sname = ref.slice(ref.lastIndexOf("/") + 1);
672
+ return { id: ref, division, name: sname };
673
+ });
674
+
675
+ // CLIs the profile's skills shell out to — parsed from the skill bodies we
676
+ // already loaded above (no extra disk reads), enriched from cli-recipes.json.
677
+ const clis = aggregateProfileClis(skills);
678
+
679
+ // Commands — resolve each profile-declared slash command to its on-disk
680
+ // markdown (resources/commands/<ref>.md), reading the frontmatter description
681
+ // + argument-hint and the body for the studio's command preview. Refs come
682
+ // from the profile definition, but the stem is validated (no separators, no
683
+ // `..`) so a malformed ref can never read outside the commands dir. Built-in
684
+ // or plugin-provided commands with no source .md degrade to a stub entry
685
+ // (kept in the list so tree counts stay honest).
686
+ const cmdDir = commandsDir();
687
+ const commands: StudioCommand[] = await Promise.all(
688
+ profile.commands.map(async (raw): Promise<StudioCommand> => {
689
+ const ref = raw.replace(/^\//, "").replace(/\.md$/, "");
690
+ const name = `/${ref}`;
691
+ if (!/^[A-Za-z0-9._-]+$/.test(ref) || ref.includes("..")) {
692
+ return { name, ref, desc: "", argHint: null, body: stubCommandBody(name), sizeK: 0, missing: true };
693
+ }
694
+ try {
695
+ const path = join(cmdDir, `${ref}.md`);
696
+ // Belt-and-suspenders containment, mirroring serveProfileIcon: the ref
697
+ // regex already bars separators, but assert the resolved path stays
698
+ // inside the commands dir so a loosened guard can never read outside it.
699
+ if (!resolve(path).startsWith(resolve(cmdDir) + sep)) {
700
+ return { name, ref, desc: "", argHint: null, body: stubCommandBody(name), sizeK: 0, missing: true };
701
+ }
702
+ const content = await readFile(path, "utf8");
703
+ const sizeK = +((await statAsync(path)).size / 1024).toFixed(1);
704
+ const { desc, argHint } = parseCommandFrontmatter(content);
705
+ return { name, ref, desc, argHint, body: content, sizeK, missing: false };
706
+ } catch {
707
+ return { name, ref, desc: "", argHint: null, body: stubCommandBody(name), sizeK: 0, missing: true };
708
+ }
709
+ }),
710
+ );
711
+
712
+ const data: ProfileDetail = {
713
+ profile: name,
714
+ parts: name.split("+").map((s) => s.trim()).filter(Boolean),
715
+ counts: {
716
+ skills: skills.length,
717
+ mcps: profile.mcps.length,
718
+ plugins: profile.plugins.length,
719
+ commands: profile.commands.length,
720
+ subagents: subagents.length,
721
+ clis: clis.length,
722
+ },
723
+ skills,
724
+ mcps: profile.mcps.map((m) => ({ id: m.id, status: "connected" })),
725
+ plugins,
726
+ commands,
727
+ playbooks,
728
+ subagents,
729
+ clis,
730
+ };
731
+
732
+ profileDetailCache.set(name, { ts: Date.now(), data });
733
+ return { ok: true, data };
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Hooks — the active profile's real Claude Code hooks, read from the settings
738
+ // files that actually drive them: the cue-materialized runtime settings.json
739
+ // (per profile) plus the user's global ~/.claude/settings.json. Flattened and
740
+ // grouped by lifecycle event for the studio Hooks view. Read-only.
741
+ // ---------------------------------------------------------------------------
742
+
743
+ interface FlatHook {
744
+ event: string;
745
+ matcher: string;
746
+ command: string;
747
+ description: string;
748
+ id: string;
749
+ source: "profile" | "global";
750
+ /** Absolute path of the script the command runs, if it resolves to one. */
751
+ scriptPath: string | null;
752
+ }
753
+
754
+ interface SettingsHookEntry {
755
+ matcher?: string;
756
+ hooks?: { type?: string; command?: string; description?: string; id?: string }[];
757
+ }
758
+
759
+ /** The Claude config dir a hook's `${CLAUDE_CONFIG_DIR}` expands to, by source. */
760
+ function claudeDirForSource(source: FlatHook["source"], profileName: string | null): string | null {
761
+ if (source === "global") return join(homedir(), ".claude");
762
+ return profileName ? join(configDir(), "runtime", profileName, "claude") : null;
763
+ }
764
+
765
+ /**
766
+ * Resolve the script file a hook command runs, if any. Expands the env vars cue
767
+ * bakes into materialized hook commands (`${CLAUDE_CONFIG_DIR}` and `~`) and
768
+ * returns an absolute path — or null when the command isn't a script invocation
769
+ * or the path can't be fully resolved (so the UI hides the "view source" link).
770
+ */
771
+ function resolveHookScript(command: string, source: FlatHook["source"], profileName: string | null): string | null {
772
+ const m = command.match(/(\S+\.(?:sh|bash|zsh|js|mjs|cjs|ts|py|rb|pl))\b/);
773
+ if (!m) return null;
774
+ const claudeDir = claudeDirForSource(source, profileName);
775
+ let p = m[1]!
776
+ .replace(/\$\{CLAUDE_CONFIG_DIR\}|\$CLAUDE_CONFIG_DIR/g, claudeDir ?? "\0")
777
+ .replace(/^~(?=\/)/, homedir());
778
+ if (p.includes("\0") || p.includes("$")) return null; // unresolved variable
779
+ if (!p.startsWith("/")) return null; // only absolute paths
780
+ return resolve(p);
781
+ }
782
+
783
+ /** Extension → human language label for the source viewer's badge + highlighter. */
784
+ function hookLanguage(path: string): string {
785
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
786
+ const map: Record<string, string> = {
787
+ sh: "bash", bash: "bash", zsh: "bash",
788
+ js: "javascript", mjs: "javascript", cjs: "javascript",
789
+ ts: "typescript", py: "python", rb: "ruby", pl: "perl",
790
+ };
791
+ return map[ext] ?? ext ?? "text";
792
+ }
793
+
794
+ /** Parse the `hooks` map out of one settings.json, tagging each with `source`. */
795
+ function readHooksFile(path: string, source: FlatHook["source"], profileName: string | null): FlatHook[] {
796
+ if (!existsSync(path)) return [];
797
+ let parsed: { hooks?: Record<string, SettingsHookEntry[]> };
798
+ try {
799
+ parsed = JSON.parse(readFileSync(path, "utf8"));
800
+ } catch {
801
+ return [];
802
+ }
803
+ const hooks = parsed.hooks;
804
+ if (!hooks || typeof hooks !== "object") return [];
805
+ const out: FlatHook[] = [];
806
+ for (const event of Object.keys(hooks)) {
807
+ const groups = hooks[event];
808
+ if (!Array.isArray(groups)) continue;
809
+ for (const g of groups) {
810
+ const matcher = g.matcher && g.matcher.length > 0 ? g.matcher : "*";
811
+ for (const h of g.hooks ?? []) {
812
+ if (!h.command) continue;
813
+ out.push({
814
+ event,
815
+ matcher,
816
+ command: h.command,
817
+ description: h.description ?? "",
818
+ id: h.id ?? `${event}:${matcher}:${h.command}`,
819
+ source,
820
+ scriptPath: resolveHookScript(h.command, source, profileName),
821
+ });
822
+ }
823
+ }
824
+ }
825
+ return out;
826
+ }
827
+
828
+ /** Resolve the profile a hooks query targets — explicit param, else cwd. */
829
+ async function resolveHooksProfile(profileParam: string | null): Promise<string | null> {
830
+ const explicit = resolveProfileQuery(profileParam);
831
+ if (explicit) return explicit;
832
+ const resolved = await resolveProfileForCwd({ cwd: process.cwd(), homeDir: homedir(), configDir: configDir() });
833
+ return resolved.source !== "none" ? (resolved as { profile: string }).profile : null;
834
+ }
835
+
836
+ /** All hooks (global + this profile's runtime), deduped by id — the shared
837
+ * source of truth for both the hooks list and the source-viewer allowlist. */
838
+ function enumerateHooks(profileName: string | null): FlatHook[] {
839
+ const globalPath = join(homedir(), ".claude", "settings.json");
840
+ const runtimePath = profileName ? join(configDir(), "runtime", profileName, "claude", "settings.json") : null;
841
+ const flat: FlatHook[] = [
842
+ ...readHooksFile(globalPath, "global", null),
843
+ ...(runtimePath ? readHooksFile(runtimePath, "profile", profileName) : []),
844
+ ];
845
+ // Dedup by id (a profile hook overriding a global one wins — it appears later).
846
+ const byId = new Map<string, FlatHook>();
847
+ for (const h of flat) byId.set(h.id, h);
848
+ return [...byId.values()];
849
+ }
850
+
851
+ export async function handleHooks(params: URLSearchParams): Promise<ApiResult<unknown>> {
852
+ const name = await resolveHooksProfile(params.get("profile"));
853
+ const deduped = enumerateHooks(name);
854
+
855
+ // Group by event in a stable lifecycle order; unknown events sort last.
856
+ const EVENT_ORDER = [
857
+ "PreToolUse", "PostToolUse", "UserPromptSubmit", "SessionStart",
858
+ "SessionEnd", "Stop", "SubagentStop", "PreCompact", "Notification",
859
+ ];
860
+ const byEvent = new Map<string, FlatHook[]>();
861
+ for (const h of deduped) {
862
+ if (!byEvent.has(h.event)) byEvent.set(h.event, []);
863
+ byEvent.get(h.event)!.push(h);
864
+ }
865
+ const events = [...byEvent.keys()]
866
+ .sort((a, b) => {
867
+ const ia = EVENT_ORDER.indexOf(a), ib = EVENT_ORDER.indexOf(b);
868
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.localeCompare(b);
869
+ })
870
+ .map((event) => ({ event, hooks: byEvent.get(event)! }));
871
+
872
+ return { ok: true, data: { profile: name, total: deduped.length, events } };
873
+ }
874
+
875
+ const HOOK_SOURCE_MAX_BYTES = 256 * 1024;
876
+
877
+ /**
878
+ * Read one hook's script source for the studio's source viewer. Security: the
879
+ * requested path must be one of the *enumerated* hook script paths for this
880
+ * profile (an allowlist rebuilt per request) — an arbitrary path, or any
881
+ * `../` traversal, fails the membership check before any file is read.
882
+ */
883
+ export async function handleHookSource(params: URLSearchParams): Promise<ApiResult<unknown>> {
884
+ const requested = params.get("path");
885
+ if (!requested) return { ok: false, error: "missing-path" };
886
+
887
+ const name = await resolveHooksProfile(params.get("profile"));
888
+ const allow = new Set(
889
+ enumerateHooks(name).map((h) => h.scriptPath).filter((p): p is string => !!p),
890
+ );
891
+ const abs = resolve(requested);
892
+ if (!allow.has(abs)) return { ok: false, error: "not-a-hook-script" };
893
+ if (!existsSync(abs) || !statSync(abs).isFile()) return { ok: false, error: "not-found" };
894
+ if (statSync(abs).size > HOOK_SOURCE_MAX_BYTES) return { ok: false, error: "too-large" };
895
+
896
+ const home = homedir();
897
+ const displayPath = abs.startsWith(home) ? "~" + abs.slice(home.length) : abs;
898
+ const slash = displayPath.lastIndexOf("/");
899
+ return {
900
+ ok: true,
901
+ data: {
902
+ path: abs,
903
+ displayPath,
904
+ filename: displayPath.slice(slash + 1),
905
+ dir: displayPath.slice(0, slash + 1),
906
+ language: hookLanguage(abs),
907
+ content: readFileSync(abs, "utf8"),
908
+ },
909
+ };
910
+ }
911
+
221
912
  interface MergeRequest {
222
913
  names?: string[];
223
914
  name?: string;
@@ -277,6 +968,69 @@ export async function handleMergeSave(body: MergeRequest | null): Promise<ApiRes
277
968
  }
278
969
  }
279
970
 
971
+ /**
972
+ * Full MCP catalog — every server cue can wire into a profile, with inferred
973
+ * transport + install hint. Drives the studio's "Available in cue" section.
974
+ * Read-only; the client diffs this against the active profile's `mcps` to show
975
+ * only the not-yet-connected entries.
976
+ */
977
+ let mcpCatalogCache: { ts: number; data: unknown[] } | null = null;
978
+ const MCP_CATALOG_TTL_MS = 60_000;
979
+
980
+ export async function handleMcpCatalog(): Promise<ApiResult<unknown>> {
981
+ // The usedBy map below scans every profile via loadProfile; cache the built
982
+ // catalog briefly so a hard refresh / multiple clients don't re-scan all
983
+ // ~77 profiles on every hit. Global data, so a single-slot TTL cache fits.
984
+ if (mcpCatalogCache && Date.now() - mcpCatalogCache.ts < MCP_CATALOG_TTL_MS) {
985
+ return { ok: true, data: mcpCatalogCache.data };
986
+ }
987
+ // Map each MCP id → the profiles that wire it (resolved, so bundle- and
988
+ // core-inherited mcps count), each with its icon — lets the studio show
989
+ // "used by <profile icons>" next to a catalog entry's add button.
990
+ const profilesDir = mergeProfilesDir();
991
+ const usedBy = new Map<
992
+ string,
993
+ { name: string; icon: string | null; iconImage: string | null }[]
994
+ >();
995
+ for (const name of await listProfiles()) {
996
+ try {
997
+ const p = await loadProfile(name);
998
+ const iconImage =
999
+ p.iconImage && existsSync(join(profilesDir, name, p.iconImage)) ? p.iconImage : null;
1000
+ for (const m of p.mcps) {
1001
+ const list = usedBy.get(m.id) ?? [];
1002
+ list.push({ name, icon: p.icon ?? null, iconImage });
1003
+ usedBy.set(m.id, list);
1004
+ }
1005
+ } catch {
1006
+ /* skip a profile that won't resolve */
1007
+ }
1008
+ }
1009
+ const data = loadMcpCatalog().map((e) => ({ ...e, usedBy: usedBy.get(e.id) ?? [] }));
1010
+ mcpCatalogCache = { ts: Date.now(), data };
1011
+ return { ok: true, data };
1012
+ }
1013
+
1014
+ /**
1015
+ * Add a catalog MCP to a single physical profile's profile.yaml. The client
1016
+ * passes the chosen part-profile (composite runtime profiles have no file to
1017
+ * write), validated here against path traversal + catalog membership.
1018
+ */
1019
+ export async function handleMcpAdd(
1020
+ body: { id?: unknown; profile?: unknown } | null,
1021
+ ): Promise<ApiResult<unknown>> {
1022
+ const id = typeof body?.id === "string" ? body.id : "";
1023
+ const profile = typeof body?.profile === "string" ? body.profile : "";
1024
+ if (!id) return { ok: false, error: "missing-id" };
1025
+ if (!profile) return { ok: false, error: "missing-profile" };
1026
+ try {
1027
+ const result = await addMcpToProfile(id, profile);
1028
+ return { ok: true, data: result };
1029
+ } catch (err) {
1030
+ return { ok: false, error: (err as Error).message };
1031
+ }
1032
+ }
1033
+
280
1034
  export async function handleSkillReport(params: URLSearchParams): Promise<ApiResult<unknown>> {
281
1035
  if (!telemetryEnabled()) return { ok: false, error: "telemetry-disabled" };
282
1036
  const name = resolveProfileQuery(params.get("profile"));
@@ -312,11 +1066,23 @@ export async function handleGates(params: URLSearchParams): Promise<ApiResult<un
312
1066
  return { ok: true, data: readGateStatus(name) };
313
1067
  }
314
1068
 
1069
+ // Trigger-gaps is the dashboard's most expensive endpoint (it reads recent
1070
+ // transcripts and runs skills × prompts matching). Cache the result briefly so
1071
+ // a page load + auto-refetches don't recompute it back-to-back and stall the
1072
+ // single-threaded server. Keyed by profile + window; 90s TTL.
1073
+ const triggerGapsCache = new Map<string, { ts: number; data: unknown }>();
1074
+ const TRIGGER_GAPS_TTL_MS = 90_000;
1075
+
315
1076
  export async function handleTriggerGaps(params: URLSearchParams): Promise<ApiResult<unknown>> {
316
1077
  if (!telemetryEnabled()) return { ok: false, error: "telemetry-disabled" };
317
1078
  const name = resolveProfileQuery(params.get("profile"));
318
1079
  if (!name) return { ok: false, error: "no-profile" };
319
1080
  const sinceDays = parseSinceDays(params.get("since"));
1081
+ const cacheKey = `${name}:${sinceDays}`;
1082
+ const cached = triggerGapsCache.get(cacheKey);
1083
+ if (cached && Date.now() - cached.ts < TRIGGER_GAPS_TTL_MS) {
1084
+ return { ok: true, data: cached.data };
1085
+ }
320
1086
  try {
321
1087
  const profile = await loadProfile(name);
322
1088
  const skills = [];
@@ -334,10 +1100,9 @@ export async function handleTriggerGaps(params: URLSearchParams): Promise<ApiRes
334
1100
  const hits = new Map<string, number>();
335
1101
  for (const u of usage) hits.set(u.id, u.hits);
336
1102
  const rows = computeTriggerGaps({ skills, userPrompts, hits });
337
- return {
338
- ok: true,
339
- data: { profile: name, windowDays: sinceDays, promptsScanned: userPrompts.length, rows },
340
- };
1103
+ const data = { profile: name, windowDays: sinceDays, promptsScanned: userPrompts.length, rows };
1104
+ triggerGapsCache.set(cacheKey, { ts: Date.now(), data });
1105
+ return { ok: true, data };
341
1106
  } catch (err) {
342
1107
  return { ok: false, error: (err as Error).message };
343
1108
  }
@@ -382,36 +1147,675 @@ export async function handleKillSession(
382
1147
  }
383
1148
  }
384
1149
 
385
- export async function handleTelemetryTimeline(params: URLSearchParams): Promise<ApiResult<unknown>> {
386
- if (!telemetryEnabled()) return { ok: false, error: "telemetry-disabled" };
387
- const sinceDays = parseSinceDays(params.get("since"));
1150
+ /**
1151
+ * The activity-chart payload (gap-filled sessions-per-day + per-profile counts)
1152
+ * for a window. Shared by the polling GET and the SSE stream so both emit the
1153
+ * exact same shape — the stream is just this, pushed on change.
1154
+ */
1155
+ export function buildTimeline(sinceDays: number): {
1156
+ windowDays: number;
1157
+ daily: ReturnType<typeof computeDailyActivity>;
1158
+ profiles: { profile: string; sessions: number; lastUsed: string | null }[];
1159
+ } {
388
1160
  const cutoff = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
389
1161
  const events = computeStats({ since: cutoff });
390
- // Bucket sessions per profile per day. Computed from session counts since
391
- // we don't have per-day rollups; close enough for a sparkline.
392
1162
  return {
393
- ok: true,
394
- data: {
395
- windowDays: sinceDays,
396
- profiles: events.map((e) => ({
397
- profile: e.profile,
398
- sessions: e.sessions,
399
- lastUsed: e.last_used,
400
- })),
1163
+ windowDays: sinceDays,
1164
+ daily: computeDailyActivity(sinceDays),
1165
+ profiles: events.map((e) => ({
1166
+ profile: e.profile,
1167
+ sessions: e.sessions,
1168
+ lastUsed: e.last_used,
1169
+ })),
1170
+ };
1171
+ }
1172
+
1173
+ export async function handleTelemetryTimeline(params: URLSearchParams): Promise<ApiResult<unknown>> {
1174
+ if (!telemetryEnabled()) return { ok: false, error: "telemetry-disabled" };
1175
+ return { ok: true, data: buildTimeline(parseSinceDays(params.get("since"))) };
1176
+ }
1177
+
1178
+ // SSE response headers — no caching, and X-Accel-Buffering: no so any reverse
1179
+ // proxy (or vite's dev proxy) forwards each event instead of buffering it.
1180
+ const SSE_HEADERS = {
1181
+ "Content-Type": "text/event-stream; charset=utf-8",
1182
+ "Cache-Control": "no-cache, no-transform",
1183
+ "Connection": "keep-alive",
1184
+ "X-Accel-Buffering": "no",
1185
+ } as const;
1186
+ const STREAM_TICK_MS = 3000;
1187
+ const STREAM_HEARTBEAT_MS = 20000;
1188
+
1189
+ /**
1190
+ * Live activity stream (Server-Sent Events). Sends the timeline payload on
1191
+ * connect, then re-sends it whenever it changes (recompute + diff every few
1192
+ * seconds; identical payloads are suppressed). A periodic comment heartbeat
1193
+ * keeps the connection from going idle. One-way + read-only; the browser's
1194
+ * EventSource handles reconnection. Returns a streaming Response —
1195
+ * `writeWebResponse` pipes text/event-stream bodies instead of buffering them.
1196
+ */
1197
+ export function handleTelemetryStream(params: URLSearchParams): Response {
1198
+ const sinceDays = parseSinceDays(params.get("since"));
1199
+ const enabled = telemetryEnabled();
1200
+ const enc = new TextEncoder();
1201
+ let tick: ReturnType<typeof setInterval> | null = null;
1202
+ let beat: ReturnType<typeof setInterval> | null = null;
1203
+ let lastSent = "";
1204
+
1205
+ const stop = () => {
1206
+ if (tick) { clearInterval(tick); tick = null; }
1207
+ if (beat) { clearInterval(beat); beat = null; }
1208
+ };
1209
+
1210
+ const stream = new ReadableStream<Uint8Array>({
1211
+ start(controller) {
1212
+ const emit = (frame: string) => {
1213
+ try { controller.enqueue(enc.encode(frame)); } catch { stop(); }
1214
+ };
1215
+ if (!enabled) {
1216
+ // Keep the connection open (heartbeat only) so EventSource doesn't
1217
+ // reconnect-spam; the polling GET surfaces "telemetry-disabled" itself.
1218
+ emit("event: error\ndata: telemetry-disabled\n\n");
1219
+ } else {
1220
+ const send = () => {
1221
+ let json: string;
1222
+ try { json = JSON.stringify(buildTimeline(sinceDays)); } catch { return; }
1223
+ if (json === lastSent) return; // suppress unchanged payloads
1224
+ lastSent = json;
1225
+ emit(`event: timeline\ndata: ${json}\n\n`);
1226
+ };
1227
+ send(); // initial snapshot, immediately
1228
+ tick = setInterval(send, STREAM_TICK_MS);
1229
+ }
1230
+ beat = setInterval(() => emit(": ping\n\n"), STREAM_HEARTBEAT_MS);
401
1231
  },
1232
+ cancel() { stop(); },
1233
+ });
1234
+
1235
+ return new Response(stream, { status: 200, headers: SSE_HEADERS });
1236
+ }
1237
+
1238
+ /**
1239
+ * Every Claude Code plugin installed on this machine (enabled or not), read
1240
+ * from Claude Code's real store — a superset of the active profile's declared
1241
+ * `plugins:`. Powers the studio Plugins page's auto-discovery view.
1242
+ */
1243
+ export async function handleDiscoveredPlugins(): Promise<ApiResult<unknown>> {
1244
+ return { ok: true, data: { plugins: discoverInstalledPlugins() } };
1245
+ }
1246
+
1247
+ // ── version / update banner ────────────────────────────────────────────────
1248
+ // The studio's "update available" pill + maintainer broadcast. This is the one
1249
+ // outbound call the dashboard makes — a GET of cue-ai's public version doc on
1250
+ // the npm registry. No user data leaves the box; it's cached ~1h and fail-soft
1251
+ // (any error → no banner), mirroring the CLI's existing 24h update check.
1252
+
1253
+ /** Maintainer-authored broadcast, baked into the published `package.json`. */
1254
+ interface NoticePayload { message?: string; command?: string }
1255
+ interface VersionInfo {
1256
+ current: string;
1257
+ latest: string | null;
1258
+ updateAvailable: boolean;
1259
+ notice: NoticePayload | null;
1260
+ }
1261
+
1262
+ const NPM_LATEST_URL = "https://registry.npmjs.org/cue-ai/latest";
1263
+ const VERSION_TTL_MS = 60 * 60 * 1000; // 1h — matches the answer's "cached ~1h".
1264
+ let versionCache: { ts: number; data: VersionInfo } | null = null;
1265
+
1266
+ /** Installed cue-ai version, read from this package's own package.json. */
1267
+ function localVersion(): string {
1268
+ try {
1269
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as { version?: string };
1270
+ return pkg.version ?? "0.0.0";
1271
+ } catch {
1272
+ return "0.0.0";
1273
+ }
1274
+ }
1275
+
1276
+ /** True when semver `a` is strictly newer than `b` (major.minor.patch only). */
1277
+ export function semverGt(a: string, b: string): boolean {
1278
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
1279
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
1280
+ for (let i = 0; i < 3; i++) {
1281
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
1282
+ if (x !== y) return x > y;
1283
+ }
1284
+ return false;
1285
+ }
1286
+
1287
+ /**
1288
+ * Pure shaping of the version banner from the local version + the registry's
1289
+ * `latest` doc (its published package.json). Split out so it's unit-testable
1290
+ * without a network round-trip. `doc` is null when the fetch failed.
1291
+ */
1292
+ export function computeVersionInfo(
1293
+ current: string,
1294
+ doc: { version?: string; cue?: { notice?: NoticePayload } } | null,
1295
+ ): VersionInfo {
1296
+ const latest = doc?.version ?? null;
1297
+ const n = doc?.cue?.notice;
1298
+ const notice = n && (n.message || n.command) ? { message: n.message, command: n.command } : null;
1299
+ return { current, latest, updateAvailable: !!latest && semverGt(latest, current), notice };
1300
+ }
1301
+
1302
+ export async function handleVersion(): Promise<ApiResult<unknown>> {
1303
+ const current = localVersion();
1304
+ // Serve cached registry data but always refresh `current` from disk (cheap,
1305
+ // and stays correct across an in-place update without a server restart).
1306
+ if (versionCache && Date.now() - versionCache.ts < VERSION_TTL_MS) {
1307
+ return { ok: true, data: computeVersionInfo(current, { version: versionCache.data.latest ?? undefined, cue: versionCache.data.notice ? { notice: versionCache.data.notice } : undefined }) };
1308
+ }
1309
+ let doc: { version?: string; cue?: { notice?: NoticePayload } } | null = null;
1310
+ try {
1311
+ const res = await fetch(NPM_LATEST_URL, { signal: AbortSignal.timeout(3000) });
1312
+ if (res.ok) doc = (await res.json()) as typeof doc;
1313
+ } catch {
1314
+ // Offline / timeout / blocked → fail-soft: no banner this cycle.
1315
+ }
1316
+ const data = computeVersionInfo(current, doc);
1317
+ versionCache = { ts: Date.now(), data };
1318
+ return { ok: true, data };
1319
+ }
1320
+
1321
+ // ── marketplace ─────────────────────────────────────────────────────────────
1322
+ // One unified feed of everything addable to a profile: shared hosted-registry
1323
+ // skills/mcps plus this checkout's local library (profiles, mcp catalog, CLIs,
1324
+ // playbooks, plugins). Normalized into a single MarketItem shape the studio's
1325
+ // Market page renders as one searchable grid. Read-only; cached 60s like the
1326
+ // profile-detail cache since it scans the registry + every playbook + plugins.
1327
+
1328
+ interface MarketItem {
1329
+ id: string;
1330
+ type: "profile" | "workflow" | "skill" | "cli" | "mcp" | "plugin";
1331
+ name: string;
1332
+ author: string;
1333
+ handle: string;
1334
+ stars: number;
1335
+ installs: string;
1336
+ when: string;
1337
+ featured: boolean;
1338
+ desc: string;
1339
+ tags: string[];
1340
+ source: "registry" | "local";
1341
+ add: string;
1342
+ addKind: "mcp" | "skill" | "profile" | "cli" | "workflow" | "plugin";
1343
+ }
1344
+
1345
+ interface MarketRegistrySkill {
1346
+ id?: string; name?: string; description?: string;
1347
+ repo?: string; tags?: string[]; stars?: number; installs?: string; featured?: boolean;
1348
+ }
1349
+ interface MarketRegistryMcp {
1350
+ id?: string; name?: string; description?: string;
1351
+ repo?: string; tags?: string[]; stars?: number; installs?: string; featured?: boolean;
1352
+ }
1353
+ interface MarketRegistry {
1354
+ skills?: MarketRegistrySkill[];
1355
+ mcps?: MarketRegistryMcp[];
1356
+ }
1357
+
1358
+ /** Path to the shared registry doc in this checkout (may not exist). */
1359
+ function registryIndexPath(): string {
1360
+ return join(REPO_ROOT, "docs", "registry", "index.json");
1361
+ }
1362
+
1363
+ const REGISTRY_URL = "https://opencue.github.io/cue/registry/index.json";
1364
+
1365
+ /**
1366
+ * Load the shared registry — local doc first, then a short-timeout curl fetch,
1367
+ * mirroring `loadRegistry` in src/commands/marketplace.ts. Any failure is
1368
+ * tolerated (returns null) so the local-library part of the feed still renders
1369
+ * offline.
1370
+ */
1371
+ function loadMarketRegistry(): MarketRegistry | null {
1372
+ const path = registryIndexPath();
1373
+ if (existsSync(path)) {
1374
+ try { return JSON.parse(readFileSync(path, "utf8")) as MarketRegistry; } catch { /* fall through */ }
1375
+ }
1376
+ try {
1377
+ const res = spawnSync("curl", ["-sfL", "--max-time", "5", REGISTRY_URL], { encoding: "utf8", timeout: 8000 });
1378
+ if (res.status === 0 && res.stdout) return JSON.parse(res.stdout) as MarketRegistry;
1379
+ } catch { /* offline / blocked */ }
1380
+ return null;
1381
+ }
1382
+
1383
+ /** "owner/name" → display author. "" when the repo field is absent. */
1384
+ function authorFromRepo(repo: string | undefined): string {
1385
+ if (!repo) return "";
1386
+ return repo.split("/")[0]!.trim();
1387
+ }
1388
+
1389
+ /** Registry skills + mcps → MarketItem[] (source:"registry"). */
1390
+ function registryItems(reg: MarketRegistry | null): MarketItem[] {
1391
+ if (!reg) return [];
1392
+ const items: MarketItem[] = [];
1393
+ for (const s of reg.skills ?? []) {
1394
+ if (!s.id || !s.repo) continue;
1395
+ const handle = authorFromRepo(s.repo);
1396
+ items.push({
1397
+ id: `skill:${s.id}`,
1398
+ type: "skill",
1399
+ name: s.name || s.id,
1400
+ author: handle || "cue",
1401
+ handle: handle || "cue",
1402
+ stars: typeof s.stars === "number" ? s.stars : 0,
1403
+ installs: s.installs ?? "",
1404
+ when: "",
1405
+ featured: s.featured === true,
1406
+ desc: s.description ?? "",
1407
+ tags: Array.isArray(s.tags) ? s.tags : [],
1408
+ source: "registry",
1409
+ add: `cue marketplace install-skill ${s.repo}`,
1410
+ addKind: "skill",
1411
+ });
1412
+ }
1413
+ for (const m of reg.mcps ?? []) {
1414
+ if (!m.id) continue;
1415
+ const handle = authorFromRepo(m.repo);
1416
+ items.push({
1417
+ id: `mcp:${m.id}`,
1418
+ type: "mcp",
1419
+ name: m.name || m.id,
1420
+ author: handle || "cue",
1421
+ handle: handle || "cue",
1422
+ stars: typeof m.stars === "number" ? m.stars : 0,
1423
+ installs: m.installs ?? "",
1424
+ when: "",
1425
+ featured: m.featured === true,
1426
+ desc: m.description ?? "",
1427
+ tags: Array.isArray(m.tags) ? m.tags : [],
1428
+ source: "registry",
1429
+ add: `cue marketplace install-mcp ${m.id}`,
1430
+ addKind: "mcp",
1431
+ });
1432
+ }
1433
+ return items;
1434
+ }
1435
+
1436
+ /** Every parseable file in resources/playbooks → workflow MarketItem[]. */
1437
+ function playbookMarketItems(): MarketItem[] {
1438
+ const dir = playbooksDir();
1439
+ let files: string[];
1440
+ try {
1441
+ files = readdirSync(dir).filter((f) => f.endsWith(".md"));
1442
+ } catch {
1443
+ return [];
1444
+ }
1445
+ const items: MarketItem[] = [];
1446
+ for (const file of files) {
1447
+ const slug = file.replace(/\.md$/, "");
1448
+ if (!/^[A-Za-z0-9._-]+$/.test(slug)) continue;
1449
+ try {
1450
+ const content = readFileSync(join(dir, file), "utf8");
1451
+ const pb = parsePlaybook(slug, content);
1452
+ items.push({
1453
+ id: `workflow:${slug}`,
1454
+ type: "workflow",
1455
+ name: pb.title,
1456
+ author: "cue",
1457
+ handle: "cue",
1458
+ stars: 0,
1459
+ installs: "",
1460
+ when: "",
1461
+ featured: false,
1462
+ desc: pb.desc,
1463
+ tags: [],
1464
+ source: "local",
1465
+ add: "(playbook)",
1466
+ addKind: "workflow",
1467
+ });
1468
+ } catch { /* skip unreadable */ }
1469
+ }
1470
+ return items;
1471
+ }
1472
+
1473
+ /** Local profiles → profile MarketItem[]. */
1474
+ async function profileMarketItems(): Promise<MarketItem[]> {
1475
+ const names = await listProfiles();
1476
+ const items: MarketItem[] = [];
1477
+ for (const name of names) {
1478
+ let desc = "";
1479
+ let tags: string[] = [];
1480
+ try {
1481
+ const p = await loadProfile(name);
1482
+ desc = p.description ?? "";
1483
+ tags = p.bundles ?? [];
1484
+ } catch { /* keep a bare row */ }
1485
+ items.push({
1486
+ id: `profile:${name}`,
1487
+ type: "profile",
1488
+ name,
1489
+ author: "cue",
1490
+ handle: "cue",
1491
+ stars: 0,
1492
+ installs: "",
1493
+ when: "",
1494
+ featured: false,
1495
+ desc,
1496
+ tags,
1497
+ source: "local",
1498
+ add: `cue use ${name}`,
1499
+ addKind: "profile",
1500
+ });
1501
+ }
1502
+ return items;
1503
+ }
1504
+
1505
+ /** Local MCP catalog → mcp MarketItem[], minus ids already in `registryMcpIds`. */
1506
+ function localMcpMarketItems(registryMcpIds: Set<string>): MarketItem[] {
1507
+ return loadMcpCatalog()
1508
+ .filter((e) => !registryMcpIds.has(e.id))
1509
+ .map((e) => ({
1510
+ id: `mcp:${e.id}`,
1511
+ type: "mcp" as const,
1512
+ name: e.id,
1513
+ author: "cue",
1514
+ handle: "cue",
1515
+ stars: 0,
1516
+ installs: "",
1517
+ when: "",
1518
+ featured: false,
1519
+ desc: e.description ?? "",
1520
+ tags: [],
1521
+ source: "local" as const,
1522
+ add: `cue marketplace install-mcp ${e.id}`,
1523
+ addKind: "mcp" as const,
1524
+ }));
1525
+ }
1526
+
1527
+ /** resources/cli-recipes.json → cli MarketItem[]. */
1528
+ function cliMarketItems(): MarketItem[] {
1529
+ let recipes: Record<string, unknown>;
1530
+ try {
1531
+ recipes = JSON.parse(readFileSync(join(REPO_ROOT, "resources", "cli-recipes.json"), "utf8")) as Record<string, unknown>;
1532
+ } catch {
1533
+ return [];
1534
+ }
1535
+ const items: MarketItem[] = [];
1536
+ for (const [name, recipe] of Object.entries(recipes)) {
1537
+ // Skip the schema-doc key and any non-object entries.
1538
+ if (name.startsWith("$") || typeof recipe !== "object" || recipe === null) continue;
1539
+ const r = recipe as { needs?: unknown; apt?: unknown; brew?: unknown };
1540
+ const desc = typeof r.needs === "string" ? r.needs : "";
1541
+ items.push({
1542
+ id: `cli:${name}`,
1543
+ type: "cli",
1544
+ name,
1545
+ author: "cue",
1546
+ handle: "cue",
1547
+ stars: 0,
1548
+ installs: "",
1549
+ when: "",
1550
+ featured: false,
1551
+ desc,
1552
+ tags: [],
1553
+ source: "local",
1554
+ add: "(see recipe)",
1555
+ addKind: "cli",
1556
+ });
1557
+ }
1558
+ return items;
1559
+ }
1560
+
1561
+ /** Installed Claude Code plugins → plugin MarketItem[]. */
1562
+ function pluginMarketItems(): MarketItem[] {
1563
+ return discoverInstalledPlugins().map((p) => ({
1564
+ id: `plugin:${p.id}`,
1565
+ type: "plugin" as const,
1566
+ name: p.name,
1567
+ author: "cue",
1568
+ handle: "cue",
1569
+ stars: 0,
1570
+ installs: "",
1571
+ when: relativeAge(p.installedAt),
1572
+ featured: false,
1573
+ desc: p.description ?? "",
1574
+ tags: [],
1575
+ source: "local" as const,
1576
+ add: `cue marketplace install-plugin ${p.id}`,
1577
+ addKind: "plugin" as const,
1578
+ }));
1579
+ }
1580
+
1581
+ /** ISO timestamp → compact relative age ("2d","1w","now"); "" when absent. */
1582
+ function relativeAge(iso: string | null): string {
1583
+ if (!iso) return "";
1584
+ const then = Date.parse(iso);
1585
+ if (Number.isNaN(then)) return "";
1586
+ const secs = Math.max(0, (Date.now() - then) / 1000);
1587
+ if (secs < 60) return "now";
1588
+ const mins = Math.floor(secs / 60);
1589
+ if (mins < 60) return `${mins}m`;
1590
+ const hours = Math.floor(mins / 60);
1591
+ if (hours < 24) return `${hours}h`;
1592
+ const days = Math.floor(hours / 24);
1593
+ if (days < 7) return `${days}d`;
1594
+ const weeks = Math.floor(days / 7);
1595
+ if (weeks < 5) return `${weeks}w`;
1596
+ const months = Math.floor(days / 30);
1597
+ if (months < 12) return `${months}mo`;
1598
+ return `${Math.floor(days / 365)}y`;
1599
+ }
1600
+
1601
+ interface MarketData { items: MarketItem[]; counts: Record<string, number> }
1602
+ let marketCache: { ts: number; data: MarketData } | null = null;
1603
+ const MARKET_TTL_MS = 60_000;
1604
+
1605
+ /**
1606
+ * Unified marketplace feed: shared-registry skills/mcps + the local library
1607
+ * (profiles, mcp catalog, CLIs, playbooks, plugins), normalized into one
1608
+ * MarketItem[] with per-type counts. Registry fetch failures degrade to the
1609
+ * local-only feed. Cached 60s. featured = any registry item flagged featured,
1610
+ * else the top 3 items by stars.
1611
+ */
1612
+ export async function handleMarket(): Promise<ApiResult<unknown>> {
1613
+ if (marketCache && Date.now() - marketCache.ts < MARKET_TTL_MS) {
1614
+ return { ok: true, data: marketCache.data };
1615
+ }
1616
+
1617
+ const reg = loadMarketRegistry();
1618
+ const regItems = registryItems(reg);
1619
+ const registryMcpIds = new Set(
1620
+ regItems.filter((i) => i.type === "mcp").map((i) => i.id.slice("mcp:".length)),
1621
+ );
1622
+
1623
+ const items: MarketItem[] = [
1624
+ ...regItems,
1625
+ ...(await profileMarketItems()),
1626
+ ...localMcpMarketItems(registryMcpIds),
1627
+ ...cliMarketItems(),
1628
+ ...playbookMarketItems(),
1629
+ ...pluginMarketItems(),
1630
+ ];
1631
+
1632
+ // Featured: honor any registry-flagged item; otherwise spotlight the top 3
1633
+ // by stars (only registry items carry stars, so this picks the most-starred
1634
+ // registry entries when nothing is explicitly flagged).
1635
+ const anyFlagged = items.some((i) => i.featured);
1636
+ if (!anyFlagged) {
1637
+ const top3 = [...items].sort((a, b) => b.stars - a.stars).slice(0, 3);
1638
+ const top3Ids = new Set(top3.map((i) => i.id));
1639
+ for (const i of items) i.featured = top3Ids.has(i.id) && i.stars > 0;
1640
+ }
1641
+
1642
+ const counts: Record<string, number> = {
1643
+ all: items.length,
1644
+ profile: 0, workflow: 0, skill: 0, cli: 0, mcp: 0, plugin: 0,
402
1645
  };
1646
+ for (const i of items) counts[i.type] = (counts[i.type] ?? 0) + 1;
1647
+
1648
+ const data: MarketData = { items, counts };
1649
+ marketCache = { ts: Date.now(), data };
1650
+ return { ok: true, data };
1651
+ }
1652
+
1653
+ // ── Workflows: the n8n-style canvas's saved DAGs (resources/workflows/*.json) ──
1654
+ export async function handleWorkflows(): Promise<ApiResult<unknown>> {
1655
+ return { ok: true, data: listWorkflows() };
1656
+ }
1657
+
1658
+ export async function handleWorkflow(params: URLSearchParams): Promise<ApiResult<unknown>> {
1659
+ const name = params.get("name");
1660
+ if (!name) return { ok: false, error: "missing-name" };
1661
+ try {
1662
+ const wf = loadWorkflow(name);
1663
+ return wf ? { ok: true, data: wf } : { ok: false, error: "not-found" };
1664
+ } catch (err) {
1665
+ return { ok: false, error: (err as Error).message };
1666
+ }
1667
+ }
1668
+
1669
+ export async function handleWorkflowSave(body: unknown): Promise<ApiResult<unknown>> {
1670
+ try {
1671
+ return { ok: true, data: saveWorkflow(body, new Date().toISOString()) };
1672
+ } catch (err) {
1673
+ return { ok: false, error: (err as Error).message };
1674
+ }
1675
+ }
1676
+
1677
+ // ════════ ENV VARIABLES ════════
1678
+ // Per-folder .env viewer. Reads real `<folder>/.env` from an allowlist of
1679
+ // project folders, parses KEY=value, classifies each var, and MASKS secret
1680
+ // values server-side — a raw secret value is returned only when the request
1681
+ // names that key in `reveal`. Loopback-only trust boundary (see binding note).
1682
+
1683
+ /** Project folders whose `.env` the studio may read. `~` expands to $HOME.
1684
+ * Mirrors the design's folder list; the canonical home for the user's repos. */
1685
+ const ENV_FOLDER_DEFS: ReadonlyArray<{ path: string; tag: string }> = [
1686
+ { path: "~/Documents/cue", tag: "cue" },
1687
+ { path: "~/Documents/recodee/authmux", tag: "authmux" },
1688
+ { path: "~/Documents/x-growth-bot", tag: "x-growth-bot" },
1689
+ { path: "~/work/medusa", tag: "medusa" },
1690
+ { path: "~/Documents/medusa-shops/marva", tag: "marva" },
1691
+ { path: "~/Documents/gitguardex", tag: "gitguardex" },
1692
+ ];
1693
+
1694
+ const expandHome = (p: string): string => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
1695
+
1696
+ const ENV_SECRET_RE = /(SECRET|_KEY|TOKEN|PASSWORD|PASSWD|_PASS|CREDENTIAL|DATABASE_URL|_DSN|PRIVATE)/i;
1697
+ type EnvKind = "secret" | "url" | "bool" | "num" | "plain";
1698
+ function classifyEnvVar(key: string, val: string): EnvKind {
1699
+ if (ENV_SECRET_RE.test(key)) return "secret";
1700
+ if (/^https?:\/\//.test(val)) return "url";
1701
+ if (/^(true|false)$/i.test(val)) return "bool";
1702
+ if (/^\d+$/.test(val)) return "num";
1703
+ return "plain";
1704
+ }
1705
+ interface EnvVarRow { key: string; value: string; kind: EnvKind; masked: boolean }
1706
+ /** Mask all but the first/last 3 chars (mirrors the design's mask()). */
1707
+ function maskSecretValue(v: string): string {
1708
+ return v.length <= 8 ? "•".repeat(v.length) : v.slice(0, 3) + "•".repeat(Math.min(18, v.length - 6)) + v.slice(-3);
1709
+ }
1710
+ function parseEnvText(raw: string, revealKey: string): EnvVarRow[] {
1711
+ const out: EnvVarRow[] = [];
1712
+ for (const line of raw.split("\n")) {
1713
+ const t = line.trim();
1714
+ if (!t || t.startsWith("#")) continue;
1715
+ const eq = line.indexOf("=");
1716
+ if (eq < 0) continue;
1717
+ const key = line.slice(0, eq).trim();
1718
+ let value = line.slice(eq + 1).trim();
1719
+ // Strip a single layer of surrounding quotes, like dotenv does.
1720
+ if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"') || (value[0] === "'" && value.at(-1) === "'"))) {
1721
+ value = value.slice(1, -1);
1722
+ }
1723
+ const kind = classifyEnvVar(key, value);
1724
+ const isSecret = kind === "secret";
1725
+ // `reveal` names a single key, or "*" to reveal every secret at once.
1726
+ const reveal = isSecret && (revealKey === "*" || revealKey === key);
1727
+ out.push({ key, value: isSecret && !reveal ? maskSecretValue(value) : value, kind, masked: isSecret && !reveal });
1728
+ }
1729
+ return out;
1730
+ }
1731
+
1732
+ /** GET /api/v1/env/folders → the allowlisted project folders + their var counts. */
1733
+ export async function handleEnvFolders(): Promise<ApiResult<unknown>> {
1734
+ const folders = ENV_FOLDER_DEFS.map((f) => {
1735
+ const abs = join(expandHome(f.path), ".env");
1736
+ let count = 0;
1737
+ try { if (existsSync(abs)) count = parseEnvText(readFileSync(abs, "utf8"), "").length; } catch { /* unreadable → 0 */ }
1738
+ return { path: f.path, tag: f.tag, count };
1739
+ });
1740
+ return { ok: true, data: { folders } };
1741
+ }
1742
+
1743
+ /** GET /api/v1/env?folder=<path>[&reveal=<KEY>] → parsed, masked vars for one
1744
+ * allowlisted folder. `reveal` returns the raw value for that single key. */
1745
+ export async function handleEnv(params: URLSearchParams): Promise<ApiResult<unknown>> {
1746
+ const folder = params.get("folder") ?? "";
1747
+ const reveal = params.get("reveal") ?? "";
1748
+ // Allowlist: the requested folder must be one we offer — no arbitrary paths.
1749
+ const def = ENV_FOLDER_DEFS.find((f) => f.path === folder);
1750
+ if (!def) return { ok: false, error: "unknown-folder" };
1751
+ const abs = join(expandHome(def.path), ".env");
1752
+ if (!existsSync(abs)) return { ok: true, data: { folder: def.path, tag: def.tag, exists: false, vars: [] } };
1753
+ let raw = "";
1754
+ try { raw = readFileSync(abs, "utf8"); } catch (err) { return { ok: false, error: `read failed: ${(err as Error).message}` }; }
1755
+ return { ok: true, data: { folder: def.path, tag: def.tag, exists: true, vars: parseEnvText(raw, reveal) } };
1756
+ }
1757
+
1758
+ /**
1759
+ * GitHub source repos a profile's skills / MCPs / plugins originate from, with
1760
+ * live star counts. Derives the profile's namespace / MCP / plugin sets from
1761
+ * the resolved profile, filters the curated catalog to what it actually
1762
+ * contains, then resolves each repo's stargazers (cached 6h, fail-soft).
1763
+ */
1764
+ export async function handleRepos(params: URLSearchParams): Promise<ApiResult<unknown>> {
1765
+ let name = resolveProfileQuery(params.get("profile"));
1766
+ if (!name) {
1767
+ const resolved = await resolveProfileForCwd({
1768
+ cwd: process.cwd(),
1769
+ homeDir: homedir(),
1770
+ configDir: configDir(),
1771
+ });
1772
+ if (resolved.source !== "none") name = (resolved as { profile: string }).profile;
1773
+ }
1774
+ if (!name) return { ok: false, error: "no-profile" };
1775
+
1776
+ let profile;
1777
+ try {
1778
+ profile = await loadProfile(name);
1779
+ } catch (err) {
1780
+ return { ok: false, error: (err as Error).message };
1781
+ }
1782
+
1783
+ const namespaces = new Set<string>();
1784
+ for (const s of profile.skills.local) {
1785
+ namespaces.add(s.id.includes("/") ? s.id.split("/")[0]! : "skills");
1786
+ }
1787
+ if (profile.skills.npx.length) namespaces.add("npx");
1788
+ const mcpIds = profile.mcps.map((m) => m.id);
1789
+ const pluginIds = profile.plugins.map((p) => p.id);
1790
+
1791
+ const matched = reposForProfile({ namespaces, mcpIds, pluginIds });
1792
+ const repos = await resolveRepoStars(matched, Date.now());
1793
+ return { ok: true, data: { profile: name, repos } };
403
1794
  }
404
1795
 
405
1796
  const ROUTES: Record<string, (params: URLSearchParams) => Promise<ApiResult<unknown>>> = {
406
1797
  "/api/v1/status": () => handleStatus(),
1798
+ "/api/v1/env/folders": () => handleEnvFolders(),
1799
+ "/api/v1/env": (p) => handleEnv(p),
1800
+ "/api/v1/plugins/discovered": () => handleDiscoveredPlugins(),
1801
+ "/api/v1/workflows": () => handleWorkflows(),
1802
+ "/api/v1/workflow": (p) => handleWorkflow(p),
407
1803
  "/api/v1/profiles": () => handleProfiles(),
408
1804
  "/api/v1/profiles/full": () => handleProfilesFull(),
1805
+ "/api/v1/profile-detail": (p) => handleProfileDetail(p),
1806
+ "/api/v1/hooks": (p) => handleHooks(p),
1807
+ "/api/v1/hook-source": (p) => handleHookSource(p),
409
1808
  "/api/v1/skill-report": (p) => handleSkillReport(p),
410
1809
  "/api/v1/pairs": (p) => handlePairs(p),
411
1810
  "/api/v1/gates": (p) => handleGates(p),
412
1811
  "/api/v1/trigger-gaps": (p) => handleTriggerGaps(p),
413
1812
  "/api/v1/active-sessions": () => handleActiveSessions(),
414
1813
  "/api/v1/telemetry/timeline": (p) => handleTelemetryTimeline(p),
1814
+ "/api/v1/mcps/catalog": () => handleMcpCatalog(),
1815
+ "/api/v1/market": () => handleMarket(),
1816
+ "/api/v1/version": () => handleVersion(),
1817
+ "/api/v1/permissions": () => Promise.resolve({ ok: true, data: collectPermissions() }),
1818
+ "/api/v1/repos": (p) => handleRepos(p),
415
1819
  };
416
1820
 
417
1821
  function contentTypeFor(path: string): string {
@@ -457,7 +1861,52 @@ export function createHandler(): (req: Request) => Promise<Response> {
457
1861
  return Response.json(result, { status: result.ok ? 200 : 400 });
458
1862
  }
459
1863
 
1864
+ if (req.method === "POST" && url.pathname === "/api/v1/mcps/add") {
1865
+ let body: unknown = null;
1866
+ try { body = await req.json(); } catch { /* malformed */ }
1867
+ const result = await handleMcpAdd(body as Parameters<typeof handleMcpAdd>[0]);
1868
+ return Response.json(result, { status: result.ok ? 200 : 400 });
1869
+ }
1870
+
1871
+ if (req.method === "POST" && url.pathname === "/api/v1/workflows/save") {
1872
+ let body: unknown = null;
1873
+ try { body = await req.json(); } catch { /* malformed */ }
1874
+ const result = await handleWorkflowSave(body);
1875
+ return Response.json(result, { status: result.ok ? 200 : 400 });
1876
+ }
1877
+
1878
+ // Live activity stream (Server-Sent Events, not JSON): pushes the timeline
1879
+ // payload on change so the dashboard chart auto-updates without polling.
1880
+ if (req.method === "GET" && url.pathname === "/api/v1/telemetry/stream") {
1881
+ return handleTelemetryStream(url.searchParams);
1882
+ }
1883
+
1884
+ // Profile logo bytes (not JSON): GET /api/v1/profile-icon?profile=<name>.
1885
+ // Serves profiles/<name>/<iconImage> for profiles that set one.
1886
+ if (req.method === "GET" && url.pathname === "/api/v1/profile-icon") {
1887
+ return serveProfileIcon(url.searchParams.get("profile"));
1888
+ }
1889
+
1890
+ // Plugin logo bytes (not JSON): GET /api/v1/plugin-icon?plugin=<id>.
1891
+ // Reuses a same-named profile's logo, else a generated PNG.
1892
+ if (req.method === "GET" && url.pathname === "/api/v1/plugin-icon") {
1893
+ return servePluginIcon(url.searchParams.get("plugin"));
1894
+ }
1895
+
460
1896
  if (url.pathname.startsWith("/api/v1/")) {
1897
+ // Defense-in-depth for the secret-revealing path: a `reveal` request to
1898
+ // /api/v1/env returns raw .env secrets. Even though the server has no
1899
+ // permissive CORS (so a cross-origin tab can't read the response) and
1900
+ // binds loopback by default, refuse a reveal whose Sec-Fetch-Site marks
1901
+ // it cross-origin — so a drive-by tab can't pull plaintext even if CORS
1902
+ // or the bind host were ever loosened. Same-origin (the studio SPA),
1903
+ // `none` (address-bar), and header-absent (curl / tests) are allowed.
1904
+ if (url.pathname === "/api/v1/env" && url.searchParams.get("reveal")) {
1905
+ const site = req.headers.get("sec-fetch-site");
1906
+ if (site === "cross-site" || site === "same-site") {
1907
+ return Response.json({ ok: false, error: "reveal-cross-origin-blocked" }, { status: 403 });
1908
+ }
1909
+ }
461
1910
  const handler = ROUTES[url.pathname];
462
1911
  if (!handler) {
463
1912
  return Response.json({ ok: false, error: "not-found" }, { status: 404 });