cue-ai 0.9.2 → 0.9.3

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 (278) hide show
  1. package/CHANGELOG.md +4 -3
  2. package/README.md +148 -170
  3. package/bin/cue-learnings +30 -4
  4. package/bin/cue-review-progress +0 -0
  5. package/bin/cue-review-watch +0 -0
  6. package/dist/cue.js +4328 -3108
  7. package/package.json +1 -1
  8. package/plugins/cue/commands/cue-switch.md +1 -1
  9. package/plugins/cue/commands/cue.md +1 -1
  10. package/profiles/backend/profile.yaml +4 -0
  11. package/profiles/browser/profile.yaml +4 -0
  12. package/profiles/career/profile.yaml +2 -13
  13. package/profiles/commerce/profile.yaml +0 -2
  14. package/profiles/coolify/profile.yaml +0 -1
  15. package/profiles/core/profile.yaml +78 -11
  16. package/profiles/dash-merge-test/profile.yaml +6 -1
  17. package/profiles/designer/profile.yaml +9 -1
  18. package/profiles/dropshipping/profile.yaml +69 -0
  19. package/profiles/frontend/profile.yaml +4 -0
  20. package/profiles/google-ads/profile.yaml +34 -0
  21. package/profiles/google-analytics/profile.yaml +34 -0
  22. package/profiles/google-drive/profile.yaml +34 -0
  23. package/profiles/gstack/profile.yaml +117 -29
  24. package/profiles/marketing/profile.yaml +0 -1
  25. package/profiles/media/README.md +70 -0
  26. package/profiles/media/profile.yaml +104 -0
  27. package/profiles/nano-banana/profile.yaml +52 -0
  28. package/profiles/ops/profile.yaml +1 -2
  29. package/profiles/secops/profile.yaml +3 -0
  30. package/profiles/skill-writer/profile.yaml +15 -0
  31. package/profiles/video/profile.yaml +3 -0
  32. package/profiles/web-frontend-base/profile.yaml +6 -0
  33. package/profiles/webshop/profile.yaml +0 -1
  34. package/profiles/webshop-google/profile.yaml +1 -0
  35. package/profiles/x-growth-bot/profile.yaml +2 -0
  36. package/resources/icons/generate-icons.py +2 -128
  37. package/resources/mcps/configs/claude.sanitized.json +88 -20
  38. package/resources/mcps/configs/claude_runtime.sanitized.json +40 -1
  39. package/resources/mcps/configs/codex.sanitized.json +29 -0
  40. package/resources/skills/skills/career/job-hunter/LICENSE +21 -0
  41. package/resources/skills/skills/career/job-hunter/README.md +323 -0
  42. package/resources/skills/skills/career/job-hunter/SKILL.md +91 -0
  43. package/resources/skills/skills/career/job-hunter/agents/README.md +96 -0
  44. package/resources/skills/skills/career/job-hunter/agents/apply-assessment-prep.md +195 -0
  45. package/resources/skills/skills/career/job-hunter/agents/apply-ats-scan.md +155 -0
  46. package/resources/skills/skills/career/job-hunter/agents/apply-bias-audit.md +224 -0
  47. package/resources/skills/skills/career/job-hunter/agents/apply-cover-letter.md +69 -0
  48. package/resources/skills/skills/career/job-hunter/agents/apply-decode-jd.md +117 -0
  49. package/resources/skills/skills/career/job-hunter/agents/apply-fit-score.md +183 -0
  50. package/resources/skills/skills/career/job-hunter/agents/apply-linkedin-audit.md +74 -0
  51. package/resources/skills/skills/career/job-hunter/agents/apply-linkedin-scrape.md +255 -0
  52. package/resources/skills/skills/career/job-hunter/agents/apply-portfolio-brief.md +123 -0
  53. package/resources/skills/skills/career/job-hunter/agents/apply-reality-check.md +164 -0
  54. package/resources/skills/skills/career/job-hunter/agents/apply-reference-prep.md +150 -0
  55. package/resources/skills/skills/career/job-hunter/agents/apply-rejection-analysis.md +172 -0
  56. package/resources/skills/skills/career/job-hunter/agents/apply-resume.md +70 -0
  57. package/resources/skills/skills/career/job-hunter/agents/apply-skills-gap-filler.md +109 -0
  58. package/resources/skills/skills/career/job-hunter/agents/career-internal.md +94 -0
  59. package/resources/skills/skills/career/job-hunter/agents/career-linkedin-content.md +173 -0
  60. package/resources/skills/skills/career/job-hunter/agents/career-linkedin-scanner.md +262 -0
  61. package/resources/skills/skills/career/job-hunter/agents/career-network-message.md +108 -0
  62. package/resources/skills/skills/career/job-hunter/agents/career-promote.md +102 -0
  63. package/resources/skills/skills/career/job-hunter/agents/career-review.md +71 -0
  64. package/resources/skills/skills/career/job-hunter/agents/interview-debrief.md +117 -0
  65. package/resources/skills/skills/career/job-hunter/agents/interview-mock.md +171 -0
  66. package/resources/skills/skills/career/job-hunter/agents/interview-panel-decoder.md +152 -0
  67. package/resources/skills/skills/career/job-hunter/agents/interview-prep.md +184 -0
  68. package/resources/skills/skills/career/job-hunter/agents/interview-question-bank.md +133 -0
  69. package/resources/skills/skills/career/job-hunter/agents/interview-research.md +148 -0
  70. package/resources/skills/skills/career/job-hunter/agents/offer-compare.md +117 -0
  71. package/resources/skills/skills/career/job-hunter/agents/offer-counteroffer.md +144 -0
  72. package/resources/skills/skills/career/job-hunter/agents/offer-deadline-manager.md +148 -0
  73. package/resources/skills/skills/career/job-hunter/agents/offer-negotiate.md +126 -0
  74. package/resources/skills/skills/career/job-hunter/agents/offer-schedule.md +99 -0
  75. package/resources/skills/skills/career/job-hunter/agents/offer-thankyou.md +80 -0
  76. package/resources/skills/skills/career/job-hunter/agents/search-company-research.md +146 -0
  77. package/resources/skills/skills/career/job-hunter/agents/search-follow-up.md +129 -0
  78. package/resources/skills/skills/career/job-hunter/agents/search-ghost-job-detector.md +152 -0
  79. package/resources/skills/skills/career/job-hunter/agents/search-inbox-scan.md +193 -0
  80. package/resources/skills/skills/career/job-hunter/agents/search-interview-scorecard.md +164 -0
  81. package/resources/skills/skills/career/job-hunter/agents/search-jobs.md +149 -0
  82. package/resources/skills/skills/career/job-hunter/agents/search-momentum-check.md +194 -0
  83. package/resources/skills/skills/career/job-hunter/agents/search-outreach.md +85 -0
  84. package/resources/skills/skills/career/job-hunter/agents/search-referral-finder.md +124 -0
  85. package/resources/skills/skills/career/job-hunter/agents/search-salary.md +96 -0
  86. package/resources/skills/skills/career/job-hunter/agents/search-send-email.md +109 -0
  87. package/resources/skills/skills/career/job-hunter/agents/search-tracker-update.md +127 -0
  88. package/resources/skills/skills/career/job-hunter/inputs/README.md +26 -0
  89. package/resources/skills/skills/career/job-hunter/inputs/apply-linkedin-url.txt +8 -0
  90. package/resources/skills/skills/career/job-hunter/inputs/interview-context.md +24 -0
  91. package/resources/skills/skills/career/job-hunter/inputs/job-description.md +20 -0
  92. package/resources/skills/skills/career/job-hunter/inputs/job-search-criteria.md +36 -0
  93. package/resources/skills/skills/career/job-hunter/inputs/my-linkedin.md +24 -0
  94. package/resources/skills/skills/career/job-hunter/inputs/my-resume.md +28 -0
  95. package/resources/skills/skills/career/job-hunter/inputs/search-outreach-target.md +24 -0
  96. package/resources/skills/skills/career/job-hunter/rules/README.md +37 -0
  97. package/resources/skills/skills/career/job-hunter/rules/writing-rules.md +81 -0
  98. package/resources/skills/skills/design/banana/SKILL.md +375 -0
  99. package/resources/skills/skills/design/banana/references/cost-tracking.md +47 -0
  100. package/resources/skills/skills/design/banana/references/gemini-models.md +236 -0
  101. package/resources/skills/skills/design/banana/references/mcp-tools.md +145 -0
  102. package/resources/skills/skills/design/banana/references/post-processing.md +192 -0
  103. package/resources/skills/skills/design/banana/references/presets.md +69 -0
  104. package/resources/skills/skills/design/banana/references/prompt-engineering.md +481 -0
  105. package/resources/skills/skills/design/banana/scripts/batch.py +97 -0
  106. package/resources/skills/skills/design/banana/scripts/cost_tracker.py +191 -0
  107. package/resources/skills/skills/design/banana/scripts/edit.py +159 -0
  108. package/resources/skills/skills/design/banana/scripts/generate.py +168 -0
  109. package/resources/skills/skills/design/banana/scripts/presets.py +154 -0
  110. package/resources/skills/skills/design/banana/scripts/setup_mcp.py +151 -0
  111. package/resources/skills/skills/design/banana/scripts/validate_setup.py +133 -0
  112. package/resources/skills/skills/gstack/ship/SKILL.md +13 -0
  113. package/resources/skills/skills/media/3d-logo-animation/SKILL.md +59 -0
  114. package/resources/skills/skills/media/action-figure-generator/SKILL.md +48 -0
  115. package/resources/skills/skills/media/ad-creative/SKILL.md +79 -0
  116. package/resources/skills/skills/media/ai-clipping/SKILL.md +194 -0
  117. package/resources/skills/skills/media/ai-clipping/scripts/run-ai-clipping.sh +200 -0
  118. package/resources/skills/skills/media/ai-fight-scene/SKILL.md +132 -0
  119. package/resources/skills/skills/media/amazon-product-listing/SKILL.md +68 -0
  120. package/resources/skills/skills/media/animal-video-generator/SKILL.md +59 -0
  121. package/resources/skills/skills/media/award-ceremony-video/SKILL.md +87 -0
  122. package/resources/skills/skills/media/blog-header/SKILL.md +61 -0
  123. package/resources/skills/skills/media/brand-kit/SKILL.md +72 -0
  124. package/resources/skills/skills/media/brochures/SKILL.md +65 -0
  125. package/resources/skills/skills/media/cartoon-dance-animation/SKILL.md +62 -0
  126. package/resources/skills/skills/media/character-story-video/SKILL.md +84 -0
  127. package/resources/skills/skills/media/chibi-collage-effect/SKILL.md +63 -0
  128. package/resources/skills/skills/media/cinema-director/SKILL.md +93 -0
  129. package/resources/skills/skills/media/cinema-director/scripts/generate-film.sh +78 -0
  130. package/resources/skills/skills/media/color-analysis-board/SKILL.md +71 -0
  131. package/resources/skills/skills/media/core-edit/SKILL.md +48 -0
  132. package/resources/skills/skills/media/core-edit/edit-image.sh +54 -0
  133. package/resources/skills/skills/media/core-edit/enhance-image.sh +191 -0
  134. package/resources/skills/skills/media/core-edit/lipsync.sh +144 -0
  135. package/resources/skills/skills/media/core-edit/video-effects.sh +193 -0
  136. package/resources/skills/skills/media/core-media/SKILL.md +49 -0
  137. package/resources/skills/skills/media/core-media/create-music.sh +169 -0
  138. package/resources/skills/skills/media/core-media/generate-image.sh +161 -0
  139. package/resources/skills/skills/media/core-media/generate-video.sh +137 -0
  140. package/resources/skills/skills/media/core-media/image-to-video.sh +228 -0
  141. package/resources/skills/skills/media/core-media/schema_data.json +18708 -0
  142. package/resources/skills/skills/media/core-media/upload.sh +41 -0
  143. package/resources/skills/skills/media/core-platform/SKILL.md +41 -0
  144. package/resources/skills/skills/media/core-platform/check-result.sh +37 -0
  145. package/resources/skills/skills/media/core-platform/setup.sh +31 -0
  146. package/resources/skills/skills/media/couple-grid-creator/SKILL.md +47 -0
  147. package/resources/skills/skills/media/design-guide/SKILL.md +73 -0
  148. package/resources/skills/skills/media/drone-style-video/SKILL.md +61 -0
  149. package/resources/skills/skills/media/fashion-try-on/SKILL.md +61 -0
  150. package/resources/skills/skills/media/floor-plan-rendering/SKILL.md +56 -0
  151. package/resources/skills/skills/media/freeze-effect-video/SKILL.md +100 -0
  152. package/resources/skills/skills/media/giant-product-showcase/SKILL.md +61 -0
  153. package/resources/skills/skills/media/instagram-post/SKILL.md +58 -0
  154. package/resources/skills/skills/media/interior-design/SKILL.md +61 -0
  155. package/resources/skills/skills/media/interior-design-visualizer/SKILL.md +57 -0
  156. package/resources/skills/skills/media/jewelry-product-video/SKILL.md +61 -0
  157. package/resources/skills/skills/media/kdenlive/SKILL.md +106 -0
  158. package/resources/skills/skills/media/kdenlive/scripts/assemble.sh +57 -0
  159. package/resources/skills/skills/media/kdenlive/scripts/common.sh +30 -0
  160. package/resources/skills/skills/media/kdenlive/scripts/inspect.sh +19 -0
  161. package/resources/skills/skills/media/kdenlive/scripts/reframe.sh +22 -0
  162. package/resources/skills/skills/media/kdenlive/scripts/render.sh +16 -0
  163. package/resources/skills/skills/media/kdenlive/scripts/title-card.sh +25 -0
  164. package/resources/skills/skills/media/keyboard-art-maker/SKILL.md +44 -0
  165. package/resources/skills/skills/media/logo-branding/SKILL.md +70 -0
  166. package/resources/skills/skills/media/logo-creator/SKILL.md +80 -0
  167. package/resources/skills/skills/media/logo-creator/scripts/create-logo.sh +38 -0
  168. package/resources/skills/skills/media/logo-generator/SKILL.md +56 -0
  169. package/resources/skills/skills/media/multi-angle-reshoot/SKILL.md +70 -0
  170. package/resources/skills/skills/media/multi-angle-shots/SKILL.md +73 -0
  171. package/resources/skills/skills/media/music-video/SKILL.md +61 -0
  172. package/resources/skills/skills/media/nano-banana/SKILL.md +80 -0
  173. package/resources/skills/skills/media/nano-banana/scripts/generate-nano-art.sh +54 -0
  174. package/resources/skills/skills/media/one-shot-video/SKILL.md +56 -0
  175. package/resources/skills/skills/media/photo-pack-generator/SKILL.md +205 -0
  176. package/resources/skills/skills/media/photo-pack-generator/scripts/generate-pack.sh +241 -0
  177. package/resources/skills/skills/media/product-ad-cinematic/SKILL.md +78 -0
  178. package/resources/skills/skills/media/product-campaign/SKILL.md +76 -0
  179. package/resources/skills/skills/media/product-showcase-video/SKILL.md +60 -0
  180. package/resources/skills/skills/media/product-video-ad-maker/SKILL.md +59 -0
  181. package/resources/skills/skills/media/rednote-cover/SKILL.md +57 -0
  182. package/resources/skills/skills/media/seedance-2/SKILL.md +632 -0
  183. package/resources/skills/skills/media/seedance-2/scripts/generate-seedance.sh +701 -0
  184. package/resources/skills/skills/media/selfie-with-celebrities/SKILL.md +64 -0
  185. package/resources/skills/skills/media/social-media-video/SKILL.md +277 -0
  186. package/resources/skills/skills/media/social-media-video/scripts/run-social-video.sh +316 -0
  187. package/resources/skills/skills/media/social-pack/SKILL.md +58 -0
  188. package/resources/skills/skills/media/storyboard/SKILL.md +57 -0
  189. package/resources/skills/skills/media/storyboard-to-cooking-video/SKILL.md +143 -0
  190. package/resources/skills/skills/media/talking-baby-video/SKILL.md +57 -0
  191. package/resources/skills/skills/media/ugc-ads-workflow/SKILL.md +70 -0
  192. package/resources/skills/skills/media/ugc-lifestyle-try-on/SKILL.md +65 -0
  193. package/resources/skills/skills/media/ugc-video-factory/SKILL.md +134 -0
  194. package/resources/skills/skills/media/ui-design/SKILL.md +81 -0
  195. package/resources/skills/skills/media/ui-design/scripts/generate-mockup.sh +49 -0
  196. package/resources/skills/skills/media/url-to-design/SKILL.md +61 -0
  197. package/resources/skills/skills/media/workflow/SKILL.md +197 -0
  198. package/resources/skills/skills/media/workflow/scripts/discover-workflow.sh +18 -0
  199. package/resources/skills/skills/media/workflow/scripts/generate-workflow.sh +33 -0
  200. package/resources/skills/skills/media/workflow/scripts/interactive-run.sh +16 -0
  201. package/resources/skills/skills/media/workflow/scripts/list-workflows.sh +20 -0
  202. package/resources/skills/skills/media/workflow/scripts/run-workflow.sh +34 -0
  203. package/resources/skills/skills/media/youtube-shorts/SKILL.md +173 -0
  204. package/resources/skills/skills/media/youtube-shorts/scripts/run-youtube-shorts.sh +141 -0
  205. package/resources/skills/skills/media/youtube-thumbnail/SKILL.md +66 -0
  206. package/resources/skills/skills/meta/cue-developer/references/architecture.md +2 -2
  207. package/resources/skills/skills/meta/cue-usage/SKILL.md +1 -1
  208. package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +2 -2
  209. package/resources/skills/skills/meta/profile-optimizer/SKILL.md +1 -1
  210. package/resources/skills/skills/meta/profile-suggest/SKILL.md +7 -7
  211. package/resources/skills/skills/meta/profile-summon/SKILL.md +159 -0
  212. package/resources/skills/skills/meta/profile-summon/evals/evals.json +53 -0
  213. package/resources/skills/skills/meta/save-profile/SKILL.md +1 -1
  214. package/resources/skills/skills/meta/skill-reviewer/SKILL.md +3 -0
  215. package/resources/skills/skills/meta/skill-reviewer/references/tdd-for-skills.md +55 -0
  216. package/resources/skills/skills/research/find-skills/SKILL.md +1 -1
  217. package/resources/skills/skills/review/code-review-deep/SKILL.md +20 -0
  218. package/resources/skills/skills/security/trivy-scan/SKILL.md +139 -0
  219. package/resources/skills/skills/security/trivy-scan/scripts/ensure-trivy.sh +21 -0
  220. package/resources/skills/skills/tools/ccusage/SKILL.md +142 -0
  221. package/src/commands/_index.ts +8 -0
  222. package/src/commands/ai.ts +2 -2
  223. package/src/commands/auto-detect.test.ts +74 -0
  224. package/src/commands/auto-detect.ts +9 -7
  225. package/src/commands/cli.test.ts +20 -4
  226. package/src/commands/cli.ts +36 -20
  227. package/src/commands/create-profile.ts +2 -2
  228. package/src/commands/debug.ts +2 -2
  229. package/src/commands/discover.ts +14 -4
  230. package/src/commands/export-docker.ts +1 -1
  231. package/src/commands/features-batch1.test.ts +1 -1
  232. package/src/commands/gates.ts +1 -1
  233. package/src/commands/import-profile.ts +1 -1
  234. package/src/commands/init.ts +15 -11
  235. package/src/commands/install.test.ts +192 -0
  236. package/src/commands/install.ts +610 -0
  237. package/src/commands/launch-handoff.e2e.test.ts +33 -1
  238. package/src/commands/launch.e2e.test.ts +15 -10
  239. package/src/commands/launch.ts +73 -116
  240. package/src/commands/materialize.ts +2 -2
  241. package/src/commands/prune.ts +1 -1
  242. package/src/commands/security-audit.ts +1 -1
  243. package/src/commands/shell.ts +7 -7
  244. package/src/commands/skill-report.ts +1 -1
  245. package/src/commands/skills.ts +3 -3
  246. package/src/commands/snapshot.ts +2 -2
  247. package/src/commands/summon.test.ts +116 -0
  248. package/src/commands/summon.ts +338 -0
  249. package/src/commands/trigger-gaps.ts +1 -1
  250. package/src/commands/use.ts +47 -3
  251. package/src/commands/watch-live.ts +5 -5
  252. package/src/commands/watch.ts +8 -8
  253. package/src/index.ts +2 -0
  254. package/src/lib/active-sessions.test.ts +3 -3
  255. package/src/lib/active-sessions.ts +4 -4
  256. package/src/lib/auto-detect.test.ts +172 -8
  257. package/src/lib/auto-detect.ts +191 -136
  258. package/src/lib/codex-persona-parity.test.ts +58 -0
  259. package/src/lib/companion-detect.test.ts +43 -1
  260. package/src/lib/companion-detect.ts +35 -0
  261. package/src/lib/credentials-sync.test.ts +121 -1
  262. package/src/lib/credentials-sync.ts +95 -1
  263. package/src/lib/cwd-resolver.test.ts +8 -8
  264. package/src/lib/cwd-resolver.ts +2 -2
  265. package/src/lib/dashboard-merge.test.ts +9 -4
  266. package/src/lib/dashboard-server.ts +1 -1
  267. package/src/lib/picker.test.ts +1 -1
  268. package/src/lib/picker.ts +5 -5
  269. package/src/lib/profile-merge.test.ts +8 -0
  270. package/src/lib/profile-names.test.ts +3 -3
  271. package/src/lib/runtime-install.ts +166 -0
  272. package/src/lib/runtime-materializer.test.ts +137 -0
  273. package/src/lib/runtime-materializer.ts +105 -2
  274. package/src/lib/skill-router.test.ts +38 -0
  275. package/src/lib/skill-router.ts +65 -4
  276. package/profiles/eu-tender-research/README.md +0 -48
  277. package/profiles/eu-tender-research/logo.png +0 -0
  278. package/profiles/eu-tender-research/profile.yaml +0 -108
