magus-cli 0.0.0-placeholder → 7.0.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 (331) 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 +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -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 +445 -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 +174 -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 +335 -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__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -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,144 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants, accessSync, statSync } from "node:fs";
3
+ import { access, realpath } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ /**
10
+ * A command as an executable plus its already-separated arguments.
11
+ *
12
+ * The alternative — a single string handed to `/bin/sh` — has cost this
13
+ * codebase twice, in two different ways, and both were shipped rather than
14
+ * hypothetical:
15
+ *
16
+ * 1. **A word the shell could not resolve.** `pip install <pkg>` died with
17
+ * `/bin/sh: pip: command not found` on machines where `pip` is only an
18
+ * interactive-zsh alias. `services/toolchain.ts` fixed *which word* gets
19
+ * emitted; it could not fix that the words are re-parsed at spawn time.
20
+ * 2. **Package names are re-parsed as shell syntax.** The builders are fed
21
+ * `bin.package` / `bin.formula` / `bin.module`, which come from
22
+ * `.claude/profiles.json` and from plugin `requires.bin` — hand-authored,
23
+ * committed by teammates, fetched from marketplaces. Under `shell: true`
24
+ * those go through word-splitting and `$(…)` expansion. Today's catalogue is
25
+ * benign; the surface is not, and nothing checks it.
26
+ *
27
+ * As argv there is no shell, so a package name is one token no matter what
28
+ * characters it contains, and `cmd` must name a real executable rather than
29
+ * anything PATH-plus-a-profile might have meant.
30
+ *
31
+ * `services/plugin-setup.ts` arrived at this same shape independently for the
32
+ * MCP dependency installer. This is the codebase agreeing with itself.
33
+ */
34
+ export interface Command {
35
+ readonly cmd: string;
36
+ readonly args: readonly string[];
37
+ }
38
+
39
+ /**
40
+ * Characters that need no quoting in any POSIX shell.
41
+ *
42
+ * Deliberately conservative, and the reason {@link formatCommand} is display-
43
+ * neutral: every token magus actually builds — `@anthropic-ai/claude-code`,
44
+ * `claudish@1.2.0`, `aider==0.1`, `github.com/x/t@latest`, `--upgrade` — is
45
+ * made only of these, so quoting never fires for real input and the rendered
46
+ * string is byte-identical to the one this codebase printed before commands
47
+ * became argv. `doctor-bins.test.ts` is the gate on that claim.
48
+ */
49
+ const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
50
+
51
+ /**
52
+ * Render a {@link Command} as the shell line a human would type.
53
+ *
54
+ * FOR DISPLAY ONLY. Nothing may feed this back to a shell — that would undo the
55
+ * whole point of carrying argv. It exists so `doctor` can print "run: brew
56
+ * install tmux" and the updater can show what it is about to do.
57
+ *
58
+ * A token outside {@link SHELL_SAFE} is single-quoted, with `'` closed,
59
+ * escaped and reopened (`'\''`) — the only POSIX-portable escape, since single
60
+ * quotes have no escape character inside them. An empty token quotes too:
61
+ * unquoted it would vanish, which would misrepresent the argv it came from.
62
+ */
63
+ export function formatCommand(c: Command): string {
64
+ return [c.cmd, ...c.args].map(quoteToken).join(" ");
65
+ }
66
+
67
+ function quoteToken(token: string): string {
68
+ if (SHELL_SAFE.test(token)) return token;
69
+ return `'${token.replaceAll("'", `'\\''`)}'`;
70
+ }
71
+
72
+ /**
73
+ * Check if a command exists in PATH
74
+ * Uses execFile (not exec) to avoid shell injection vulnerabilities
75
+ * @param command - Command name to check
76
+ * @returns Path to command if found, null otherwise
77
+ */
78
+ export async function which(command: string): Promise<string | null> {
79
+ try {
80
+ // Use execFile with argument array to prevent command injection
81
+ const { stdout } = await execFileAsync("which", [command]);
82
+ return stdout.trim() || null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Synchronous PATH lookup — walks `$PATH` rather than spawning `which(1)`.
90
+ *
91
+ * Synchronous on purpose. The installer-command builders in
92
+ * `services/toolchain.ts` are sync and used by printers; making them async just
93
+ * to answer "does this machine have uv?" would ripple through every caller.
94
+ *
95
+ * Node's `fs` is used rather than `Bun.which` so this needs no `@types/bun`,
96
+ * whose globals collide with `@types/node` in this project.
97
+ *
98
+ * Unlike {@link resolveExecutable} this does NOT follow symlinks to prove the
99
+ * target still exists — use it to pick between candidates, not to prove a
100
+ * binary works.
101
+ */
102
+ export function whichSync(command: string): string | null {
103
+ // An explicit path is not a PATH lookup; honour it as given.
104
+ if (command.includes("/")) {
105
+ return isExecutableFile(command) ? command : null;
106
+ }
107
+ for (const dir of (process.env.PATH ?? "").split(":")) {
108
+ if (!dir) continue;
109
+ const candidate = join(dir, command);
110
+ if (isExecutableFile(candidate)) return candidate;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function isExecutableFile(p: string): boolean {
116
+ try {
117
+ if (!statSync(p).isFile()) return false;
118
+ accessSync(p, constants.X_OK);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Like {@link which}, but verifies the command actually RESOLVES and is
127
+ * executable — a dangling symlink (e.g. a stale npx-cache link) returns null.
128
+ * `which` alone reports the symlink's own path even when its target is gone,
129
+ * which is exactly how a plugin's binary can look "present" yet fail at runtime.
130
+ * @returns The real (symlink-resolved) executable path, or null.
131
+ */
132
+ export async function resolveExecutable(
133
+ command: string,
134
+ ): Promise<string | null> {
135
+ const p = await which(command);
136
+ if (!p) return null;
137
+ try {
138
+ const real = await realpath(p); // follows symlinks; throws if dangling
139
+ await access(real, constants.X_OK); // must be executable
140
+ return real;
141
+ } catch {
142
+ return null; // dangling link or not executable
143
+ }
144
+ }
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Simple fuzzy search utility for filtering lists
3
+ * Uses a scoring algorithm that rewards:
4
+ * - Consecutive character matches
5
+ * - Matches at word boundaries
6
+ * - Matches at start of string
7
+ */
8
+
9
+ export interface FuzzyMatch<T> {
10
+ item: T;
11
+ score: number;
12
+ matches: number[]; // indices of matched characters
13
+ }
14
+
15
+ /**
16
+ * Calculate fuzzy match score for a query against a target string
17
+ * Returns null if no match, otherwise returns score and match indices
18
+ */
19
+ export function fuzzyMatch(
20
+ query: string,
21
+ target: string,
22
+ ): { score: number; matches: number[] } | null {
23
+ if (!query) return { score: 1, matches: [] };
24
+
25
+ const queryLower = query.toLowerCase();
26
+ const targetLower = target.toLowerCase();
27
+
28
+ let queryIdx = 0;
29
+ let score = 0;
30
+ const matches: number[] = [];
31
+ let prevMatchIdx = -1;
32
+
33
+ for (let i = 0; i < targetLower.length && queryIdx < queryLower.length; i++) {
34
+ if (targetLower[i] === queryLower[queryIdx]) {
35
+ matches.push(i);
36
+
37
+ // Bonus for consecutive matches
38
+ if (prevMatchIdx === i - 1) {
39
+ score += 5;
40
+ }
41
+
42
+ // Bonus for matching at word boundary
43
+ if (i === 0 || /[\s\-_.]/.test(target[i - 1])) {
44
+ score += 10;
45
+ }
46
+
47
+ // Bonus for matching at start
48
+ if (i === 0) {
49
+ score += 15;
50
+ }
51
+
52
+ // Base score for each match
53
+ score += 1;
54
+
55
+ prevMatchIdx = i;
56
+ queryIdx++;
57
+ }
58
+ }
59
+
60
+ // All query characters must be found
61
+ if (queryIdx !== queryLower.length) {
62
+ return null;
63
+ }
64
+
65
+ // Penalty for longer strings (prefer shorter, more precise matches)
66
+ score -= Math.floor(target.length / 10);
67
+
68
+ return { score, matches };
69
+ }
70
+
71
+ /**
72
+ * Filter and sort items by fuzzy match score
73
+ */
74
+ export function fuzzyFilter<T>(
75
+ items: T[],
76
+ query: string,
77
+ getText: (item: T) => string,
78
+ ): FuzzyMatch<T>[] {
79
+ if (!query.trim()) {
80
+ return items.map((item) => ({ item, score: 1, matches: [] }));
81
+ }
82
+
83
+ const results: FuzzyMatch<T>[] = [];
84
+
85
+ for (const item of items) {
86
+ const text = getText(item);
87
+ const match = fuzzyMatch(query, text);
88
+
89
+ if (match) {
90
+ results.push({
91
+ item,
92
+ score: match.score,
93
+ matches: match.matches,
94
+ });
95
+ }
96
+ }
97
+
98
+ // Sort by score descending
99
+ return results.sort((a, b) => b.score - a.score);
100
+ }
101
+
102
+ /**
103
+ * Highlight matched characters in a string
104
+ * Returns array of { text, highlighted } segments
105
+ */
106
+ export function highlightMatches(
107
+ text: string,
108
+ matches: number[],
109
+ ): Array<{ text: string; highlighted: boolean }> {
110
+ if (matches.length === 0) {
111
+ return [{ text, highlighted: false }];
112
+ }
113
+
114
+ const matchSet = new Set(matches);
115
+ const segments: Array<{ text: string; highlighted: boolean }> = [];
116
+ let currentSegment = "";
117
+ let isHighlighted = matchSet.has(0);
118
+
119
+ for (let i = 0; i < text.length; i++) {
120
+ const shouldHighlight = matchSet.has(i);
121
+
122
+ if (shouldHighlight !== isHighlighted) {
123
+ if (currentSegment) {
124
+ segments.push({ text: currentSegment, highlighted: isHighlighted });
125
+ }
126
+ currentSegment = text[i];
127
+ isHighlighted = shouldHighlight;
128
+ } else {
129
+ currentSegment += text[i];
130
+ }
131
+ }
132
+
133
+ if (currentSegment) {
134
+ segments.push({ text: currentSegment, highlighted: isHighlighted });
135
+ }
136
+
137
+ return segments;
138
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Go module path → installed binary name.
3
+ *
4
+ * `go install` names the binary after the last path element of the module
5
+ * path, EXCEPT when that element is a major-version suffix: the module
6
+ * `github.com/MadAppGang/tmux-mcp/v2` installs `tmux-mcp`, not `v2`. Taking a
7
+ * plain basename therefore points every later check at a binary that never
8
+ * exists — presence probes fail, so the module is reinstalled on every run,
9
+ * doctor reports it missing, and the pin comparison never runs at all.
10
+ *
11
+ * Only the Go rule lives here. npm and cargo share the `name@version` syntax
12
+ * but not the suffix convention — an npm scoped package can legitimately be
13
+ * called `@scope/v2` — so this must not be folded into the generic spec parser.
14
+ */
15
+
16
+ /**
17
+ * Trailing major-version suffix per the Go modules spec: `/vN` with N >= 2.
18
+ * Anchored to the END of the path with nothing after `vN`, so a segment that
19
+ * merely starts with "v2" (`github.com/x/v2tool`) is a real name, not a suffix.
20
+ * v0 and v1 are never written as a path suffix. The alternation, not a plain
21
+ * `[2-9][0-9]*`, because that shorthand rejects v10–v19 — a first digit of 1
22
+ * is only excluded when it is the ONLY digit.
23
+ */
24
+ const MAJOR_VERSION_SUFFIX = /\/v(?:[2-9]|[1-9][0-9]+)$/;
25
+
26
+ /**
27
+ * The binary `go install <modulePath>` produces.
28
+ *
29
+ * Takes the MODULE PATH — the part before any `@version` — so callers that
30
+ * hold a full `path@version` spec split it first. An empty or single-segment
31
+ * input is returned unchanged.
32
+ */
33
+ export function goBinaryName(modulePath: string): string {
34
+ const withoutSuffix = modulePath.replace(MAJOR_VERSION_SUFFIX, "");
35
+ return withoutSuffix.split("/").pop() || withoutSuffix;
36
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Hand a file or URL to the operating system's default application.
3
+ *
4
+ * Why this exists rather than launching `$EDITOR`: magus owns the terminal.
5
+ * Suspending an OpenTUI app to give the TTY to a full-screen editor and then
6
+ * restoring raw mode, the alternate screen buffer and the cursor is a reliable
7
+ * way to leave the terminal wedged if anything goes wrong in between. Handing
8
+ * the path to the OS launches a separate process that never touches our TTY.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+
13
+ export interface Opener {
14
+ command: string;
15
+ /** Arguments that precede the target path. */
16
+ args: string[];
17
+ }
18
+
19
+ /**
20
+ * The platform's "open this with whatever handles it" command.
21
+ *
22
+ * magus ships darwin-arm64, darwin-x64 and linux-x64, so macOS and Linux
23
+ * both have to work — the existing `open "${url}"` call in the Skills screen is
24
+ * macOS-only and silently does nothing on Linux.
25
+ */
26
+ export function openerFor(platform: NodeJS.Platform): Opener {
27
+ if (platform === "darwin") return { command: "open", args: [] };
28
+ // `start` is a cmd.exe builtin, and its first quoted argument is the window
29
+ // title — hence the empty string, or a quoted path would be consumed as one.
30
+ if (platform === "win32")
31
+ return { command: "cmd", args: ["/c", "start", ""] };
32
+ return { command: "xdg-open", args: [] };
33
+ }
34
+
35
+ /**
36
+ * Open `target` in the default application, without blocking the TUI.
37
+ *
38
+ * The child is spawned WITHOUT a shell and the target is passed as its own
39
+ * argv entry, so a filename containing a space, quote or `$(…)` is opened
40
+ * rather than interpreted. Resolves once the process has been spawned, not
41
+ * when it exits — the viewer outlives us by design.
42
+ */
43
+ export function openInDefaultApp(
44
+ target: string,
45
+ platform: NodeJS.Platform = process.platform,
46
+ /**
47
+ * Test seam. The default is the platform's real opener; a test passes a
48
+ * command that cannot exist, so the missing-opener path is exercised
49
+ * deterministically on every OS — CI runners ship `xdg-open`, so "assume
50
+ * the opener is absent" fails exactly there, and "spawn the real one"
51
+ * launches applications on a build machine.
52
+ */
53
+ opener: Opener = openerFor(platform),
54
+ ): Promise<void> {
55
+ const { command, args } = opener;
56
+
57
+ return new Promise((resolve, reject) => {
58
+ let settled = false;
59
+ const child = spawn(command, [...args, target], {
60
+ detached: true,
61
+ stdio: "ignore",
62
+ });
63
+
64
+ child.once("error", (error: NodeJS.ErrnoException) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ reject(
68
+ error.code === "ENOENT"
69
+ ? new Error(
70
+ `No way to open files here — "${command}" is not on PATH.`,
71
+ )
72
+ : error,
73
+ );
74
+ });
75
+
76
+ child.once("spawn", () => {
77
+ if (settled) return;
78
+ settled = true;
79
+ // Detach so magus exiting does not take the editor with it.
80
+ child.unref();
81
+ resolve();
82
+ });
83
+ });
84
+ }