magus-cli 0.0.0-placeholder → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,175 @@
1
+ import { RGBA } from "@opentui/core";
2
+
3
+ /**
4
+ * magus's palette: our own vivid accents, on the terminal's own canvas.
5
+ *
6
+ * Three rules, and the reason for each:
7
+ *
8
+ * 1. **Backgrounds are never ours.** Surfaces and modals use the terminal's
9
+ * background. Painting an absolute fill boxes the UI into whichever theme we
10
+ * guessed, and it was the original sin here — modals hardcoded #1C1C1E.
11
+ * 2. **Body text is never ours.** Normal text uses the terminal's foreground,
12
+ * which is the only colour guaranteed to clear a text-grade contrast ratio
13
+ * against that terminal's own background. `text` used to be the string
14
+ * `"white"` — OpenTUI resolves that to rgb(255,255,255), not to "the
15
+ * terminal's white" — so on a light theme every installed plugin was drawn
16
+ * white-on-cream and vanished while the *un*installed ones, in grey, stayed
17
+ * readable. The list read exactly backwards.
18
+ * 3. **Accents ARE ours, and vivid.** Deferring accents to ANSI slots 0-15 made
19
+ * everything legible but flattened it: a theme that renders slots 2 and 3 as
20
+ * neighbouring olives leaves user/project/local scope squares indistinguishable,
21
+ * and slot 8 chips melt into the page. Distinction is a product decision, so
22
+ * these are chosen, not inherited.
23
+ *
24
+ * ## How the accents were chosen
25
+ *
26
+ * By measurement, not taste. A single colour CANNOT reach 4.5:1 against both a
27
+ * light and a dark terminal — clearing 4.5:1 on cream needs relative luminance
28
+ * <= ~0.17, clearing it on near-black needs >= ~0.22, and those do not overlap.
29
+ * So the bar is 3:1, WCAG's threshold for UI components and large text, which
30
+ * admits a band of mid-dark saturated colours. Every accent below clears 3:1
31
+ * against BOTH reference backgrounds, verified in theme-adaptive-colors.test.ts —
32
+ * so a colour that only works on dark cannot be added without failing the build.
33
+ *
34
+ * Where we own a badge's background we also own its ink, so `ink` is plain white
35
+ * and its contrast is deterministic rather than a function of the user's theme.
36
+ * That is why badge ink is not slot 0 or the terminal background: on a light
37
+ * theme, terminal-background ink on a yellow chip is unreadable.
38
+ */
39
+
40
+ /** Reference backgrounds every accent is measured against. */
41
+ export const CONTRAST_REFERENCE = {
42
+ light: "#FAFAD2",
43
+ dark: "#1C1C1E",
44
+ } as const;
45
+
46
+ /**
47
+ * The vivid set. Ratios are (on light / on dark), measured — see the test.
48
+ * Hues for the three scopes are deliberately far apart (blue / green / amber)
49
+ * because they appear as single-character squares, where a subtle hue shift is
50
+ * not readable at all.
51
+ */
52
+ export const brand = {
53
+ accent: "#9333EA", // 5.04 / 3.16 — brand purple
54
+ link: "#2563EB", // 4.84 / 3.29
55
+ info: "#0E7490", // 5.02 / 3.18 — teal
56
+ success: "#15803D", // 4.70 / 3.39
57
+ warning: "#B45309", // 4.70 / 3.39 — amber
58
+ danger: "#DC2626", // 4.52 / 3.52
59
+ muted: "#6B7280", // 4.53 / 3.52 — de-emphasis, still legible on both
60
+ /** Ink on OUR badges. We own both sides, so contrast is deterministic. */
61
+ ink: "#FFFFFF",
62
+ /**
63
+ * De-emphasised ink on the SELECTION fill, and nowhere else.
64
+ *
65
+ * Measured on a screenshot: `muted` (#6B7280) on the selection purple is
66
+ * 1.11:1, `warning` 1.07:1, `danger` 1.11:1 — every meta tone was invisible
67
+ * the moment its row was selected, and `(default)` on the Models screen
68
+ * disappeared entirely. The page-level greys cannot help here: they are
69
+ * chosen to sit between a cream and a near-black terminal, and the selection
70
+ * is neither.
71
+ *
72
+ * 3.64:1 on the selection fill, and 1.48:1 against `ink` — far enough from
73
+ * white to still read as secondary. Like `ink`, it is measured against a fill
74
+ * WE paint rather than against the terminal, so it is exempt from the
75
+ * both-backgrounds rule the accents above obey.
76
+ */
77
+ selectionDim: "#D4D4D8",
78
+ } as const;
79
+
80
+ /** Any colour a component may hand to a JSX colour prop. */
81
+ export type UiColor = string | RGBA;
82
+
83
+ /** The terminal's own foreground — the only honest "normal text" colour. */
84
+ const fg = () => RGBA.defaultForeground();
85
+
86
+ /** The terminal's own background — for surfaces, never for ink. */
87
+ const bg = () => RGBA.defaultBackground();
88
+
89
+ export const theme = {
90
+ colors: {
91
+ /** Adaptive by rule 2 — never make this one of ours. */
92
+ text: fg(),
93
+ muted: brand.muted,
94
+ /** Same grey as `muted`; a third level should come from layout, not colour. */
95
+ dim: brand.muted,
96
+ border: brand.muted,
97
+ link: brand.link,
98
+ accent: brand.accent,
99
+ /** Low-key brand tint for bylines — readable, deliberately not attention-grabbing. */
100
+ byline: brand.accent,
101
+ success: brand.success,
102
+ warning: brand.warning,
103
+ danger: brand.danger,
104
+ info: brand.info,
105
+ },
106
+
107
+ /**
108
+ * The selected row owns the colour channel.
109
+ *
110
+ * A row's normal palette is chosen against the TERMINAL's background; on the
111
+ * selection fill none of it clears even 1.2:1. So a selected row has exactly
112
+ * two inks — `fg` for what it is saying, `dim` for what it is saying quietly —
113
+ * and any tone a row would otherwise carry is surrendered while it is
114
+ * selected. The words survive; the hue does not, and cannot.
115
+ */
116
+ selection: {
117
+ bg: brand.accent,
118
+ fg: brand.ink,
119
+ dim: brand.selectionDim,
120
+ },
121
+
122
+ /** Blue / green / amber: the widest hue separation available, because these
123
+ * render as one-character squares where a subtle shift reads as noise. */
124
+ scopes: {
125
+ user: brand.link,
126
+ project: brand.success,
127
+ local: brand.warning,
128
+ },
129
+
130
+ /** Badge colours. `fg` is the ink ON the block; `badgeFg` is the same hue used
131
+ * as ink on the normal background instead. */
132
+ category: {
133
+ purple: { bg: brand.accent, fg: brand.ink, badgeFg: brand.accent },
134
+ green: { bg: brand.success, fg: brand.ink, badgeFg: brand.success },
135
+ teal: { bg: brand.info, fg: brand.ink, badgeFg: brand.info },
136
+ yellow: { bg: brand.warning, fg: brand.ink, badgeFg: brand.warning },
137
+ gray: { bg: brand.muted, fg: brand.ink, badgeFg: brand.muted },
138
+ red: { bg: brand.danger, fg: brand.ink, badgeFg: brand.danger },
139
+ },
140
+
141
+ meta: {
142
+ muted: brand.muted,
143
+ warning: brand.warning,
144
+ success: brand.success,
145
+ danger: brand.danger,
146
+ },
147
+
148
+ /**
149
+ * Footer key chips. A vivid block with white ink, because the point of a chip
150
+ * is to read as a pressable key at a glance — the previous neutral-grey chip
151
+ * melted into a light page. Dark slates were measured and rejected: they clear
152
+ * 3:1 on cream but drop to ~2.2 on a dark terminal, i.e. an invisible block.
153
+ */
154
+ hints: {
155
+ defaultBg: brand.accent,
156
+ primaryBg: brand.info,
157
+ dangerBg: brand.danger,
158
+ fg: brand.ink,
159
+ },
160
+
161
+ /** Panels and modals sit on the terminal's own background and are separated by
162
+ * their border, never by an absolute fill that fights the user's theme. */
163
+ surface: {
164
+ bg: bg(),
165
+ border: brand.muted,
166
+ },
167
+
168
+ spacing: {
169
+ rowIndent: 1,
170
+ sectionGap: 1,
171
+ panelPadding: 1,
172
+ },
173
+ } as const;
174
+
175
+ export { fg as defaultFg, bg as defaultBg };
@@ -0,0 +1,58 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ /**
4
+ * Write text to the system clipboard.
5
+ * macOS: uses pbcopy
6
+ * Linux: uses xclip
7
+ * Throws ClipboardUnavailableError if no clipboard tool is available.
8
+ */
9
+ export async function writeClipboard(text: string): Promise<void> {
10
+ if (process.platform === "darwin") {
11
+ execSync("pbcopy", { input: text });
12
+ } else if (process.platform === "linux") {
13
+ execSync("xclip -selection clipboard", { input: text });
14
+ } else {
15
+ throw new ClipboardUnavailableError(
16
+ `Clipboard not supported on platform: ${process.platform}`,
17
+ );
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Read text from the system clipboard.
23
+ * macOS: uses pbpaste
24
+ * Linux: uses xclip
25
+ * Throws ClipboardUnavailableError if no clipboard tool is available.
26
+ */
27
+ export async function readClipboard(): Promise<string> {
28
+ if (process.platform === "darwin") {
29
+ return execSync("pbpaste").toString();
30
+ }
31
+ if (process.platform === "linux") {
32
+ return execSync("xclip -selection clipboard -o").toString();
33
+ }
34
+ throw new ClipboardUnavailableError(
35
+ `Clipboard not supported on platform: ${process.platform}`,
36
+ );
37
+ }
38
+
39
+ export class ClipboardUnavailableError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "ClipboardUnavailableError";
43
+ }
44
+ }
45
+
46
+ /** Check if clipboard operations are available on this platform/system */
47
+ export function isClipboardAvailable(): boolean {
48
+ if (process.platform === "darwin") return true;
49
+ if (process.platform === "linux") {
50
+ try {
51
+ execSync("which xclip", { stdio: "ignore" });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+ return false;
58
+ }
@@ -0,0 +1,149 @@
1
+ import { constants, accessSync, statSync } from "node:fs";
2
+ import { access, realpath } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * A command as an executable plus its already-separated arguments.
7
+ *
8
+ * The alternative — a single string handed to `/bin/sh` — has cost this
9
+ * codebase twice, in two different ways, and both were shipped rather than
10
+ * hypothetical:
11
+ *
12
+ * 1. **A word the shell could not resolve.** `pip install <pkg>` died with
13
+ * `/bin/sh: pip: command not found` on machines where `pip` is only an
14
+ * interactive-zsh alias. `services/toolchain.ts` fixed *which word* gets
15
+ * emitted; it could not fix that the words are re-parsed at spawn time.
16
+ * 2. **Package names are re-parsed as shell syntax.** The builders are fed
17
+ * `bin.package` / `bin.formula` / `bin.module`, which come from
18
+ * `.claude/profiles.json` and from plugin `requires.bin` — hand-authored,
19
+ * committed by teammates, fetched from marketplaces. Under `shell: true`
20
+ * those go through word-splitting and `$(…)` expansion. Today's catalogue is
21
+ * benign; the surface is not, and nothing checks it.
22
+ *
23
+ * As argv there is no shell, so a package name is one token no matter what
24
+ * characters it contains, and `cmd` must name a real executable rather than
25
+ * anything PATH-plus-a-profile might have meant.
26
+ *
27
+ * `services/plugin-setup.ts` arrived at this same shape independently for the
28
+ * MCP dependency installer. This is the codebase agreeing with itself.
29
+ */
30
+ export interface Command {
31
+ readonly cmd: string;
32
+ readonly args: readonly string[];
33
+ }
34
+
35
+ /**
36
+ * Characters that need no quoting in any POSIX shell.
37
+ *
38
+ * Deliberately conservative, and the reason {@link formatCommand} is display-
39
+ * neutral: every token magus actually builds — `@anthropic-ai/claude-code`,
40
+ * `claudish@1.2.0`, `aider==0.1`, `github.com/x/t@latest`, `--upgrade` — is
41
+ * made only of these, so quoting never fires for real input and the rendered
42
+ * string is byte-identical to the one this codebase printed before commands
43
+ * became argv. `doctor-bins.test.ts` is the gate on that claim.
44
+ */
45
+ const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
46
+
47
+ /**
48
+ * Render a {@link Command} as the shell line a human would type.
49
+ *
50
+ * FOR DISPLAY ONLY. Nothing may feed this back to a shell — that would undo the
51
+ * whole point of carrying argv. It exists so `doctor` can print "run: brew
52
+ * install tmux" and the updater can show what it is about to do.
53
+ *
54
+ * A token outside {@link SHELL_SAFE} is single-quoted, with `'` closed,
55
+ * escaped and reopened (`'\''`) — the only POSIX-portable escape, since single
56
+ * quotes have no escape character inside them. An empty token quotes too:
57
+ * unquoted it would vanish, which would misrepresent the argv it came from.
58
+ */
59
+ export function formatCommand(c: Command): string {
60
+ return [c.cmd, ...c.args].map(quoteToken).join(" ");
61
+ }
62
+
63
+ function quoteToken(token: string): string {
64
+ if (SHELL_SAFE.test(token)) return token;
65
+ return `'${token.replaceAll("'", `'\\''`)}'`;
66
+ }
67
+
68
+ /**
69
+ * Check if a command exists in PATH.
70
+ *
71
+ * Async only for its callers' sake — it spawns nothing. This used to run
72
+ * `execFileAsync("which", [command])`, a real `which(1)` process with no
73
+ * timeout, on every one of its dozen call sites. Answering "is bun on PATH?"
74
+ * does not need a process, and under load that process is what stalls:
75
+ * `detectToolchains` calls this per toolchain, and in a 103-file parallel test
76
+ * run the spawn queue backed up until the test failed at **20,007 ms** — after
77
+ * the per-test timeout had already been raised from 5s to 20s to accommodate
78
+ * the earlier 5s failures. Raising the limit moved the goalposts; removing the
79
+ * spawn removes the stall.
80
+ *
81
+ * {@link whichSync} is also STRICTER than `which(1)`, which is a bonus rather
82
+ * than a risk: it stats the candidate and checks the execute bit, so a dangling
83
+ * symlink answers null here instead of reporting the link's own path. That is
84
+ * the exact failure {@link resolveExecutable} was written to catch afterwards.
85
+ *
86
+ * @param command - Command name to check, or an explicit path
87
+ * @returns Path to command if found, null otherwise
88
+ */
89
+ export async function which(command: string): Promise<string | null> {
90
+ return whichSync(command);
91
+ }
92
+
93
+ /**
94
+ * Synchronous PATH lookup — walks `$PATH` rather than spawning `which(1)`.
95
+ *
96
+ * Synchronous on purpose. The installer-command builders in
97
+ * `services/toolchain.ts` are sync and used by printers; making them async just
98
+ * to answer "does this machine have uv?" would ripple through every caller.
99
+ *
100
+ * Node's `fs` is used rather than `Bun.which` so this needs no `@types/bun`,
101
+ * whose globals collide with `@types/node` in this project.
102
+ *
103
+ * Unlike {@link resolveExecutable} this does NOT follow symlinks to prove the
104
+ * target still exists — use it to pick between candidates, not to prove a
105
+ * binary works.
106
+ */
107
+ export function whichSync(command: string): string | null {
108
+ // An explicit path is not a PATH lookup; honour it as given.
109
+ if (command.includes("/")) {
110
+ return isExecutableFile(command) ? command : null;
111
+ }
112
+ for (const dir of (process.env.PATH ?? "").split(":")) {
113
+ if (!dir) continue;
114
+ const candidate = join(dir, command);
115
+ if (isExecutableFile(candidate)) return candidate;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function isExecutableFile(p: string): boolean {
121
+ try {
122
+ if (!statSync(p).isFile()) return false;
123
+ accessSync(p, constants.X_OK);
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Like {@link which}, but verifies the command actually RESOLVES and is
132
+ * executable — a dangling symlink (e.g. a stale npx-cache link) returns null.
133
+ * `which` alone reports the symlink's own path even when its target is gone,
134
+ * which is exactly how a plugin's binary can look "present" yet fail at runtime.
135
+ * @returns The real (symlink-resolved) executable path, or null.
136
+ */
137
+ export async function resolveExecutable(
138
+ command: string,
139
+ ): Promise<string | null> {
140
+ const p = await which(command);
141
+ if (!p) return null;
142
+ try {
143
+ const real = await realpath(p); // follows symlinks; throws if dangling
144
+ await access(real, constants.X_OK); // must be executable
145
+ return real;
146
+ } catch {
147
+ return null; // dangling link or not executable
148
+ }
149
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * config-dir.ts — where Claude Code's config lives, with a test guard.
3
+ *
4
+ * `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured. It is
5
+ * resolved per call rather than captured at import time, because a module-level
6
+ * `os.homedir()` bakes in the operator's real directory and cannot be overridden
7
+ * afterwards.
8
+ *
9
+ * The test guard
10
+ * --------------
11
+ * Under `bun test`, a missing `CLAUDE_CONFIG_DIR` returns null instead of
12
+ * `~/.claude`. Callers treat null as "no cache, no clone" — an empty, inert state.
13
+ *
14
+ * This is not defensive decoration. Once the catalog cache and the rate-limit
15
+ * cooldown moved to disk, and the catalog resolver gained a `git fetch` fallback,
16
+ * any test that had not isolated its config dir began reading the operator's real
17
+ * cooldown file and fetching from their real marketplace clones. The suite went
18
+ * from 6s to 59s and started failing intermittently, because results depended on
19
+ * whether this particular machine happened to be rate-limited at that moment.
20
+ *
21
+ * A test that forgets to isolate should get nothing, not the developer's live
22
+ * state. Production is unaffected: NODE_ENV is not "test" there.
23
+ */
24
+
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+
28
+ /**
29
+ * The Claude config directory, or null when a test has not chosen one.
30
+ *
31
+ * Callers that must always have a path (writing real user config) should use
32
+ * `requireClaudeConfigDir`. Callers holding regenerable state — caches — should
33
+ * treat null as "cache unavailable" and carry on.
34
+ */
35
+ export function claudeConfigDirOrNull(): string | null {
36
+ const explicit = process.env.CLAUDE_CONFIG_DIR;
37
+ if (explicit) return explicit;
38
+ if (process.env.NODE_ENV === "test") return null;
39
+ return path.join(os.homedir(), ".claude");
40
+ }
41
+
42
+ /** The Claude config directory, falling back to `~/.claude` even under test. */
43
+ export function requireClaudeConfigDir(): string {
44
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
45
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * File locking utilities for concurrent write protection
3
+ *
4
+ * Prevents race conditions when multiple processes modify settings files
5
+ * Uses lock files for cross-platform compatibility
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { mkdir, open } from "node:fs/promises";
10
+ import type { FileHandle } from "node:fs/promises";
11
+ import { dirname } from "node:path";
12
+
13
+ /**
14
+ * Maximum time to wait for a lock (milliseconds)
15
+ */
16
+ const LOCK_TIMEOUT_MS = 10000;
17
+
18
+ /**
19
+ * Lock retry interval (milliseconds)
20
+ */
21
+ const LOCK_RETRY_INTERVAL_MS = 100;
22
+
23
+ /**
24
+ * Acquire exclusive lock on file and execute operation
25
+ * Uses lock files for cross-platform compatibility
26
+ *
27
+ * @param filePath - Path to file to lock
28
+ * @param operation - Async operation to execute with lock held
29
+ * @returns Result of the operation
30
+ * @throws Error if lock cannot be acquired within timeout
31
+ */
32
+ export async function withFileLock<T>(
33
+ filePath: string,
34
+ operation: () => Promise<T>,
35
+ ): Promise<T> {
36
+ const lockPath = `${filePath}.lock`;
37
+ const lockDir = dirname(lockPath);
38
+
39
+ // Ensure lock directory exists
40
+ if (!existsSync(lockDir)) {
41
+ await mkdir(lockDir, { recursive: true });
42
+ }
43
+
44
+ const startTime = Date.now();
45
+ let fileHandle: FileHandle | null = null;
46
+
47
+ // Try to acquire lock with retries
48
+ while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
49
+ try {
50
+ // Try to create lock file exclusively (fails if exists)
51
+ fileHandle = await open(lockPath, "wx");
52
+
53
+ // Write PID to lock file for debugging
54
+ await fileHandle.write(`${process.pid}\n${new Date().toISOString()}\n`);
55
+
56
+ break;
57
+ } catch (error: unknown) {
58
+ // Lock file exists, retry after interval
59
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
60
+ await new Promise((resolve) =>
61
+ setTimeout(resolve, LOCK_RETRY_INTERVAL_MS),
62
+ );
63
+ continue;
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ if (fileHandle === null) {
70
+ throw new Error(
71
+ `Failed to acquire lock on ${filePath} after ${LOCK_TIMEOUT_MS}ms. ` +
72
+ `Another process may be holding the lock. Lock file: ${lockPath}`,
73
+ );
74
+ }
75
+
76
+ try {
77
+ // Execute operation with lock held
78
+ return await operation();
79
+ } finally {
80
+ // Release lock
81
+ try {
82
+ await fileHandle.close();
83
+ // Delete lock file
84
+ const fs = await import("node:fs/promises");
85
+ await fs.unlink(lockPath);
86
+ } catch (error) {
87
+ // Log error but don't throw - operation succeeded
88
+ console.error(`Warning: Failed to release lock ${lockPath}:`, error);
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Check if file is currently locked
95
+ *
96
+ * @param filePath - Path to file to check
97
+ * @returns True if lock file exists
98
+ */
99
+ export function isFileLocked(filePath: string): boolean {
100
+ const lockPath = `${filePath}.lock`;
101
+ return existsSync(lockPath);
102
+ }
103
+
104
+ /**
105
+ * Force remove lock file (use with caution)
106
+ * Only use if you're certain the lock is stale
107
+ *
108
+ * @param filePath - Path to file whose lock to remove
109
+ */
110
+ export async function forceRemoveLock(filePath: string): Promise<void> {
111
+ const lockPath = `${filePath}.lock`;
112
+ try {
113
+ const fs = await import("node:fs/promises");
114
+ await fs.unlink(lockPath);
115
+ } catch (error: unknown) {
116
+ // Ignore ENOENT (lock doesn't exist)
117
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
118
+ throw error;
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Get information about current lock
125
+ *
126
+ * @param filePath - Path to file to check
127
+ * @returns Lock info or null if not locked
128
+ */
129
+ export async function getLockInfo(
130
+ filePath: string,
131
+ ): Promise<{ pid: string; timestamp: string } | null> {
132
+ const lockPath = `${filePath}.lock`;
133
+
134
+ if (!existsSync(lockPath)) {
135
+ return null;
136
+ }
137
+
138
+ try {
139
+ const fs = await import("node:fs/promises");
140
+ const content = await fs.readFile(lockPath, "utf-8");
141
+ const [pid, timestamp] = content.trim().split("\n");
142
+ return { pid, timestamp };
143
+ } catch (error) {
144
+ return null;
145
+ }
146
+ }