@@ -16,6 +16,8 @@
16
16
  import { readdirSync } from "node:fs";
17
17
  import { basename, extname } from "node:path";
18
18
 
19
+ import { DEP_PROFILE_RULES, type DetectionResultV2 } from "./auto-detect";
20
+
19
21
  export interface CompanionSignal {
20
22
  /** Companion profile to surface in the combine multiselect. */
21
23
  profile: string;
@@ -138,3 +140,36 @@ export function detectCompanions(input: CompanionDetectInput): CompanionSignal[]
138
140
  }))
139
141
  .sort((a, b) => b.confidence - a.confidence || a.profile.localeCompare(b.profile));
140
142
  }
143
+
144
+ /**
145
+ * Floor applied to dep-detected service companions. In the *main picker* a
146
+ * service dep deliberately sits below the auto-pick line (it must never
147
+ * hijack Enter), but in the combine multiselect a direct package.json
148
+ * dependency is a strong "you'll want this alongside" signal — and a
149
+ * pre-checked row costs one keystroke to drop. 0.75 clears the picker's
150
+ * COMBINE_AUTO_CHECK_CONFIDENCE (0.7) so these rows start checked.
151
+ */
152
+ const SERVICE_COMPANION_CONF = 0.75;
153
+
154
+ /**
155
+ * Convert dep-based profile detections (see `DEP_PROFILE_RULES`) into combine
156
+ * companions: a repo with `stripe` in package.json gets the stripe profile
157
+ * offered — pre-checked — when the user picks any primary. Only rules marked
158
+ * `companion: true` qualify (primary stacks like react-native are excluded),
159
+ * and only profiles installed in this cue install survive.
160
+ */
161
+ export function serviceCompanions(
162
+ detections: ReadonlyArray<DetectionResultV2>,
163
+ knownProfiles: ReadonlySet<string>,
164
+ ): CompanionSignal[] {
165
+ const eligible = new Set(
166
+ DEP_PROFILE_RULES.filter((r) => r.companion === true).map((r) => r.profile),
167
+ );
168
+ return detections
169
+ .filter((d) => eligible.has(d.profile) && knownProfiles.has(d.profile))
170
+ .map((d) => ({
171
+ profile: d.profile,
172
+ confidence: Math.max(d.confidence, SERVICE_COMPANION_CONF),
173
+ reason: d.reasons.join(", "),
174
+ }));
175
+ }
@@ -3,7 +3,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- import { findFreshestCredentials, syncFreshestToSource } from "./credentials-sync";
6
+ import { findFreshestCredentials, listKnownAccountDirs, rescueRuntimeCredentials, syncFreshestToSource } from "./credentials-sync";
7
7
 
