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,181 @@
1
+ import { lstatSync, readFileSync, statSync } from "node:fs";
2
+ import type { Stats } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Load the project's .env into the process environment — deliberately, in our
7
+ * own code, with every failure downgraded to a warning.
8
+ *
9
+ * Bun's standalone-executable autoload used to do this for free. It is compiled
10
+ * OFF now (see scripts/build-binaries.ts): Bun 1.4.0 aborts inside that autoload
11
+ * when .env is a symlink pointing at a FIFO — exactly how 1Password serves
12
+ * secrets into a git worktree — exiting 1 with nothing on stdout or stderr,
13
+ * before any JavaScript runs and therefore before any error handler exists.
14
+ * magus simply vanished in every such directory.
15
+ *
16
+ * Two rules this loader keeps that the autoload did not:
17
+ *
18
+ * 1. Only regular files are read. A FIFO, socket or device is skipped, never
19
+ * opened, because opening a pipe BLOCKS until some process opens the other
20
+ * end — with no writer standing by, magus hangs on startup instead of
21
+ * running. That is the whole reason. It is NOT that reading would steal
22
+ * another process's secrets: 1Password's pipe re-serves its contents on
23
+ * every open, and `readFileSync` reads it correctly (`Bun.file().text()`
24
+ * is the API that hangs on a FIFO). An earlier version of this comment,
25
+ * the warning text, and the 4.42.1 changelog all claimed the one-reader
26
+ * story; it is wrong, so do not restore it.
27
+ * 2. Nothing here is fatal. A missing, unreadable or malformed .env produces a
28
+ * warning and magus carries on. Managing ~/.claude must never depend on
29
+ * the state of whatever directory you happen to be standing in.
30
+ *
31
+ * A real environment variable always beats a file, matching dotenv convention;
32
+ * a later file beats an earlier one.
33
+ */
34
+
35
+ /** Files loaded, in order. A later file overrides an earlier one. */
36
+ const ENV_FILES = [".env", ".env.local"] as const;
37
+
38
+ export interface DotenvOutcome {
39
+ /** Files actually parsed, in the order applied. */
40
+ loaded: string[];
41
+ /** Names newly set. A pre-existing variable is never overwritten. */
42
+ applied: string[];
43
+ /** One line per file skipped or failed. Print these; never throw them. */
44
+ warnings: string[];
45
+ }
46
+
47
+ /**
48
+ * Parse dotenv text. Best-effort by design: an unparseable line is skipped
49
+ * rather than raised, because a broken .env must not be able to stop magus.
50
+ * Values spanning several lines are not supported.
51
+ */
52
+ export function parseDotenv(text: string): Record<string, string> {
53
+ const out: Record<string, string> = {};
54
+
55
+ for (const rawLine of text.split(/\r?\n/)) {
56
+ const line = rawLine.trim();
57
+ if (line === "" || line.startsWith("#")) continue;
58
+
59
+ const eq = line.indexOf("=");
60
+ if (eq <= 0) continue;
61
+
62
+ let key = line.slice(0, eq).trim();
63
+ if (key.startsWith("export ")) key = key.slice("export ".length).trim();
64
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
65
+
66
+ let value = line.slice(eq + 1).trim();
67
+ const quote = value[0];
68
+ const quoted =
69
+ (quote === '"' || quote === "'") &&
70
+ value.length > 1 &&
71
+ value.endsWith(quote);
72
+
73
+ if (quoted) {
74
+ value = value.slice(1, -1);
75
+ // Escapes are a double-quote feature; single quotes stay literal.
76
+ if (quote === '"') {
77
+ value = value
78
+ .replace(/\\n/g, "\n")
79
+ .replace(/\\r/g, "\r")
80
+ .replace(/\\t/g, "\t");
81
+ }
82
+ } else {
83
+ // An unquoted value ends at the first " #" comment.
84
+ const hash = value.indexOf(" #");
85
+ if (hash !== -1) value = value.slice(0, hash).trimEnd();
86
+ }
87
+
88
+ out[key] = value;
89
+ }
90
+
91
+ return out;
92
+ }
93
+
94
+ /** What a non-regular entry actually is, for a warning a human can act on. */
95
+ function describeKind(info: Stats): string {
96
+ if (info.isFIFO()) {
97
+ return "it is a named pipe; opening one blocks until a writer appears, and magus must not hang before it starts";
98
+ }
99
+ if (info.isSocket()) return "it is a socket, not a regular file";
100
+ if (info.isDirectory()) return "it is a directory, not a regular file";
101
+ if (info.isBlockDevice() || info.isCharacterDevice()) {
102
+ return "it is a device, not a regular file";
103
+ }
104
+ return "it is not a regular file";
105
+ }
106
+
107
+ function reason(error: unknown): string {
108
+ return error instanceof Error ? error.message : String(error);
109
+ }
110
+
111
+ /**
112
+ * Read .env and .env.local from `cwd` into `env`. Never throws.
113
+ *
114
+ * Returns what was loaded and, in `warnings`, every reason a file was skipped or
115
+ * failed — the caller decides how to surface them.
116
+ */
117
+ export function loadProjectDotenv(
118
+ cwd: string = process.cwd(),
119
+ env: Record<string, string | undefined> = process.env,
120
+ ): DotenvOutcome {
121
+ const outcome: DotenvOutcome = { loaded: [], applied: [], warnings: [] };
122
+
123
+ // Merge every file BEFORE touching `env`. Applying file by file would make
124
+ // the "never overwrite" rule below fire against .env's own values, so .env
125
+ // would silently beat .env.local — the opposite of the intended precedence.
126
+ const merged: Record<string, string> = {};
127
+
128
+ for (const name of ENV_FILES) {
129
+ const file = path.join(cwd, name);
130
+
131
+ // lstat first, so a symlink is recognised as one: the warning should name
132
+ // the file the user sees, while the decision is made about its target.
133
+ let info: Stats;
134
+ try {
135
+ info = lstatSync(file);
136
+ } catch {
137
+ continue; // Absent. That is the ordinary case, not a problem.
138
+ }
139
+
140
+ if (info.isSymbolicLink()) {
141
+ try {
142
+ info = statSync(file);
143
+ } catch {
144
+ outcome.warnings.push(`skipped ${name} — the symlink does not resolve`);
145
+ continue;
146
+ }
147
+ }
148
+
149
+ if (!info.isFile()) {
150
+ outcome.warnings.push(`skipped ${name} — ${describeKind(info)}`);
151
+ continue;
152
+ }
153
+
154
+ let text: string;
155
+ try {
156
+ text = readFileSync(file, "utf8");
157
+ } catch (error) {
158
+ outcome.warnings.push(`could not read ${name} — ${reason(error)}`);
159
+ continue;
160
+ }
161
+
162
+ let parsed: Record<string, string>;
163
+ try {
164
+ parsed = parseDotenv(text);
165
+ } catch (error) {
166
+ outcome.warnings.push(`could not parse ${name} — ${reason(error)}`);
167
+ continue;
168
+ }
169
+
170
+ outcome.loaded.push(name);
171
+ Object.assign(merged, parsed); // A later file beats an earlier one.
172
+ }
173
+
174
+ for (const [key, value] of Object.entries(merged)) {
175
+ if (env[key] !== undefined) continue; // A real variable always wins.
176
+ env[key] = value;
177
+ outcome.applied.push(key);
178
+ }
179
+
180
+ return outcome;
181
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * git-worktree.ts — which other directories count as "the same project" as this one.
3
+ *
4
+ * `installed_plugins.json` keys one entry per `projectPath`, so a directory that
5
+ * nobody has run `claude plugin install` in has no rows at all. A freshly created
6
+ * git worktree is exactly that: measured on a real machine, 750 project-scope rows
7
+ * across 76 plugin keys and not one of them for the new worktree.
8
+ *
9
+ * Claude Code does not care. Its loader is not gated on the current project — a
10
+ * worktree with zero rows resolves `installPath` from another project's row and
11
+ * loads from cache (measured, `ai-docs/plugin-system-state-model.md`). magus
12
+ * mirrored the registry faithfully instead, so it reported every plugin as missing
13
+ * and offered to install things that were already working.
14
+ *
15
+ * The fix is to ask a narrower question than "does the loader find something
16
+ * somewhere": inherit rows only from directories that are the *same repository*.
17
+ * That covers both ways a project path can miss its own rows —
18
+ *
19
+ * 1. a linked worktree, whose main working tree holds the rows
20
+ * 2. a subdirectory of a checkout, where the rows sit at the repo root
21
+ *
22
+ * — under one rule, and never inherits from an unrelated project, which would be
23
+ * claiming a version we cannot attribute.
24
+ */
25
+
26
+ import { spawn } from "node:child_process";
27
+ import path from "node:path";
28
+
29
+ function git(
30
+ cwd: string,
31
+ args: string[],
32
+ ): Promise<{ code: number; out: string }> {
33
+ return new Promise((resolve) => {
34
+ const child = spawn("git", args, {
35
+ cwd,
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ });
38
+ let out = "";
39
+ child.stdout.on("data", (d) => {
40
+ out += String(d);
41
+ });
42
+ // Never let a wedged git block the UI.
43
+ const timer = setTimeout(() => {
44
+ child.kill();
45
+ resolve({ code: -1, out: "" });
46
+ }, 5000);
47
+ timer.unref?.();
48
+ const settle = (code: number, text: string) => {
49
+ clearTimeout(timer);
50
+ resolve({ code, out: text });
51
+ };
52
+ child.on("error", () => settle(-1, ""));
53
+ child.on("close", (code) => settle(code ?? -1, out.trim()));
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Session cache. Keyed by resolved project path — the repository layout cannot
59
+ * change under a running magus in any way worth tracking, and the resolution
60
+ * runs once per plugin per scope otherwise (24+ plugins × 3 scopes of `git`).
61
+ */
62
+ const cache = new Map<string, string[]>();
63
+
64
+ export function clearWorktreeCache(): void {
65
+ cache.clear();
66
+ }
67
+
68
+ /**
69
+ * Directories whose registry rows this project may inherit, nearest first.
70
+ *
71
+ * Returns `[]` — never a guess — when the question cannot be answered honestly:
72
+ * git missing, not a repository, a bare repo with no working tree, or the path
73
+ * already being the repository root. Inheriting nothing restores the previous
74
+ * behaviour for that path, which is the safe direction: a false negative shows
75
+ * an "install" the user does not need, a false positive hides a real one.
76
+ */
77
+ export async function inheritablePaths(projectPath: string): Promise<string[]> {
78
+ const key = path.resolve(projectPath);
79
+ const cached = cache.get(key);
80
+ if (cached) return cached;
81
+ const { paths, answered } = await resolveInheritable(key);
82
+ // Only cache an answer git actually gave. A timeout or a transient spawn
83
+ // failure would otherwise pin an empty result for the rest of the session,
84
+ // turning one slow moment into a persistent wrong "not installed".
85
+ if (answered) cache.set(key, paths);
86
+ return paths;
87
+ }
88
+
89
+ async function resolveInheritable(
90
+ dir: string,
91
+ ): Promise<{ paths: string[]; answered: boolean }> {
92
+ const candidates: string[] = [];
93
+
94
+ // Independent queries, so run them together: sequentially, two wedged git calls
95
+ // would stall the first paint for the full 10s rather than 5.
96
+ const [top, list] = await Promise.all([
97
+ // The root of whichever working tree we are standing in. Covers a subdirectory
98
+ // of a checkout, and is the nearer answer when both apply.
99
+ git(dir, ["rev-parse", "--show-toplevel"]),
100
+ // The main working tree, which `git worktree list` always lists first. This is
101
+ // the row-bearing directory for a linked worktree.
102
+ git(dir, ["worktree", "list", "--porcelain"]),
103
+ ]);
104
+
105
+ if (top.code === 0 && top.out) candidates.push(path.resolve(top.out));
106
+
107
+ if (list.code === 0 && list.out) {
108
+ const firstRecord = list.out.split("\n\n")[0].split("\n");
109
+ // A bare repo is listed with a `bare` line and has no working tree to inherit.
110
+ const isBare = firstRecord.some((l) => l.trim() === "bare");
111
+ const line = firstRecord.find((l) => l.startsWith("worktree "));
112
+ if (!isBare && line) {
113
+ candidates.push(path.resolve(line.slice("worktree ".length).trim()));
114
+ }
115
+ }
116
+
117
+ // "Not a repository" is a real answer worth caching; "git never replied" is not.
118
+ const answered = top.code !== -1 || list.code !== -1;
119
+
120
+ // Drop this project itself and any duplicate, preserving nearest-first order.
121
+ const seen = new Set<string>([dir]);
122
+ const inheritable: string[] = [];
123
+ for (const candidate of candidates) {
124
+ if (seen.has(candidate)) continue;
125
+ seen.add(candidate);
126
+ inheritable.push(candidate);
127
+ }
128
+ return { paths: inheritable, answered };
129
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * github-budget.ts — one place that knows when we are allowed to call GitHub.
3
+ *
4
+ * magus talks to two GitHub hosts from several services, and they enforce
5
+ * separate budgets:
6
+ *
7
+ * api.github.com 60 req/hr unauthenticated, and it TELLS you:
8
+ * x-ratelimit-remaining / x-ratelimit-reset / retry-after
9
+ * raw.githubusercontent.com rate-limited too, and it tells you NOTHING —
10
+ * measured 2026-08-17: a 429 carried no retry-after
11
+ * and no x-ratelimit-* header, only a request id.
12
+ *
13
+ * That asymmetry is the whole design problem. For one host we can state exactly
14
+ * when the window reopens; for the other any specific time would be invented. So
15
+ * a cooldown records WHERE its timing came from, and the UI says "resets in 4m"
16
+ * only when GitHub actually said so, and "next attempt in 30s" — our own
17
+ * schedule, honestly labelled — when it did not.
18
+ *
19
+ * Two jobs:
20
+ * 1. Stop sending requests that are already known to fail. Every service shares
21
+ * one bucket per host, so the skills screen's raw fetches and the plugin
22
+ * catalog fetches must share one gate — otherwise each rediscovers the limit
23
+ * by burning more attempts against it.
24
+ * 2. Report when the next attempt is due, so the UI can show a countdown and
25
+ * retry itself instead of asking the user to guess.
26
+ */
27
+
28
+ import {
29
+ clearStoredCooldown,
30
+ readStoredCooldowns,
31
+ writeStoredCooldown,
32
+ } from "./catalog-cache-store.js";
33
+
34
+ /** Hosts we gate. Anything else is not budgeted. */
35
+ export type GitHubHost = "api.github.com" | "raw.githubusercontent.com";
36
+
37
+ export interface Cooldown {
38
+ host: GitHubHost;
39
+ /** Wall-clock ms when the next attempt is allowed. */
40
+ until: number;
41
+ /**
42
+ * True when `until` came from GitHub's own `retry-after` /
43
+ * `x-ratelimit-reset`. False when we derived it from backoff because the
44
+ * response carried no timing — the distinction the UI must not blur.
45
+ */
46
+ exact: boolean;
47
+ /** Consecutive rate-limited responses, driving the backoff. */
48
+ strikes: number;
49
+ }
50
+
51
+ const cooldowns = new Map<GitHubHost, Cooldown>();
52
+
53
+ /**
54
+ * Load cooldowns recorded by a previous launch.
55
+ *
56
+ * Without this the gate is per-process, which for a TUI means per-launch, which
57
+ * means useless: two consecutive processes each spent six requests rediscovering
58
+ * the same rate limit. Memoised, so callers may await it unconditionally.
59
+ */
60
+ let hydrated: Promise<void> | null = null;
61
+
62
+ export function hydrateGitHubBudget(): Promise<void> {
63
+ hydrated ??= (async () => {
64
+ try {
65
+ const stored = await readStoredCooldowns();
66
+ for (const [host, cooldown] of Object.entries(stored)) {
67
+ if (host !== "api.github.com" && host !== "raw.githubusercontent.com") {
68
+ continue;
69
+ }
70
+ // A cooldown already known in this process wins: it is newer.
71
+ if (cooldowns.has(host)) continue;
72
+ cooldowns.set(host, { host, ...cooldown });
73
+ }
74
+ } catch {
75
+ // A cache that cannot be read must not stop us making requests.
76
+ }
77
+ })();
78
+ return hydrated;
79
+ }
80
+
81
+ /**
82
+ * Backoff for hosts that give no retry timing. Deliberately coarse: a rate limit
83
+ * measured in minutes is not escaped by retrying in one second, and each wasted
84
+ * attempt can extend the penalty.
85
+ */
86
+ const BACKOFF_MS = [30_000, 60_000, 120_000, 300_000, 600_000];
87
+
88
+ /** Cap on a server-provided window we will honour, so a bad header cannot wedge the UI. */
89
+ const MAX_EXACT_WAIT_MS = 60 * 60_000;
90
+
91
+ export function hostOf(url: string): GitHubHost | null {
92
+ try {
93
+ const { hostname } = new URL(url);
94
+ if (hostname === "api.github.com") return "api.github.com";
95
+ if (hostname === "raw.githubusercontent.com")
96
+ return "raw.githubusercontent.com";
97
+ return null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Parse GitHub's retry timing, if the response carries any.
105
+ *
106
+ * `retry-after` is delta-seconds or an HTTP date (RFC 9110). `x-ratelimit-reset`
107
+ * is epoch SECONDS, not ms — treating it as ms yields a date in 1970 and a
108
+ * cooldown that expires instantly, which reads as "not rate limited".
109
+ */
110
+ function parseRetryTiming(headers: Headers, now: number): number | null {
111
+ const retryAfter = headers.get("retry-after");
112
+ if (retryAfter) {
113
+ const seconds = Number(retryAfter);
114
+ if (Number.isFinite(seconds) && seconds >= 0) {
115
+ return now + seconds * 1000;
116
+ }
117
+ const asDate = Date.parse(retryAfter);
118
+ if (Number.isFinite(asDate)) return asDate;
119
+ }
120
+
121
+ const reset = headers.get("x-ratelimit-reset");
122
+ if (reset) {
123
+ const epochSeconds = Number(reset);
124
+ if (Number.isFinite(epochSeconds) && epochSeconds > 0) {
125
+ return epochSeconds * 1000;
126
+ }
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Record a rate-limited response and return the resulting cooldown.
134
+ *
135
+ * Call this for 429 and for 403, which is how GitHub reports an exhausted
136
+ * unauthenticated budget on the REST API.
137
+ */
138
+ export function recordRateLimit(
139
+ host: GitHubHost,
140
+ headers?: Headers,
141
+ now: number = Date.now(),
142
+ ): Cooldown {
143
+ const strikes = (cooldowns.get(host)?.strikes ?? 0) + 1;
144
+
145
+ const fromHeader = headers ? parseRetryTiming(headers, now) : null;
146
+ if (fromHeader !== null && fromHeader > now) {
147
+ const cooldown: Cooldown = {
148
+ host,
149
+ until: Math.min(fromHeader, now + MAX_EXACT_WAIT_MS),
150
+ exact: true,
151
+ strikes,
152
+ };
153
+ cooldowns.set(host, cooldown);
154
+ persist(cooldown);
155
+ return cooldown;
156
+ }
157
+
158
+ const step = BACKOFF_MS[Math.min(strikes - 1, BACKOFF_MS.length - 1)];
159
+ const cooldown: Cooldown = {
160
+ host,
161
+ // No jitter: this is a single-user desktop tool, not a fleet stampeding
162
+ // one endpoint. A predictable countdown is worth more here than spread.
163
+ until: now + (step ?? 30_000),
164
+ exact: false,
165
+ strikes,
166
+ };
167
+ cooldowns.set(host, cooldown);
168
+ persist(cooldown);
169
+ return cooldown;
170
+ }
171
+
172
+ /**
173
+ * Write-behind, so `recordRateLimit` stays synchronous for its many call sites.
174
+ *
175
+ * Fire-and-forget is acceptable here and only here: losing one cooldown write
176
+ * degrades to the old behaviour (one wasted request next launch), whereas making
177
+ * every caller async to guarantee it would spread `await` through the fetch path
178
+ * for no proportional gain.
179
+ */
180
+ function persist(cooldown: Cooldown): void {
181
+ void writeStoredCooldown(cooldown.host, {
182
+ until: cooldown.until,
183
+ exact: cooldown.exact,
184
+ strikes: cooldown.strikes,
185
+ });
186
+ }
187
+
188
+ /**
189
+ * A successful response clears the host's cooldown and its accumulated strikes.
190
+ *
191
+ * Resetting strikes matters: without it, one bad afternoon leaves the backoff
192
+ * pinned at ten minutes for the rest of the session even though requests are
193
+ * succeeding again.
194
+ */
195
+ export function recordSuccess(host: GitHubHost): void {
196
+ cooldowns.delete(host);
197
+ void clearStoredCooldown(host);
198
+ }
199
+
200
+ /**
201
+ * Also worth recording: the budget is nearly spent. Lets a caller stop before it
202
+ * trips the limit rather than after. Only api.github.com reports this.
203
+ */
204
+ export function remainingFromHeaders(headers: Headers): number | null {
205
+ const remaining = headers.get("x-ratelimit-remaining");
206
+ if (!remaining) return null;
207
+ const n = Number(remaining);
208
+ return Number.isFinite(n) ? n : null;
209
+ }
210
+
211
+ /** The active cooldown for a host, or null when calls are allowed. */
212
+ export function cooldownFor(
213
+ host: GitHubHost,
214
+ now: number = Date.now(),
215
+ ): Cooldown | null {
216
+ const cooldown = cooldowns.get(host);
217
+ if (!cooldown) return null;
218
+ if (cooldown.until <= now) {
219
+ // Expired. Keep the strike count — it is what makes repeated failures back
220
+ // off further — but stop blocking.
221
+ cooldowns.set(host, { ...cooldown, until: now });
222
+ return null;
223
+ }
224
+ return cooldown;
225
+ }
226
+
227
+ /** Milliseconds until the next attempt is allowed. 0 when allowed now. */
228
+ export function waitMs(host: GitHubHost, now: number = Date.now()): number {
229
+ const cooldown = cooldownFor(host, now);
230
+ return cooldown ? cooldown.until - now : 0;
231
+ }
232
+
233
+ /** Every host currently in cooldown, soonest first. */
234
+ export function activeCooldowns(now: number = Date.now()): Cooldown[] {
235
+ const active: Cooldown[] = [];
236
+ for (const host of cooldowns.keys()) {
237
+ const cooldown = cooldownFor(host, now);
238
+ if (cooldown) active.push(cooldown);
239
+ }
240
+ return active.sort((a, b) => a.until - b.until);
241
+ }
242
+
243
+ /** Human countdown: "45s", "4m", "1h 12m". */
244
+ export function formatWait(ms: number): string {
245
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
246
+ if (totalSeconds < 60) return `${totalSeconds}s`;
247
+ const minutes = Math.ceil(totalSeconds / 60);
248
+ if (minutes < 60) return `${minutes}m`;
249
+ const hours = Math.floor(minutes / 60);
250
+ return `${hours}h ${minutes % 60}m`;
251
+ }
252
+
253
+ /**
254
+ * How to describe a cooldown to the user.
255
+ *
256
+ * The two phrasings are not interchangeable. "resets in 4m" is a claim about
257
+ * GitHub's state and is only sayable when GitHub sent the timing;
258
+ * "next attempt in 30s" is a claim about our own schedule and is always sayable.
259
+ */
260
+ export function describeCooldown(
261
+ cooldown: Cooldown,
262
+ now: number = Date.now(),
263
+ ): string {
264
+ const wait = formatWait(cooldown.until - now);
265
+ return cooldown.exact
266
+ ? `GitHub rate limit resets in ${wait}`
267
+ : `GitHub rate limit — next attempt in ${wait}`;
268
+ }
269
+
270
+ /** Test seam only. Clears in-memory state and the hydration memo, not the file. */
271
+ export function resetGitHubBudget(): void {
272
+ cooldowns.clear();
273
+ hydrated = null;
274
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Read-only gitignore check for the gitignore manager.
3
+ *
4
+ * Called from the TUI on startup and from `doctor`. Resolves the manifest, runs
5
+ * detection, and returns a concise warning when violations exist. Does NOT
6
+ * auto-apply fixes — the user must open the Git State tab and confirm. This
7
+ * mirrors the plugin-version-mismatch flow, where the check only *reports* and
8
+ * the TUI provides the interactive fix dialog.
9
+ */
10
+
11
+ import type { Violation } from "../types/gitignore.js";
12
+ import { detectViolations } from "./gitignore-detector.js";
13
+ import { formatViolationSummary } from "./gitignore-fixer.js";
14
+ import { resolveGitignore } from "./gitignore-resolver.js";
15
+
16
+ export interface GitignoreCheckResult {
17
+ violationCount: number;
18
+ warnings: string[];
19
+ }
20
+
21
+ export async function checkGitignore(
22
+ cwd: string,
23
+ ): Promise<GitignoreCheckResult> {
24
+ const warnings: string[] = [];
25
+ try {
26
+ const resolved = await resolveGitignore(cwd, {
27
+ logger: (m) => warnings.push(m),
28
+ });
29
+ const violations = await detectViolations(cwd, resolved);
30
+ return {
31
+ violationCount: violations.length,
32
+ warnings,
33
+ };
34
+ } catch (err) {
35
+ warnings.push(
36
+ `[gitignore] check failed: ${err instanceof Error ? err.message : String(err)}`,
37
+ );
38
+ return { violationCount: 0, warnings };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * One-line summary suitable for stderr. Returns null when there's
44
+ * nothing to warn about.
45
+ */
46
+ export function formatGitignoreWarning(
47
+ result: GitignoreCheckResult,
48
+ ): string | null {
49
+ if (result.violationCount === 0) return null;
50
+ return `⚠ ${result.violationCount} git state issue(s) detected. Run: magus → Git State tab to review and fix`;
51
+ }
52
+
53
+ /**
54
+ * Verbose multi-line summary, used by the Git State tab when the user wants
55
+ * to know what's wrong before deciding what to do.
56
+ */
57
+ export async function describeViolations(
58
+ cwd: string,
59
+ violations: Violation[],
60
+ ): Promise<string> {
61
+ void cwd;
62
+ return formatViolationSummary(violations);
63
+ }