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,167 @@
1
+ /**
2
+ * TUI-facing wrapper around the gitignore core services. Loads everything
3
+ * the Gitignore screen needs in one call, and exposes a small set of
4
+ * action helpers (apply safe fixes, apply one destructive fix, apply a
5
+ * built-in template).
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
12
+ import type {
13
+ GitignoreManifest,
14
+ ResolvedManifest,
15
+ Violation,
16
+ } from "../types/gitignore.js";
17
+ import { detectViolations } from "./gitignore-detector.js";
18
+ import {
19
+ type DestructiveFixResult,
20
+ type SafeFixResult,
21
+ applyDestructiveFix as coreApplyDestructiveFix,
22
+ applySafeFixes as coreApplySafeFixes,
23
+ } from "./gitignore-fixer.js";
24
+ import { resolveGitignore } from "./gitignore-resolver.js";
25
+
26
+ export interface GitignoreState {
27
+ resolved: ResolvedManifest;
28
+ violations: Violation[];
29
+ /** Logger lines from resolution (malformed JSON, missing template, etc). */
30
+ warnings: string[];
31
+ }
32
+
33
+ export async function loadGitignoreState(cwd: string): Promise<GitignoreState> {
34
+ const warnings: string[] = [];
35
+ const resolved = await resolveGitignore(cwd, {
36
+ logger: (m) => warnings.push(m),
37
+ });
38
+ const violations = await detectViolations(cwd, resolved);
39
+ return { resolved, violations, warnings };
40
+ }
41
+
42
+ export async function applyAllSafeFixes(
43
+ cwd: string,
44
+ violations: Violation[],
45
+ ): Promise<SafeFixResult> {
46
+ return coreApplySafeFixes(cwd, violations);
47
+ }
48
+
49
+ export async function applyDestructiveFix(
50
+ cwd: string,
51
+ violation: Violation,
52
+ rulePattern?: string,
53
+ ): Promise<DestructiveFixResult> {
54
+ return coreApplyDestructiveFix(cwd, violation, rulePattern);
55
+ }
56
+
57
+ export async function ensureProjectManifestForEdit(
58
+ cwd: string,
59
+ resolved: ResolvedManifest,
60
+ ): Promise<string> {
61
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
62
+ if (existsSync(projectManifestPath)) {
63
+ const existing = await readFile(projectManifestPath, "utf8");
64
+ if (existing.trim().length > 0) return projectManifestPath;
65
+ }
66
+
67
+ const manifest: GitignoreManifest = { ignore: [], track: [] };
68
+ for (const rule of resolved.rules) {
69
+ if (rule.action === "ignore") manifest.ignore.push(rule.pattern);
70
+ else manifest.track.push(rule.pattern);
71
+ }
72
+
73
+ await mkdir(dirname(projectManifestPath), { recursive: true });
74
+ await writeFile(
75
+ projectManifestPath,
76
+ `${JSON.stringify(manifest, null, 2)}\n`,
77
+ );
78
+ return projectManifestPath;
79
+ }
80
+
81
+ export interface ApplyTemplateResult {
82
+ appliedTo: string;
83
+ added: { ignore: string[]; track: string[] };
84
+ conflicts: string[];
85
+ }
86
+
87
+ /**
88
+ * Merge a built-in template into the project manifest at .claude/gitignore.json.
89
+ * Conflicts (a pattern that already exists with the opposite action) are
90
+ * skipped and reported back to the caller.
91
+ */
92
+ export async function applyTemplate(
93
+ cwd: string,
94
+ templateName: string,
95
+ ): Promise<ApplyTemplateResult> {
96
+ const tpl = BUILTIN_TEMPLATES[templateName];
97
+ if (!tpl) throw new Error(`Unknown template: ${templateName}`);
98
+
99
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
100
+ const existing = await readManifestOrEmpty(projectManifestPath);
101
+
102
+ const existingIgnore = new Set(existing.ignore);
103
+ const existingTrack = new Set(existing.track);
104
+
105
+ const conflicts: string[] = [];
106
+ const newIgnore: string[] = [];
107
+ const newTrack: string[] = [];
108
+
109
+ for (const p of tpl.ignore) {
110
+ if (existingTrack.has(p)) {
111
+ conflicts.push(p);
112
+ continue;
113
+ }
114
+ if (!existingIgnore.has(p)) {
115
+ newIgnore.push(p);
116
+ existingIgnore.add(p);
117
+ }
118
+ }
119
+ for (const p of tpl.track) {
120
+ if (existingIgnore.has(p)) {
121
+ conflicts.push(p);
122
+ continue;
123
+ }
124
+ if (!existingTrack.has(p)) {
125
+ newTrack.push(p);
126
+ existingTrack.add(p);
127
+ }
128
+ }
129
+
130
+ const merged: GitignoreManifest = {
131
+ ignore: [...existing.ignore, ...newIgnore],
132
+ track: [...existing.track, ...newTrack],
133
+ extends: existing.extends,
134
+ };
135
+
136
+ await mkdir(dirname(projectManifestPath), { recursive: true });
137
+ await writeFile(projectManifestPath, `${JSON.stringify(merged, null, 2)}\n`);
138
+
139
+ return {
140
+ appliedTo: projectManifestPath,
141
+ added: { ignore: newIgnore, track: newTrack },
142
+ conflicts,
143
+ };
144
+ }
145
+
146
+ async function readManifestOrEmpty(path: string): Promise<GitignoreManifest> {
147
+ if (!existsSync(path)) return { ignore: [], track: [] };
148
+ try {
149
+ const raw = await readFile(path, "utf8");
150
+ const parsed = JSON.parse(raw);
151
+ if (!isManifest(parsed)) return { ignore: [], track: [] };
152
+ return parsed;
153
+ } catch {
154
+ return { ignore: [], track: [] };
155
+ }
156
+ }
157
+
158
+ function isManifest(x: unknown): x is GitignoreManifest {
159
+ if (!x || typeof x !== "object") return false;
160
+ const m = x as Record<string, unknown>;
161
+ return (
162
+ Array.isArray(m.ignore) &&
163
+ m.ignore.every((p) => typeof p === "string") &&
164
+ Array.isArray(m.track) &&
165
+ m.track.every((p) => typeof p === "string")
166
+ );
167
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Register `magus hook agent-model` as a `PreToolUse` hook on the Agent tool.
3
+ *
4
+ * The hook is what makes per-subagent routing exist at all: Claude Code has no
5
+ * per-agent model knob, and a PreToolUse hook rewriting the Agent tool's input
6
+ * is the only dynamic seam (measured — see `models-core.ts`). A config with no
7
+ * hook registered routes NOTHING, which is why `computeModelsStatus` reports
8
+ * `unhooked` ahead of any drift.
9
+ *
10
+ * Three rules, each earned:
11
+ *
12
+ * 1. **Identity is the command string.** Registration is idempotent because it
13
+ * looks for the command, not for a block shape. An existing `Agent` matcher
14
+ * block gets the command APPENDED rather than a second block created, so a
15
+ * user's own Agent hooks keep running alongside this one.
16
+ * 2. **The command is BARE, never an absolute path.** `magus upgrade`
17
+ * replaces the binary, and on npm the path carries a version directory — an
18
+ * absolute path recorded today is a dead path after the next upgrade, and
19
+ * the failure is invisible (a hook that cannot start is a hook that
20
+ * silently does not route).
21
+ * 3. **User scope, through `updateSettingsFile`.** NOT
22
+ * `readGlobalSettings`/`writeGlobalSettings`: those swallow a parse error
23
+ * and return `{}`, so a settings file a human is mid-edit in would be
24
+ * overwritten with one hook and nothing else. `updateSettingsFile` refuses
25
+ * a file it cannot parse.
26
+ */
27
+
28
+ import path from "node:path";
29
+ import { claudeConfigDirOrNull } from "../utils/config-dir.js";
30
+ import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
31
+
32
+ /** The command Claude Code runs. Bare — see rule 2 above. */
33
+ export const AGENT_MODEL_HOOK_COMMAND = "magus hook agent-model";
34
+
35
+ /** The tool whose input the hook rewrites. */
36
+ const MATCHER = "Agent";
37
+
38
+ /**
39
+ * Seconds. Generous for a process that reads one small JSON file and exits, and
40
+ * deliberately finite: Claude Code waits for this on every Agent call.
41
+ */
42
+ const TIMEOUT_SECONDS = 5;
43
+
44
+ interface HookCommand {
45
+ type: "command";
46
+ command: string;
47
+ timeout?: number;
48
+ }
49
+
50
+ const isRec = (v: unknown): v is Record<string, unknown> =>
51
+ typeof v === "object" && v !== null && !Array.isArray(v);
52
+
53
+ /** The `hooks.PreToolUse` array, or [] for any shape that is not one. */
54
+ function preToolUseBlocks(
55
+ settings: Record<string, unknown>,
56
+ ): Record<string, unknown>[] {
57
+ const hooks = settings.hooks;
58
+ if (!isRec(hooks)) return [];
59
+ const blocks = hooks.PreToolUse;
60
+ if (!Array.isArray(blocks)) return [];
61
+ return blocks.filter(isRec);
62
+ }
63
+
64
+ /** The command entries inside one matcher block. */
65
+ function commandsIn(block: Record<string, unknown>): Record<string, unknown>[] {
66
+ return Array.isArray(block.hooks) ? block.hooks.filter(isRec) : [];
67
+ }
68
+
69
+ function isOurCommand(entry: Record<string, unknown>): boolean {
70
+ return (
71
+ typeof entry.command === "string" &&
72
+ entry.command.trim() === AGENT_MODEL_HOOK_COMMAND
73
+ );
74
+ }
75
+
76
+ /** Is the agent-model hook already registered in this settings object? */
77
+ export function isHookRegistered(settings: Record<string, unknown>): boolean {
78
+ return preToolUseBlocks(settings).some((block) =>
79
+ commandsIn(block).some(isOurCommand),
80
+ );
81
+ }
82
+
83
+ function newCommandEntry(): HookCommand {
84
+ return {
85
+ type: "command",
86
+ command: AGENT_MODEL_HOOK_COMMAND,
87
+ timeout: TIMEOUT_SECONDS,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Add the hook if it is missing. Returns a NEW settings object — the input is
93
+ * never mutated, so a caller can diff before deciding to write.
94
+ */
95
+ export function ensureHookRegistered(settings: Record<string, unknown>): {
96
+ settings: Record<string, unknown>;
97
+ changed: boolean;
98
+ } {
99
+ if (isHookRegistered(settings)) return { settings, changed: false };
100
+
101
+ const next = structuredClone(settings);
102
+ const hooks = isRec(next.hooks) ? next.hooks : {};
103
+ const blocks = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
104
+
105
+ // Append into an existing Agent block rather than adding a second one: two
106
+ // blocks with the same matcher both fire, which works, but it makes the
107
+ // user's settings file progressively less readable and is indistinguishable
108
+ // from a bug when they open it.
109
+ const existing = blocks.findIndex(
110
+ (b) => isRec(b) && b.matcher === MATCHER && Array.isArray(b.hooks),
111
+ );
112
+ if (existing >= 0) {
113
+ const block = blocks[existing] as Record<string, unknown>;
114
+ blocks[existing] = {
115
+ ...block,
116
+ hooks: [...(block.hooks as unknown[]), newCommandEntry()],
117
+ };
118
+ } else {
119
+ blocks.push({ matcher: MATCHER, hooks: [newCommandEntry()] });
120
+ }
121
+
122
+ next.hooks = { ...hooks, PreToolUse: blocks };
123
+ return { settings: next, changed: true };
124
+ }
125
+
126
+ /**
127
+ * Remove every registration of the hook. An Agent block left with no commands
128
+ * is dropped, so uninstalling leaves no trace; other blocks and other commands
129
+ * inside a shared Agent block are untouched.
130
+ */
131
+ export function removeHookRegistration(settings: Record<string, unknown>): {
132
+ settings: Record<string, unknown>;
133
+ changed: boolean;
134
+ } {
135
+ if (!isHookRegistered(settings)) return { settings, changed: false };
136
+
137
+ const next = structuredClone(settings);
138
+ const hooks = next.hooks as Record<string, unknown>;
139
+ const blocks: unknown[] = [];
140
+ for (const block of hooks.PreToolUse as unknown[]) {
141
+ if (!isRec(block) || !Array.isArray(block.hooks)) {
142
+ blocks.push(block);
143
+ continue;
144
+ }
145
+ const kept = block.hooks.filter((e) => !(isRec(e) && isOurCommand(e)));
146
+ if (kept.length === 0 && block.matcher === MATCHER) continue;
147
+ blocks.push({ ...block, hooks: kept });
148
+ }
149
+
150
+ if (blocks.length > 0) {
151
+ hooks.PreToolUse = blocks;
152
+ } else {
153
+ // biome-ignore lint/performance/noDelete: an empty PreToolUse array is noise in a file people read
154
+ delete hooks.PreToolUse;
155
+ }
156
+ if (Object.keys(hooks).length === 0) {
157
+ // biome-ignore lint/performance/noDelete: same reason — leave no empty husk
158
+ delete next.hooks;
159
+ }
160
+ return { settings: next, changed: true };
161
+ }
162
+
163
+ /**
164
+ * The user-scope settings file this writes to.
165
+ *
166
+ * `claudeConfigDirOrNull` honours `CLAUDE_CONFIG_DIR` and returns null under
167
+ * `bun test` when no test has chosen a directory — which is a refusal, not a
168
+ * fallback: writing a hook into the operator's real `~/.claude/settings.json`
169
+ * from a test run is exactly the damage that guard exists to prevent.
170
+ */
171
+ function userSettingsPath(configDir?: string): string {
172
+ const dir = configDir ?? claudeConfigDirOrNull();
173
+ if (!dir) {
174
+ throw new Error(
175
+ "No Claude config directory resolved. Set CLAUDE_CONFIG_DIR (a test must isolate it) before registering the agent-model hook.",
176
+ );
177
+ }
178
+ return path.join(dir, "settings.json");
179
+ }
180
+
181
+ /**
182
+ * Register the hook at user scope, once.
183
+ *
184
+ * User scope on purpose: routing is configured per project, but the hook that
185
+ * applies it has to be running in whatever session opens that project, and a
186
+ * project-scope hook would only exist for projects magus had already
187
+ * touched. The hook itself is a no-op for a project with no `models.json`, so
188
+ * one global registration costs nothing everywhere else.
189
+ */
190
+ export async function registerAgentModelHook(
191
+ opts: { configDir?: string } = {},
192
+ ): Promise<"registered" | "already"> {
193
+ const settingsPath = userSettingsPath(opts.configDir);
194
+ // Read first so an unchanged file is not rewritten (and its mtime not
195
+ // touched) on every `models use`.
196
+ if (isHookRegistered(await readSettingsFile(settingsPath))) return "already";
197
+
198
+ await updateSettingsFile(settingsPath, (settings) => {
199
+ return ensureHookRegistered(settings).settings;
200
+ });
201
+ return "registered";
202
+ }
203
+
204
+ /** Is the hook registered in the user-scope settings file? */
205
+ export async function isAgentModelHookRegistered(
206
+ opts: { configDir?: string } = {},
207
+ ): Promise<boolean> {
208
+ try {
209
+ return isHookRegistered(
210
+ await readSettingsFile(userSettingsPath(opts.configDir)),
211
+ );
212
+ } catch {
213
+ // No config dir, or a settings file we cannot parse. Either way we cannot
214
+ // claim it is registered — and the caller (doctor / status) reports that
215
+ // as "not routed", which is the honest answer.
216
+ return false;
217
+ }
218
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pure planning helpers for `magus install`.
3
+ *
4
+ * The impure "what does this machine already have" probing lives in the command
5
+ * (cli/install.ts); everything here is a pure function of a ResolvedClosure and
6
+ * some captured machine state, so it can be unit-tested and reused by
7
+ * `install --check` (the CI drift gate).
8
+ */
9
+
10
+ import semver from "semver";
11
+ import type {
12
+ InstalledPluginsRegistry,
13
+ ResolvedClosure,
14
+ } from "../types/index.js";
15
+
16
+ export interface VersionDrift {
17
+ pluginId: string;
18
+ /** The version the manifest pins. */
19
+ pinned: string;
20
+ /** The highest installed version, or null if not installed. */
21
+ installed: string | null;
22
+ }
23
+
24
+ /** Highest valid semver in a list, or the raw first value if none parse. */
25
+ function maxVersion(versions: string[]): string | null {
26
+ const valid = versions.filter((v) => semver.valid(v));
27
+ const highest = valid.sort(semver.rcompare)[0];
28
+ return highest ?? versions[0] ?? null;
29
+ }
30
+
31
+ /**
32
+ * Compare the closure's pinned plugin versions against the installed registry.
33
+ * Only plugins with an explicit pin (not "latest") are checked. Returns the set
34
+ * of plugins whose installed version doesn't match the pin — the signal
35
+ * `install --check` fails on.
36
+ */
37
+ export function computeVersionDrift(
38
+ closure: ResolvedClosure,
39
+ registry: InstalledPluginsRegistry,
40
+ ): VersionDrift[] {
41
+ const drifts: VersionDrift[] = [];
42
+ for (const [pluginId, pinned] of Object.entries(closure.plugins)) {
43
+ if (pinned === "latest") continue;
44
+ const entries = registry.plugins?.[pluginId] ?? [];
45
+ const installed = entries.length
46
+ ? maxVersion(entries.map((e) => e.version))
47
+ : null;
48
+ if (installed !== pinned) {
49
+ drifts.push({ pluginId, pinned, installed });
50
+ }
51
+ }
52
+ return drifts;
53
+ }
54
+
55
+ export interface EnvStatus {
56
+ required: { name: string; present: boolean }[];
57
+ optional: { name: string; present: boolean }[];
58
+ /** Required vars that are unset — must be collected before the profile works. */
59
+ missingRequired: string[];
60
+ }
61
+
62
+ /** Which of the closure's declared env vars are present in `env` (process.env). */
63
+ export function computeEnvStatus(
64
+ closure: ResolvedClosure,
65
+ env: Record<string, string | undefined> = process.env,
66
+ ): EnvStatus {
67
+ const check = (name: string) => ({
68
+ name,
69
+ present: typeof env[name] === "string" && env[name] !== "",
70
+ });
71
+ const required = closure.env.required.map(check);
72
+ const optional = closure.env.optional.map(check);
73
+ return {
74
+ required,
75
+ optional,
76
+ missingRequired: required.filter((e) => !e.present).map((e) => e.name),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * A one-line-per-item human summary of what a closure contains, for the
82
+ * pre-install plan display. Machine-state annotations (already installed, etc.)
83
+ * are layered on by the caller.
84
+ */
85
+ export function summarizeClosure(closure: ResolvedClosure): string[] {
86
+ const lines: string[] = [];
87
+ const mps = Object.keys(closure.marketplaces);
88
+ if (mps.length) lines.push(`marketplaces ${mps.join(", ")}`);
89
+ const plugins = Object.entries(closure.plugins).map(([id, v]) =>
90
+ v === "latest" ? id : `${id}@${v}`,
91
+ );
92
+ if (plugins.length) lines.push(`plugins ${plugins.join(", ")}`);
93
+ const mcps = Object.keys(closure.mcpServers);
94
+ if (mcps.length) lines.push(`mcp servers ${mcps.join(", ")}`);
95
+ if (closure.bins.length) {
96
+ lines.push(
97
+ `cli tools ${closure.bins.map((b) => (b.version ? `${b.name}@${b.version}` : b.name)).join(", ")}`,
98
+ );
99
+ }
100
+ if (closure.skills.length) {
101
+ lines.push(`skills ${closure.skills.map((s) => s.name).join(", ")}`);
102
+ }
103
+ if (closure.env.required.length) {
104
+ lines.push(`env ${closure.env.required.join(", ")}`);
105
+ }
106
+ return lines;
107
+ }