8
8
  let root: string;
9
9
  beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "cue-credsync-")); });
@@ -206,3 +206,123 @@ describe("syncFreshestToSource", () => {
206
206
  expect(after.claudeAiOauth.refreshToken).toBe("rt-A");
207
207
  });
208
208
  });
209
+
210
+ describe("rescueRuntimeCredentials", () => {
211
+ test("rescues fresher runtime creds to the account dir owning the uuid", async () => {
212
+ // The user's bug in miniature: account2 logged in inside the shared
213
+ // runtime (fresh token lives ONLY there), account2's own dir holds the
214
+ // dead rotated token. Rescue must return the fresh token home before the
215
+ // identity guard wipes the runtime for the other account.
216
+ const account1 = join(root, "accounts", "account1");
217
+ const account2 = join(root, "accounts", "account2");
218
+ await writeAccountDir(account1, UUID_A, { refreshToken: "rt-a1", expiresAt: 5000 });
219
+ await writeAccountDir(account2, UUID_B, { refreshToken: "rt-a2-dead", expiresAt: 1000 });
220
+
221
+ const runtimeDir = join(root, "runtime", "core", "claude");
222
+ await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-a2-fresh", expiresAt: 9999 });
223
+
224
+ const result = await rescueRuntimeCredentials(runtimeDir, [account1, account2]);
225
+ expect(result.rescued).toBe(true);
226
+ if (result.rescued) expect(result.to).toBe(join(account2, ".credentials.json"));
227
+
228
+ const after = JSON.parse(await readFile(join(account2, ".credentials.json"), "utf8"));
229
+ expect(after.claudeAiOauth.refreshToken).toBe("rt-a2-fresh");
230
+ // The other account's dir is untouched.
231
+ const other = JSON.parse(await readFile(join(account1, ".credentials.json"), "utf8"));
232
+ expect(other.claudeAiOauth.refreshToken).toBe("rt-a1");
233
+ });
234
+
235
+ test("skips when the owner already holds creds as fresh or fresher", async () => {
236
+ const account = join(root, "accounts", "account2");
237
+ await writeAccountDir(account, UUID_B, { refreshToken: "rt-owner", expiresAt: 9999 });
238
+
239
+ const runtimeDir = join(root, "runtime", "core", "claude");
240
+ await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-runtime", expiresAt: 9999 });
241
+
242
+ const result = await rescueRuntimeCredentials(runtimeDir, [account]);
243
+ expect(result.rescued).toBe(false);
244
+ const after = JSON.parse(await readFile(join(account, ".credentials.json"), "utf8"));
245
+ expect(after.claudeAiOauth.refreshToken).toBe("rt-owner");
246
+ // The runtime copy must be left untouched too.
247
+ const runtimeAfter = JSON.parse(await readFile(join(runtimeDir, ".credentials.json"), "utf8"));
248
+ expect(runtimeAfter.claudeAiOauth.refreshToken).toBe("rt-runtime");
249
+ });
250
+
251
+ test("heals EVERY dir claiming the uuid, not just the first match", async () => {
252
+ // ~/.claude and an authmux account dir can both hold the same account.
253
+ const homeClaude = join(root, ".claude");
254
+ const accountDir = join(root, "accounts", "account2");
255
+ await writeAccountDir(homeClaude, UUID_B, { refreshToken: "rt-home-dead", expiresAt: 1000 });
256
+ await writeAccountDir(accountDir, UUID_B, { refreshToken: "rt-acct-dead", expiresAt: 2000 });
257
+
258
+ const runtimeDir = join(root, "runtime", "core", "claude");
259
+ await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-fresh", expiresAt: 9999 });
260
+
261
+ const result = await rescueRuntimeCredentials(runtimeDir, [homeClaude, accountDir]);
262
+ expect(result.rescued).toBe(true);
263
+
264
+ for (const dir of [homeClaude, accountDir]) {
265
+ const after = JSON.parse(await readFile(join(dir, ".credentials.json"), "utf8"));
266
+ expect(after.claudeAiOauth.refreshToken).toBe("rt-fresh");
267
+ }
268
+ });
269
+
270
+ test("skips when no account dir matches the runtime's uuid", async () => {
271
+ const account = join(root, "accounts", "account1");
272
+ await writeAccountDir(account, UUID_A, { refreshToken: "rt-a1", expiresAt: 1000 });
273
+
274
+ const runtimeDir = join(root, "runtime", "core", "claude");
275
+ await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-b", expiresAt: 9999 });
276
+
277
+ const result = await rescueRuntimeCredentials(runtimeDir, [account]);
278
+ expect(result.rescued).toBe(false);
279
+ const after = JSON.parse(await readFile(join(account, ".credentials.json"), "utf8"));
280
+ expect(after.claudeAiOauth.refreshToken).toBe("rt-a1");
281
+ });
282
+
283
+ test("skips when the runtime has no readable accountUuid", async () => {
284
+ const account = join(root, "accounts", "account1");
285
+ await writeAccountDir(account, UUID_A, { refreshToken: "rt-a1", expiresAt: 1000 });
286
+
287
+ // Credentials without a .claude.json — owner unknowable, must not guess.
288
+ const runtimeDir = join(root, "runtime", "core", "claude");
289
+ await writeAccountDir(runtimeDir, undefined, { refreshToken: "rt-mystery", expiresAt: 9999 });
290
+
291
+ const result = await rescueRuntimeCredentials(runtimeDir, [account]);
292
+ expect(result.rescued).toBe(false);
293
+ });
294
+
295
+ test("writes to an owner dir that has identity but no credentials yet", async () => {
296
+ const account = join(root, "accounts", "account2");
297
+ await writeAccountDir(account, UUID_B, undefined);
298
+
299
+ const runtimeDir = join(root, "runtime", "core", "claude");
300
+ await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-fresh", expiresAt: 9999 });
301
+
302
+ const result = await rescueRuntimeCredentials(runtimeDir, [account]);
303
+ expect(result.rescued).toBe(true);
304
+ const after = JSON.parse(await readFile(join(account, ".credentials.json"), "utf8"));
305
+ expect(after.claudeAiOauth.refreshToken).toBe("rt-fresh");
306
+ });
307
+ });
308
+
309
+ describe("listKnownAccountDirs", () => {
310
+ test("returns ~/.claude plus every ~/.claude-accounts/<name> directory", async () => {
311
+ await mkdir(join(root, ".claude"), { recursive: true });
312
+ await mkdir(join(root, ".claude-accounts", "account1"), { recursive: true });
313
+ await mkdir(join(root, ".claude-accounts", "account2"), { recursive: true });
314
+ // A stray file must not be listed.
315
+ await writeFile(join(root, ".claude-accounts", "notes.txt"), "x");
316
+
317
+ const dirs = await listKnownAccountDirs(root);
318
+ expect(dirs).toContain(join(root, ".claude"));
319
+ expect(dirs).toContain(join(root, ".claude-accounts", "account1"));
320
+ expect(dirs).toContain(join(root, ".claude-accounts", "account2"));
321
+ expect(dirs).not.toContain(join(root, ".claude-accounts", "notes.txt"));
322
+ });
323
+
324
+ test("works when ~/.claude-accounts does not exist", async () => {
325
+ const dirs = await listKnownAccountDirs(root);
326
+ expect(dirs).toEqual([join(root, ".claude")]);
327
+ });
328
+ });
@@ -24,7 +24,7 @@
24
24
  * touching `~/`.
25
25
  */
26
26
 
27
- import { readFile, readdir, copyFile, stat } from "node:fs/promises";
27
+ import { readFile, readdir, copyFile, rename, rm, stat } from "node:fs/promises";
28
28
  import { join } from "node:path";
29
29
 
30
30
  interface CredentialsBlob {
@@ -203,3 +203,97 @@ export async function syncFreshestToSource(
203
203
  return { synced: false };
204
204
  }
205
205
  }
206
+
207
+ /**
208
+ * Known Claude account dirs a runtime's credentials could belong to:
209
+ * `~/.claude` plus every `~/.claude-accounts/<name>` (authmux's parallel
210
+ * account convention — the CLAUDE_CONFIG_DIRs its claude-<name> aliases set).
211
+ */
212
+ export async function listKnownAccountDirs(homeDir: string): Promise<string[]> {
213
+ const dirs = [join(homeDir, ".claude")];
214
+ const parallelRoot = join(homeDir, ".claude-accounts");
215
+ try {
216
+ for (const name of await readdir(parallelRoot)) {
217
+ const p = join(parallelRoot, name);
218
+ try {
219
+ if ((await stat(p)).isDirectory()) dirs.push(p);
220
+ } catch { /* unreadable entry — skip */ }
221
+ }
222
+ } catch { /* no parallel accounts */ }
223
+ return dirs;
224
+ }
225
+
226
+ /**
227
+ * Write a runtime's login-fresh credentials back to the account dir that
228
+ * OWNS them (matched by accountUuid).
229
+ *
230
+ * Why this exists: `/login` inside a cue-launched session writes tokens into
231
+ * the per-PROFILE runtime dir, not the account's CLAUDE_CONFIG_DIR. The heal
232
+ * in `syncFreshestToSource` only runs at the next launch *of that same
233
+ * account* — but runtimes are shared per profile, so when a DIFFERENT
234
+ * account launches the profile first, the account-identity guard discards
235
+ * the runtime's `.claude.json`/`.credentials.json`. Anthropic rotates the
236
+ * refresh token on every refresh, so that discarded copy was the only live
237
+ * token for the account: its source still holds a dead one → forced re-login
238
+ * every time two accounts alternate on one profile. Calling this before
239
+ * materialization (and after the session exits) returns the tokens home
240
+ * before they can be destroyed.
241
+ *
242
+ * Strictness rules mirror `syncFreshestToSource`:
243
+ * - runtime must have a readable accountUuid and non-empty refreshToken
244
+ * - the owner dir's uuid must match exactly
245
+ * - copy only when the runtime's expiresAt is *strictly* newer
246
+ * - copy is tmp + atomic rename so a concurrent reader never sees a
247
+ * partial file
248
+ */
249
+ export async function rescueRuntimeCredentials(
250
+ runtimeClaudeDir: string,
251
+ accountDirs: string[],
252
+ ): Promise<{ rescued: false } | { rescued: true; to: string; expiresAt: number }> {
253
+ const cand = await readCredentials(runtimeClaudeDir);
254
+ if (!cand || !cand.accountUuid || cand.refreshToken.trim().length === 0) return { rescued: false };
255
+
256
+ const readOwnerExpiresAt = async (dir: string): Promise<number> => {
257
+ try {
258
+ const raw = await readFile(join(dir, ".credentials.json"), "utf8");
259
+ const parsed = JSON.parse(raw) as CredentialsBlob;
260
+ return parsed?.claudeAiOauth?.expiresAt ?? 0;
261
+ } catch {
262
+ return 0; // missing/corrupt — anything is better
263
+ }
264
+ };
265
+
266
+ // Heal EVERY dir claiming this uuid, not just the first match — `~/.claude`
267
+ // and an authmux account dir can both hold the same account, and stopping
268
+ // at the first would leave the other with a dead rotated token.
269
+ const rescuedTo: string[] = [];
270
+ for (const dir of accountDirs) {
271
+ const ownerUuid = await readAccountUuid(dir);
272
+ if (ownerUuid !== cand.accountUuid) continue;
273
+ if (cand.expiresAt <= await readOwnerExpiresAt(dir)) continue;
274
+
275
+ const dest = join(dir, ".credentials.json");
276
+ try {
277
+ // tmp lives next to dest so the rename is same-device atomic; the
278
+ // cross-device step (runtime → account dir) is the copyFile into tmp,
279
+ // which a concurrent reader never sees.
280
+ const tmp = `${dest}.cue-rescue.${process.pid}`;
281
+ await copyFile(cand.path, tmp);
282
+ // Re-check freshness right before the swap: the owning account may have
283
+ // a LIVE session rotating tokens concurrently. A fresher token landing
284
+ // between the check above and this rename must win. (A write inside
285
+ // this final window can still lose — Claude Code's own writes are
286
+ // atomic renames too, so the loser is a whole consistent file, and the
287
+ // next launch's heal repairs it.)
288
+ if (cand.expiresAt <= await readOwnerExpiresAt(dir)) {
289
+ await rm(tmp, { force: true });
290
+ continue;
291
+ }
292
+ await rename(tmp, dest);
293
+ rescuedTo.push(dest);
294
+ } catch { /* best-effort per dir — keep trying the others */ }
295
+ }
296
+
297
+ if (rescuedTo.length === 0) return { rescued: false };
298
+ return { rescued: true, to: rescuedTo.join(", "), expiresAt: cand.expiresAt };
299
+ }
@@ -23,18 +23,18 @@ describe("resolveProfileForCwd", () => {
23
23
  expect(out).toEqual({ source: "none" });
24
24
  });
25
25
 
26
- test("reads .cue-profile in cwd", async () => {
27
- await writeFile(join(root, ".cue-profile"), "frontend\n");
26
+ test("reads .cue.profile in cwd", async () => {
27
+ await writeFile(join(root, ".cue.profile"), "frontend\n");
28
28
  const out = await resolveProfileForCwd({
29
29
  cwd: root,
30
30
  homeDir: root,
31
31
  configDir: join(root, ".config", "cue"),
32
32
  });
33
- expect(out).toEqual({ source: "pin-file", profile: "frontend", pinPath: join(root, ".cue-profile") });
33
+ expect(out).toEqual({ source: "pin-file", profile: "frontend", pinPath: join(root, ".cue.profile") });
34
34
  });
35
35
 
36
- test("walks up to find .cue-profile", async () => {
37
- await writeFile(join(root, ".cue-profile"), "backend\n");
36
+ test("walks up to find .cue.profile", async () => {
37
+ await writeFile(join(root, ".cue.profile"), "backend\n");
38
38
  const child = join(root, "a", "b", "c");
39
39
  await mkdir(child, { recursive: true });
40
40
  const out = await resolveProfileForCwd({
@@ -42,11 +42,11 @@ describe("resolveProfileForCwd", () => {
42
42
  homeDir: root,
43
43
  configDir: join(root, ".config", "cue"),
44
44
  });
45
- expect(out).toEqual({ source: "pin-file", profile: "backend", pinPath: join(root, ".cue-profile") });
45
+ expect(out).toEqual({ source: "pin-file", profile: "backend", pinPath: join(root, ".cue.profile") });
46
46
  });
47
47
 
48
48
  test("stops walking at homeDir", async () => {
49
- await writeFile(join(root, ".cue-profile"), "should-not-find");
49
+ await writeFile(join(root, ".cue.profile"), "should-not-find");
50
50
  const home = join(root, "home");
51
51
  const child = join(home, "user");
52
52
  await mkdir(child, { recursive: true });
@@ -100,7 +100,7 @@ describe("resolveProfileForCwd", () => {
100
100
  });
101
101
 
102
102
  test("--cue-profile flag (passed via override) wins over everything", async () => {
103
- await writeFile(join(root, ".cue-profile"), "frontend");
103
+ await writeFile(join(root, ".cue.profile"), "frontend");
104
104
  const out = await resolveProfileForCwd({
105
105
  cwd: root,
106
106
  homeDir: root,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Resolution precedence (stop at first hit):
5
5
  * 1. `opts.override` (matches the --cue-profile CLI flag)
6
- * 2. `.cue-profile` file walking up from cwd; stops at git repo root or homeDir
6
+ * 2. `.cue.profile` file walking up from cwd; stops at git repo root or homeDir
7
7
  * 3. `<configDir>/repo-defaults.json` keyed by git repo root absolute path
8
8
  * 4. `<configDir>/default-profile` (composition list: one profile per line
9
9
  * and/or `+`-joined; composed into a `core+...` selector)
@@ -81,7 +81,7 @@ async function findGitRoot(startDir: string, stopAt: string): Promise<string | n
81
81
  export async function resolveProfileForCwd(opts: ResolveOptions): Promise<ResolveResult> {
82
82
  if (opts.override) return { source: "flag", profile: opts.override };
83
83
 
84
- const pinPath = await findUpward(opts.cwd, ".cue-profile", opts.homeDir);
84
+ const pinPath = await findUpward(opts.cwd, ".cue.profile", opts.homeDir);
85
85
  if (pinPath) {
86
86
  const profile = (await readFile(pinPath, "utf8")).trim();
87
87
  if (profile) return { source: "pin-file", profile, pinPath };
@@ -12,17 +12,22 @@ import { fileURLToPath } from "node:url";
12
12
 
13
13
  import { createHandler } from "./dashboard-server";
14
14
 
15
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+ const TEST_PROFILE = "dash-merge-test";
17
+ const TEST_DIR = join(REPO_ROOT, "profiles", TEST_PROFILE);
18
+
15
19
  // Sibling test files set CUE_PROFILES_DIR / SOUL_PROFILES_DIR to a temp dir;
16
20
  // clear any leak so the dashboard resolves the repo's real profiles/ tree.
21
+ // Also clean a leftover TEST_DIR up front: the save test expects a clean slate
22
+ // (first save → created), so a dir left behind by an interrupted prior run
23
+ // would make the first save return 400 "already exists" and fail. afterAll
24
+ // alone doesn't cover that — a crashed run never reaches it. (race fix)
17
25
  beforeEach(() => {
18
26
  delete process.env.CUE_PROFILES_DIR;
19
27
  delete process.env.SOUL_PROFILES_DIR;
28
+ rmSync(TEST_DIR, { recursive: true, force: true });
20
29
  });
21
30
 
22
- const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
23
- const TEST_PROFILE = "dash-merge-test";
24
- const TEST_DIR = join(REPO_ROOT, "profiles", TEST_PROFILE);
25
-
26
31
  function post(path: string, body: unknown): Promise<Response> {
27
32
  const handler = createHandler();
28
33
  return handler(new Request(`http://x${path}`, {
@@ -61,7 +61,7 @@ export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
61
61
  */
62
62
  function resolveProfileQuery(explicit: string | null): string | null {
63
63
  if (explicit) return explicit;
64
- const pin = join(process.cwd(), ".cue-profile");
64
+ const pin = join(process.cwd(), ".cue.profile");
65
65
  if (existsSync(pin)) {
66
66
  try {
67
67
  const txt = readFileSync(pin, "utf8").trim().split("\n")[0]?.trim();
@@ -295,7 +295,7 @@ describe("buildCompanionOptions", () => {
295
295
  const full = buildConflictMap([...companionOptions, ...overflowOptions]);
296
296
  expect(resolveConflicts(["medusa-next", "medusa-vite"], full)).toEqual(["medusa-next"]);
297
297
  // Regression guard: the old curated-only map (the CRITICAL bug) let BOTH survive
298
- // into the written .cue-profile while the live UI showed the conflict blocked.
298
+ // into the written .cue.profile while the live UI showed the conflict blocked.
299
299
  const stale = buildConflictMap(companionOptions);
300
300
  expect(resolveConflicts(["medusa-next", "medusa-vite"], stale)).toEqual([
301
301
  "medusa-next",
package/src/lib/picker.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * - renderProfileList(): pure formatter (testable)
6
6
  * - runPicker(): interactive TUI driven by @clack/prompts; opens stdin/stdout
7
7
  *
8
- * Picker writes the chosen profile to ./.cue-profile unless --no-pin is passed.
8
+ * Picker writes the chosen profile to ./.cue.profile unless --no-pin is passed.
9
9
  * Cancel (esc / Ctrl-C) → exit code 130 (caller handles).
10
10
  */
11
11
 
@@ -80,7 +80,7 @@ export function renderProfileList(opts: PickerOption[], render: RenderOptions):
80
80
  export interface PickerInput {
81
81
  cwd: string;
82
82
  options: PickerOption[];
83
- /** Skip writing .cue-profile if true. */
83
+ /** Skip writing .cue.profile if true. */
84
84
  noPin?: boolean;
85
85
  /**
86
86
  * Optional hook invoked after the user picks a profile (and pin confirm),
@@ -701,7 +701,7 @@ export function compressCombo(parts: string[], max = 3): string {
701
701
  * Control sentinels (SHOW_ALL / SKIP_COMBINE) are dropped here as a write-
702
702
  * boundary backstop: the upstream filters (asciiMultiselect strips SHOW_ALL,
703
703
  * runPicker guards on SKIP_COMBINE) are the primary defense, but this is the
704
- * last transform before the selector is joined and persisted to .cue-profile,
704
+ * last transform before the selector is joined and persisted to .cue.profile,
705
705
  * so a sentinel must never survive it even if an upstream path regresses.
706
706
  */
707
707
  const CONTROL_SENTINELS = new Set<string>([SHOW_ALL, SKIP_COMBINE]);
@@ -1050,7 +1050,7 @@ async function asciiMultiselect(opts: {
1050
1050
  // after "show all" is revealed. resolveConflicts acts only on values actually
1051
1051
  // selected, so seeding the map with not-yet-revealed options is harmless — and
1052
1052
  // skipping them is the CRITICAL bug where medusa-vite + medusa-next both
1053
- // survive into the written .cue-profile.
1053
+ // survive into the written .cue.profile.
1054
1054
  const conflictMap = buildConflictMap([
1055
1055
  ...opts.options,
1056
1056
  ...(opts.overflow?.options ?? []),
@@ -1484,7 +1484,7 @@ export async function runPicker(input: PickerInput): Promise<PickerOutput> {
1484
1484
  process.exit(130);
1485
1485
  }
1486
1486
  if (pinChoice === true) {
1487
- await writeFile(join(input.cwd, ".cue-profile"), `${choice}\n`);
1487
+ await writeFile(join(input.cwd, ".cue.profile"), `${choice}\n`);
1488
1488
  pinned = true;
1489
1489
  }
1490
1490
  }
@@ -44,6 +44,14 @@ describe("mergeProfiles", () => {
44
44
  expect(p.skills.some((id) => id.startsWith("design/"))).toBe(true);
45
45
  });
46
46
 
47
+ test("core pins subagents to Sonnet (cost-knob regression guard)", async () => {
48
+ // A future core edit must not silently drop this — it fans out to every
49
+ // inheriting profile and is the one automatic Opus→Sonnet lever. Surfaced
50
+ // into settings.json `env` by buildClaudeSettings (runtime-materializer).
51
+ const core = await loadProfile("core");
52
+ expect(core.env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-sonnet-4-6");
53
+ });
54
+
47
55
  test("skill conflicts are deduped to unique pairs (not per-directive)", async () => {
48
56
  const p = await mergeProfiles(["medusa-dev", "designer"]);
49
57
  const keys = p.skillConflicts.map((c) => [c.skillA, c.skillB].sort().join("|") + c.domain);
@@ -14,7 +14,7 @@ import { describe, expect, test } from "bun:test";
14
14
  import { readdirSync, existsSync } from "node:fs";
15
15
  import { join, resolve } from "node:path";
16
16
 
17
- import { SIGNALS } from "./auto-detect";
17
+ import { DEP_PROFILE_RULES } from "./auto-detect";
18
18
  import { STACK_PROFILES, PROFILE_KEYWORDS as DISCOVER_KEYWORDS } from "../commands/discover";
19
19
  import { PROFILE_KEYWORDS as AI_KEYWORDS } from "../commands/ai";
20
20
 
@@ -36,8 +36,8 @@ describe("profile-name references resolve to real profiles", () => {
36
36
  expect(real.size).toBeGreaterThan(10);
37
37
  });
38
38
 
39
- test("auto-detect SIGNALS reference only real profiles", () => {
40
- const missing = [...new Set(SIGNALS.map((s) => s.profile))].filter((p) => !real.has(p));
39
+ test("auto-detect DEP_PROFILE_RULES reference only real profiles", () => {
40
+ const missing = [...new Set(DEP_PROFILE_RULES.map((r) => r.profile))].filter((p) => !real.has(p));
41
41
  expect(missing).toEqual([]);
42
42
  });
43
43
 
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Shared profile-runtime preparation helpers.
3
+ *
4
+ * `launch` and `install` both need the same boring setup: wildcard expansion,
5
+ * MCP registry loading, user memory reads, and the materializeRuntime call.
6
+ * Keeping it here avoids the installer drifting from the hot launch path.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { readFile } from "node:fs/promises";
11
+ import { join, resolve } from "node:path";
12
+ import { homedir } from "node:os";
13
+
14
+ import type { AgentKind, ResolvedProfile } from "../../profiles/_types";
15
+ import { configDir } from "./config-paths";
16
+ import { debug } from "./debug-log";
17
+ import { listAllSkillIds, resolveLocalSkill } from "./resolver-local";
18
+ import { materializeRuntime, type McpServerConfig, type MaterializeOutput } from "./runtime-materializer";
19
+
20
+ export type RuntimeAgent = Extract<AgentKind, "claude-code" | "codex">;
21
+
22
+ export const RUNTIME_AGENTS: RuntimeAgent[] = ["claude-code", "codex"];
23
+
24
+ export function isRuntimeAgent(agent: AgentKind | string): agent is RuntimeAgent {
25
+ return agent === "claude-code" || agent === "codex";
26
+ }
27
+
28
+ export function runtimeAgentSubdir(agent: RuntimeAgent): "claude" | "codex" {
29
+ return agent === "claude-code" ? "claude" : "codex";
30
+ }
31
+
32
+ export function runtimeDirFor(profileName: string, agent: RuntimeAgent, runtimeRoot = join(configDir(), "runtime")): string {
33
+ return join(runtimeRoot, profileName, runtimeAgentSubdir(agent));
34
+ }
35
+
36
+ export async function expandSkillWildcards(profile: ResolvedProfile): Promise<void> {
37
+ if (!profile.skills.local.some((s) => s.id === "*/*")) return;
38
+ const allIds = await listAllSkillIds();
39
+ const wildcard = profile.skills.local.find((s) => s.id === "*/*")!;
40
+ const existing = new Set(profile.skills.local.filter((s) => s.id !== "*/*").map((s) => s.id));
41
+ profile.skills.local = [
42
+ ...profile.skills.local.filter((s) => s.id !== "*/*"),
43
+ ...allIds.filter((id) => !existing.has(id)).map((id) => ({ ...wildcard, id })),
44
+ ];
45
+ }
46
+
47
+ export async function loadMcpRegistry(agent: RuntimeAgent): Promise<Record<string, McpServerConfig>> {
48
+ const root = process.env.CUE_REPO_ROOT ?? process.env.SOUL_REPO_ROOT ?? resolve(import.meta.dirname, "..", "..");
49
+ const files = agent === "claude-code"
50
+ ? ["claude_runtime.sanitized.json", "claude.sanitized.json"]
51
+ : ["codex.sanitized.json"];
52
+
53
+ const merged: Record<string, McpServerConfig> = {};
54
+ for (const file of files) {
55
+ try {
56
+ const text = await readFile(join(root, "resources", "mcps", "configs", file), "utf8");
57
+ const raw = JSON.parse(text) as { servers?: Record<string, McpServerConfig> };
58
+ for (const [id, config] of Object.entries(raw.servers ?? {})) {
59
+ if (!(id in merged)) merged[id] = config;
60
+ }
61
+ } catch {
62
+ // Missing registries are tolerated; validate/doctor report broken refs.
63
+ }
64
+ }
65
+
66
+ // The curated master registry wins over the runtime snapshot.
67
+ const master = agent === "claude-code" ? "claude.sanitized.json" : "codex.sanitized.json";
68
+ try {
69
+ const text = await readFile(join(root, "resources", "mcps", "configs", master), "utf8");
70
+ const raw = JSON.parse(text) as { servers?: Record<string, McpServerConfig> };
71
+ for (const [id, config] of Object.entries(raw.servers ?? {})) {
72
+ merged[id] = config;
73
+ }
74
+ } catch (err) {
75
+ debug("runtime-install:master-config", err);
76
+ }
77
+
78
+ return merged;
79
+ }
80
+
81
+ export async function readUserAgentMemory(agent: RuntimeAgent): Promise<string> {
82
+ const path = agent === "claude-code"
83
+ ? join(homedir(), ".claude", "CLAUDE.md")
84
+ : join(homedir(), ".codex", "AGENTS.md");
85
+ try {
86
+ return await readFile(path, "utf8");
87
+ } catch {
88
+ return "";
89
+ }
90
+ }
91
+
92
+ export async function pickClaudeCredentialsSource(): Promise<string> {
93
+ if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR;
94
+
95
+ const homeClaude = join(homedir(), ".claude");
96
+ if (existsSync(join(homeClaude, ".credentials.json"))) return homeClaude;
97
+
98
+ try {
99
+ const { spawnSync } = await import("node:child_process");
100
+ const { statSync } = await import("node:fs");
101
+ const res = spawnSync("authmux", ["parallel", "--list", "--json"], {
102
+ encoding: "utf8",
103
+ timeout: 3000,
104
+ stdio: ["ignore", "pipe", "pipe"],
105
+ });
106
+ if (res.status === 0 && res.stdout) {
107
+ const parsed = JSON.parse(res.stdout) as { data?: { profiles?: Array<{ name: string; configDir: string }> } };
108
+ const profiles = parsed?.data?.profiles ?? [];
109
+ const withMtime = profiles
110
+ .map((p) => {
111
+ const credsPath = join(p.configDir, ".credentials.json");
112
+ let mtime = 0;
113
+ try { mtime = statSync(credsPath).mtimeMs; } catch { /* missing */ }
114
+ return { ...p, mtime };
115
+ })
116
+ .filter((p) => p.mtime > 0)
117
+ .sort((a, b) => b.mtime - a.mtime);
118
+ const pick = withMtime[0];
119
+ if (pick) {
120
+ process.stderr.write(`▸ cue: inheriting auth from authmux profile "${pick.name}"\n`);
121
+ return pick.configDir;
122
+ }
123
+ }
124
+ } catch {
125
+ // authmux not installed or query failed.
126
+ }
127
+
128
+ return homeClaude;
129
+ }
130
+
131
+ export async function resolveClaudeCredentialsSource(options: { healFromRuntime?: boolean } = {}): Promise<string> {
132
+ const picked = await pickClaudeCredentialsSource();
133
+ if (!options.healFromRuntime) return picked;
134
+
135
+ try {
136
+ const { syncFreshestToSource } = await import("./credentials-sync");
137
+ const result = await syncFreshestToSource(picked, join(configDir(), "runtime"));
138
+ if (result.synced) {
139
+ process.stderr.write(
140
+ `▸ cue: refreshed source credentials from a sibling runtime (rotated refresh-token healed)\n`,
141
+ );
142
+ }
143
+ } catch (err) {
144
+ debug("runtime-install:runtime-heal", err);
145
+ }
146
+ return picked;
147
+ }
148
+
149
+ export interface PrepareRuntimeOptions {
150
+ profile: ResolvedProfile;
151
+ agent: RuntimeAgent;
152
+ userMemory?: string;
153
+ credentialsSource?: string;
154
+ }
155
+
156
+ export async function prepareRuntime(options: PrepareRuntimeOptions): Promise<MaterializeOutput> {
157
+ return materializeRuntime({
158
+ profile: options.profile,
159
+ agent: options.agent,
160
+ runtimeRoot: join(configDir(), "runtime"),
161
+ skillSourceLookup: (id) => resolveLocalSkill(id),
162
+ mcpRegistry: await loadMcpRegistry(options.agent),
163
+ userClaudeMd: options.userMemory ?? await readUserAgentMemory(options.agent),
164
+ credentialsSource: options.credentialsSource,
165
+ });
166
+ }