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,613 @@
1
+ /**
2
+ * `magus update [profile] [--check] [--yes]`
3
+ *
4
+ * Dependency management, not package management: no resolver, no lockfile, no
5
+ * version negotiation. `magus install` puts the profile's declared set on
6
+ * this machine; `magus update` keeps it current — anything the profile
7
+ * declares but the machine lacks is installed, and anything behind the version
8
+ * its marketplace publishes is advanced to it.
9
+ *
10
+ * Scope is the ACTIVE profile, not the union of every profile: "update my
11
+ * dependencies" means the set actually in use. Pass a name to update another.
12
+ *
13
+ * Self-update moved to `magus upgrade`.
14
+ *
15
+ * ## Why this file drives a live region
16
+ *
17
+ * Most of an update's wall clock is spent inside four calls that print nothing:
18
+ * a git fetch per marketplace, a catalog read, a serial PATH probe per binary,
19
+ * then an install per plugin. The command used to print its first line after
20
+ * all of the first three had finished, and one line per plugin only once that
21
+ * plugin was already installed. On a slow network that is thirty seconds of a
22
+ * dead terminal, which reads as a hang, not as work.
23
+ *
24
+ * So every phase reports while it runs. `cli/live.ts` owns the mechanism. A
25
+ * line printed while a region is painting must go through the region, or it
26
+ * lands inside the animated block and the next repaint's cursor-up arithmetic
27
+ * erases it along with part of the frame. Prefer `live.note` here for anything
28
+ * this file emits deliberately — but the region also CAPTURES `console` while
29
+ * it paints, so a write from deeper in the call graph is routed rather than
30
+ * corrupting the display. That capture is not belt-and-braces: it is the only
31
+ * thing that covers `getAvailablePlugins`, which reaches a stale-marketplace
32
+ * advisory two layers down and used to erase itself mid-frame.
33
+ *
34
+ * `cli/update-view.ts` owns every string. Nothing in this file formats.
35
+ */
36
+
37
+ import { selectProfileId } from "../services/active-closure.js";
38
+ import { isClaudeAvailable } from "../services/claude-cli.js";
39
+ import { clearContentDriftCache } from "../services/content-drift.js";
40
+ import { validateManifest } from "../services/manifest.js";
41
+ import {
42
+ refreshRegisteredMarketplaces,
43
+ reportRegisteredMarketplaces,
44
+ } from "../services/marketplace-refresh.js";
45
+ import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
46
+ import {
47
+ type PluginInfo,
48
+ clearMarketplaceCache,
49
+ getAvailablePlugins,
50
+ } from "../services/plugin-manager.js";
51
+ import { resolveProfile } from "../services/resolver.js";
52
+ import { getInstalledSkillNames } from "../services/skills-manager.js";
53
+ import { activeProfile } from "../services/symlink-manager.js";
54
+ import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
55
+ // The apply half lives in services/ so the TUI runs the SAME code — see the
56
+ // header of update-engine.ts for what drifted while it did not.
57
+ import {
58
+ type ApplyReporter,
59
+ applyPlan,
60
+ planWorkItems,
61
+ } from "../services/update-engine.js";
62
+ import {
63
+ type UpdatePlan,
64
+ planBinUpdates,
65
+ planHasWork,
66
+ planIsBehind,
67
+ planPluginUpdates,
68
+ planSkillUpdates,
69
+ summarizePlan,
70
+ } from "../services/update-plan.js";
71
+ import { brand } from "../ui/theme.js";
72
+ import { bold, dim, fg, width } from "./ansi.js";
73
+ import { ensureManifest } from "./bootstrap.js";
74
+ import { LiveRegion, withPaused } from "./live.js";
75
+ import {
76
+ type ApplyState,
77
+ type Step,
78
+ applyFrame,
79
+ applyRow,
80
+ fail as failLine,
81
+ header,
82
+ info,
83
+ note as noteLine,
84
+ nothingToApplyNotice,
85
+ ok as okLine,
86
+ planReport,
87
+ stepFrame,
88
+ stepRecord,
89
+ summaryLine,
90
+ uncheckedGateNotice,
91
+ unknownPluginsNotice,
92
+ unknownToolsNotice,
93
+ warn as warnLine,
94
+ } from "./update-view.js";
95
+
96
+ interface UpdateFlags {
97
+ /** Report drift and exit 1 if anything is behind. The CI gate. */
98
+ check: boolean;
99
+ /** Show the plan and stop. Always exits 0 — it is a preview, not a gate. */
100
+ dryRun: boolean;
101
+ profile?: string;
102
+ }
103
+
104
+ /**
105
+ * `--yes` / `-y` are gone, and are silently ignored rather than rejected.
106
+ *
107
+ * They existed to skip a confirmation that no longer exists: `update` applies
108
+ * its plan. Both start with `-`, so an old script or a habit still runs and
109
+ * still does the right thing; there is no flag kept alive to mean nothing.
110
+ */
111
+ export function parseArgs(args: string[]): UpdateFlags {
112
+ return {
113
+ check: args.includes("--check"),
114
+ dryRun: args.includes("--dry-run"),
115
+ profile: args.find((a) => !a.startsWith("-")),
116
+ };
117
+ }
118
+
119
+ // -- phase runner ------------------------------------------------------------
120
+
121
+ /**
122
+ * Run one checklist step, keeping its row and the animation truthful.
123
+ *
124
+ * `work` gets a `say` callback so a long step can narrate what it is currently
125
+ * blocked on — the marketplace step names the clone it is pulling, the binary
126
+ * step names the binary it is probing. That detail is the difference between
127
+ * "something is happening" and "the network call to THIS marketplace is slow".
128
+ *
129
+ * A step that throws is marked `fail` and RE-THROWN. Marking it matters: the
130
+ * record printed on the way out then names the step that died, instead of a
131
+ * stack trace landing under a checklist whose last row is still spinning.
132
+ */
133
+ function makeStepRunner(live: LiveRegion, all: Step[]) {
134
+ return async function runStep<T>(
135
+ step: Step,
136
+ work: (say: (detail: string) => void) => Promise<T>,
137
+ ): Promise<T> {
138
+ step.state = "running";
139
+ step.startedAt = Date.now();
140
+ step.detail = step.detail || "…";
141
+ live.refresh();
142
+ const say = (detail: string) => {
143
+ step.detail = detail;
144
+ live.refresh();
145
+ };
146
+ try {
147
+ return await work(say);
148
+ } catch (error) {
149
+ step.state = "fail";
150
+ step.detail = error instanceof Error ? error.message : String(error);
151
+ step.endedAt = Date.now();
152
+ step.progress = undefined;
153
+ // Collapse to the record on the way out. Without this the exception
154
+ // surfaces under a checklist frozen mid-spin, with the cursor still
155
+ // hidden — the process-exit hook restores the cursor but cannot know
156
+ // what the last frame should have said.
157
+ live.stop(stepRecord(all, Date.now()));
158
+ throw error;
159
+ } finally {
160
+ step.endedAt = Date.now();
161
+ step.progress = undefined;
162
+ if (step.state === "running") step.state = "done";
163
+ live.refresh();
164
+ }
165
+ };
166
+ }
167
+
168
+ // -- entry point -------------------------------------------------------------
169
+
170
+ export async function runUpdateCommand(
171
+ args: string[],
172
+ projectPath: string = process.cwd(),
173
+ ): Promise<number> {
174
+ const flags = parseArgs(args);
175
+ const startedAt = Date.now();
176
+
177
+ // The ONE prompt that survives, and only in a repo that has no manifest yet.
178
+ // Writing `.claude/profiles.json` is not part of updating: it authors the
179
+ // file a team commits and shares, possibly derived from this machine's
180
+ // personal user-scope plugin set. That is worth a human, once per repo,
181
+ // forever. Everything after it applies without asking.
182
+ const manifest = await ensureManifest(projectPath, {
183
+ check: flags.check || flags.dryRun,
184
+ });
185
+ if (!manifest) return 1;
186
+
187
+ const errors = validateManifest(manifest);
188
+ if (errors.length > 0) {
189
+ console.error(failLine("Invalid .claude/profiles.json:"));
190
+ for (const e of errors) console.error(noteLine(`${e.path}: ${e.message}`));
191
+ return 1;
192
+ }
193
+
194
+ const profileIds = Object.keys(manifest.profiles);
195
+ if (flags.profile && !manifest.profiles[flags.profile]) {
196
+ console.error(
197
+ failLine(
198
+ `Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
199
+ ),
200
+ );
201
+ return 1;
202
+ }
203
+
204
+ // Active profile, not the union — "update my dependencies" means the set in
205
+ // use. A single-profile manifest needs no activation to be unambiguous.
206
+ // Chosen through the same function the TUI uses, so both judge a plugin
207
+ // against the same pin.
208
+ const targetId = selectProfileId(
209
+ profileIds,
210
+ flags.profile,
211
+ await activeProfile(projectPath),
212
+ );
213
+ if (!targetId) {
214
+ console.error(
215
+ failLine(
216
+ `No active profile. Pass one: magus update <${profileIds.join("|")}>`,
217
+ ),
218
+ );
219
+ return 1;
220
+ }
221
+
222
+ for (const line of header(targetId)) console.log(line);
223
+
224
+ // ---- resolve phase, under one animated checklist -------------------------
225
+
226
+ const steps: Record<
227
+ "profile" | "marketplaces" | "catalog" | "binaries" | "skills",
228
+ Step
229
+ > = {
230
+ profile: { label: "profile", state: "pending", detail: "" },
231
+ marketplaces: { label: "marketplaces", state: "pending", detail: "" },
232
+ catalog: { label: "catalog", state: "pending", detail: "" },
233
+ binaries: { label: "binaries", state: "pending", detail: "" },
234
+ skills: { label: "skills", state: "pending", detail: "" },
235
+ };
236
+ const stepList = Object.values(steps);
237
+
238
+ const live = new LiveRegion();
239
+ live.start((tick) => stepFrame(stepList, tick, Date.now()));
240
+ const runStep = makeStepRunner(live, stepList);
241
+
242
+ /** Notices worth keeping, flushed after the checklist collapses. */
243
+ const notices: string[] = [];
244
+
245
+ const closure = await runStep(steps.profile, async (say) => {
246
+ say(`resolving "${targetId}"`);
247
+ const resolved = await resolveProfile(manifest, targetId);
248
+ steps.profile.detail = [
249
+ `${Object.keys(resolved.plugins).length} plugins`,
250
+ `${resolved.bins.length} tools`,
251
+ `${resolved.skills.length} skills`,
252
+ ].join(" · ");
253
+ return resolved;
254
+ });
255
+
256
+ // "latest" is only as fresh as the catalog. Fast-forward the marketplace
257
+ // clones first, then drop every cached answer derived from the old HEAD —
258
+ // skipping this is how a clone sits days behind while every plugin reads as
259
+ // up to date.
260
+ // ...but NOT under --dry-run or --check. Both promise to write nothing, and
261
+ // `git pull --ff-only` on a clone under ~/.claude/plugins/marketplaces/ is a
262
+ // write — it advances the user's marketplace to a new commit. The help text
263
+ // said "Writes nothing, exits 0" while the run printed "1 refreshed" in its
264
+ // own output, which is where this was caught. A read-only run plans against
265
+ // the clone as it stands; the notice below says so, so an unrefreshed
266
+ // catalog is never mistaken for a current one.
267
+ const refresh = await runStep(steps.marketplaces, async (say) => {
268
+ if (flags.dryRun || flags.check) {
269
+ say("read-only — selecting, not pulling");
270
+ // Report-only, NOT a skip: the autoUpdate-disabled verdict is a pure
271
+ // read and is the one diagnostic a read-only run must not lose.
272
+ const report = await reportRegisteredMarketplaces();
273
+ steps.marketplaces.detail = `not refreshed (read-only) · ${report.skipped.length} eligible`;
274
+ return report;
275
+ }
276
+ let settled = 0;
277
+ return refreshRegisteredMarketplaces([], {
278
+ onStart: (names) => {
279
+ // Only now is there a denominator; until `onStart` fires the step
280
+ // shows a sweep rather than a meter that would have to guess one.
281
+ steps.marketplaces.progress = { done: 0, total: names.length };
282
+ say(
283
+ names.length === 0 ? "none eligible" : `pulling ${names.join(", ")}`,
284
+ );
285
+ },
286
+ onSettled: (name, status) => {
287
+ settled++;
288
+ if (steps.marketplaces.progress)
289
+ steps.marketplaces.progress.done = settled;
290
+ say(`${name} ${status}`);
291
+ },
292
+ });
293
+ });
294
+ steps.marketplaces.detail = [
295
+ `${refresh.refreshed.length} refreshed`,
296
+ refresh.autoUpdateDisabled.length > 0
297
+ ? `${refresh.autoUpdateDisabled.length} auto-update off`
298
+ : "",
299
+ refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
300
+ refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
301
+ ]
302
+ .filter(Boolean)
303
+ .join(" · ");
304
+ if (refresh.failed.length > 0) steps.marketplaces.state = "fail";
305
+ else if (refresh.autoUpdateDisabled.length > 0)
306
+ steps.marketplaces.state = "warn";
307
+
308
+ for (const name of refresh.autoUpdateDisabled) {
309
+ notices.push(
310
+ warnLine(
311
+ `${bold(name)}: auto-update disabled — its catalog will not refresh, so updates stay hidden.`,
312
+ ),
313
+ );
314
+ notices.push(
315
+ noteLine(
316
+ `claude plugin marketplace update ${name} # to refresh it once`,
317
+ ),
318
+ );
319
+ }
320
+ for (const name of refresh.failed) {
321
+ notices.push(
322
+ failLine(
323
+ `${bold(name)}: refresh failed — clone left intact but stale, so its versions may be behind.`,
324
+ ),
325
+ );
326
+ }
327
+
328
+ clearMarketplaceCache();
329
+ clearContentDriftCache();
330
+
331
+ const catalog = await runStep(steps.catalog, async (say) => {
332
+ say("reading installed and available plugins");
333
+ const map = new Map<string, PluginInfo>(
334
+ (await getAvailablePlugins(projectPath)).map((p) => [p.id, p]),
335
+ );
336
+ steps.catalog.detail = `${map.size} plugins known`;
337
+ return map;
338
+ });
339
+
340
+ // The tool probe: installed version, owner, and the owning installer's own
341
+ // "behind?" answer — the same probe the TUI's CLI Tools screen uses. It used
342
+ // to be a presence check, which is why every present unpinned tool planned
343
+ // as an upgrade on every run.
344
+ const toolFacts = await runStep(steps.binaries, async (say) => {
345
+ if (closure.bins.length === 0) {
346
+ steps.binaries.detail = "none declared";
347
+ return [];
348
+ }
349
+ steps.binaries.progress = { done: 0, total: closure.bins.length };
350
+ const facts = await probeTools(closure.bins, REAL_TOOL_PROBE_IO, {
351
+ // `settled`, not `index`: results arrive in completion order, and a
352
+ // meter driven by index would jump backwards.
353
+ onResult: (f, { settled, total }) => {
354
+ steps.binaries.progress = { done: settled, total };
355
+ say(f.name);
356
+ },
357
+ });
358
+ const items = planBinUpdates(closure.bins, facts);
359
+ const n = (a: string) => items.filter((i) => i.action === a).length;
360
+ const behind = n("upgrade");
361
+ const unknown = n("unknown");
362
+ const missing = n("install");
363
+ steps.binaries.detail = [
364
+ `${items.length} checked`,
365
+ behind > 0 ? `${behind} behind` : "",
366
+ unknown > 0 ? `${unknown} unknown` : "",
367
+ missing > 0 ? `${missing} missing` : "",
368
+ ]
369
+ .filter(Boolean)
370
+ .join(" · ");
371
+ if (missing + unknown > 0) steps.binaries.state = "warn";
372
+ return facts;
373
+ });
374
+
375
+ const installedSkills = await runStep(steps.skills, async (say) => {
376
+ say("reading .claude/skills");
377
+ const names = await getInstalledSkillNames("project", projectPath);
378
+ steps.skills.detail =
379
+ closure.skills.length === 0
380
+ ? "none declared"
381
+ : `${names.size} installed of ${closure.skills.length} declared`;
382
+ return names;
383
+ });
384
+
385
+ // Collapse the animation into its permanent record, then flush the notices
386
+ // underneath it — a warning printed mid-checklist would have scrolled past.
387
+ live.stop(stepRecord(stepList, Date.now()));
388
+ for (const line of notices) console.log(line);
389
+
390
+ // ---- plan ----------------------------------------------------------------
391
+
392
+ const plan: UpdatePlan = {
393
+ profileId: targetId,
394
+ plugins: planPluginUpdates(closure.plugins, catalog),
395
+ bins: planBinUpdates(closure.bins, toolFacts),
396
+ skills: planSkillUpdates(closure.skills, installedSkills),
397
+ binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
398
+ };
399
+
400
+ for (const line of planReport(plan)) console.log(line);
401
+
402
+ const counts = summarizePlan(plan);
403
+ const unknownToolNames = plan.bins
404
+ .filter((b) => b.action === "unknown")
405
+ .map((b) => b.name);
406
+ const toolUnknownLine = () =>
407
+ unknownToolsNotice(counts.unknownTools, unknownToolNames);
408
+ if (counts.unknownPlugins > 0 || counts.unknownTools > 0) console.log("");
409
+ if (counts.unknownPlugins > 0) {
410
+ console.log(unknownPluginsNotice(counts.unknownPlugins));
411
+ }
412
+ if (counts.unknownTools > 0) console.log(toolUnknownLine());
413
+
414
+ // `--check` gates on planIsBehind, not planHasWork. An already-installed
415
+ // skill is re-fetched on every apply because its manifest ref carries no
416
+ // content hash, which is no evidence that anything is out of date — counting
417
+ // it made the gate exit 1 forever for any profile declaring a single skill.
418
+ //
419
+ // An `unknown` does not fail the gate, for plugins and tools alike (see
420
+ // planIsBehind). The same argument applies to both: a plugin whose
421
+ // marketplace publishes no version, or a tool another npm owns, is unknown
422
+ // permanently, so failing on it is the skill case again — this profile's 5
423
+ // unversioned official plugins made `--check` exit 1 on every run. A tool
424
+ // or plugin something says is BEHIND still does fail it. Because a failed
425
+ // check now passes silently by exit code, the unchecked count is printed
426
+ // beside the verdict; the per-kind notices and each row's reason are above.
427
+ if (flags.check) {
428
+ console.log("");
429
+ if (!planIsBehind(plan)) {
430
+ if (counts.unknown > 0) console.log(uncheckedGateNotice(counts.unknown));
431
+ console.log(okLine("Nothing missing and nothing behind."));
432
+ return 0;
433
+ }
434
+ console.error(
435
+ failLine(`Updates available. Run ${bold("magus update")} to apply.`),
436
+ );
437
+ return 1;
438
+ }
439
+
440
+ // Exactly the items the engine will touch. Derived by the engine from the
441
+ // same predicates its loops use, so the meter's denominator cannot drift
442
+ // from the work — a bar stuck short of full reads as a hang.
443
+ const workItems = planWorkItems(plan);
444
+
445
+ if (!planHasWork(plan) || workItems.length === 0) {
446
+ for (const line of summaryLine(
447
+ 0,
448
+ counts.current,
449
+ counts.unknown,
450
+ 0,
451
+ Date.now() - startedAt,
452
+ ))
453
+ console.log(line);
454
+ // `planHasWork` counts an `unknown` plugin as work, because from the
455
+ // planner's side "we could not check this" is genuinely not "this is
456
+ // fine". But nothing is APPLIED for an unknown — the apply loop skips it —
457
+ // so reporting on `planHasWork` alone claimed a run had happened when it
458
+ // had not. Say what is actually true instead.
459
+ if (counts.unknownPlugins + counts.unknownTools > 0) {
460
+ if (counts.unknownPlugins > 0) {
461
+ console.log(nothingToApplyNotice(counts.unknownPlugins));
462
+ }
463
+ if (counts.unknownTools > 0) console.log(toolUnknownLine());
464
+ return 0;
465
+ }
466
+ console.log(okLine("Everything is up to date."));
467
+ return 0;
468
+ }
469
+
470
+ // `--dry-run` stops here: the plan is printed above, nothing is written, and
471
+ // it exits 0. That is the difference from `--check`, which is a CI gate and
472
+ // exits 1 precisely when there IS something to do.
473
+ if (flags.dryRun) {
474
+ console.log("");
475
+ console.log(
476
+ info(
477
+ `Dry run — ${workItems.length} item(s) would be applied. Nothing written.`,
478
+ ),
479
+ );
480
+ return 0;
481
+ }
482
+
483
+ // No confirmation. `update` moves the machine toward what the profile
484
+ // declares and can only go FORWARD: `claude plugin install` takes no version
485
+ // and installs the newest published one, so there is no downgrade and no
486
+ // delete for a prompt to protect against. The plan is printed above, so the
487
+ // run is still legible after the fact — and `--dry-run` shows it beforehand.
488
+ //
489
+ // Note this also runs a profile's CLI-tool installers unattended (`brew
490
+ // upgrade`, `bun install -g`). That is the deliberate cost of the choice.
491
+
492
+ if (!(await isClaudeAvailable())) {
493
+ console.error(
494
+ failLine(
495
+ "claude CLI not found on PATH — cannot install or update plugins.",
496
+ ),
497
+ );
498
+ return 1;
499
+ }
500
+
501
+ // Register any marketplace the profile names but this machine does not know.
502
+ // Without this an install fails with "not found in marketplace" for anything
503
+ // outside magus's built-in list, which is every private or third-party one.
504
+ await registerClosureMarketplaces(closure.marketplaces);
505
+
506
+ // ---- apply phase, under an animated meter --------------------------------
507
+
508
+ const nameWidth = Math.max(12, ...workItems.map((n) => width(n)));
509
+
510
+ const state: ApplyState = {
511
+ done: 0,
512
+ total: workItems.length,
513
+ current: "starting",
514
+ startedAt: Date.now(),
515
+ };
516
+
517
+ const applyLive = new LiveRegion();
518
+ /** The last detail each item finished with — the reason, for an unchanged one. */
519
+ const finishedDetail = new Map<string, string>();
520
+ const reporter: ApplyReporter = {
521
+ begin: (name) => {
522
+ state.current = name;
523
+ applyLive.refresh();
524
+ },
525
+ finish: (succeeded, name, detail, ms) => {
526
+ state.done++;
527
+ finishedDetail.set(name, detail);
528
+ applyLive.note(
529
+ applyRow(
530
+ succeeded,
531
+ name,
532
+ nameWidth,
533
+ !succeeded
534
+ ? fg(brand.danger, detail)
535
+ : detail.startsWith("no change — ")
536
+ ? fg(brand.warning, detail)
537
+ : dim(detail),
538
+ ms,
539
+ ),
540
+ );
541
+ },
542
+ detach: (work) => withPaused(applyLive, work),
543
+ };
544
+
545
+ console.log("");
546
+ applyLive.start((tick) => applyFrame(state, tick, Date.now()));
547
+
548
+ const outcome = await applyPlan(plan, projectPath, { report: reporter });
549
+
550
+ applyLive.stop();
551
+
552
+ const { failed } = outcome;
553
+
554
+ for (const line of summaryLine(
555
+ outcome.changed,
556
+ counts.current,
557
+ counts.unknown,
558
+ failed.length,
559
+ Date.now() - startedAt,
560
+ outcome.unchanged.length,
561
+ ))
562
+ console.log(line);
563
+
564
+ // Exited 0, read back unmoved. Reported, never fatal — the same precedent
565
+ // as the pin mismatches below: this is a standing property of the machine
566
+ // (a stale tap, a shadowing PATH entry), not something this run did wrong,
567
+ // and it will be replanned. What it must never be is counted as "changed".
568
+ if (outcome.unchanged.length > 0) {
569
+ console.log(
570
+ warnLine(
571
+ `${bold(String(outcome.unchanged.length))} item(s) ran but did not change:`,
572
+ ),
573
+ );
574
+ for (const name of outcome.unchanged) {
575
+ const detail = (finishedDetail.get(name) ?? "").replace(
576
+ /^no change — /,
577
+ "",
578
+ );
579
+ console.log(noteLine(`${name}: ${detail}`));
580
+ }
581
+ }
582
+
583
+ // An exact pin the installer could not deliver. Reported, never fatal: only
584
+ // the newest published version is installable, so this is a standing property
585
+ // of the manifest rather than something this run did wrong, and failing here
586
+ // would make `magus update` exit 1 forever for any profile with a pin.
587
+ // `install --check` with `strictVersions` is where pin drift is GATED.
588
+ if (outcome.plugins.mismatches.length > 0) {
589
+ console.log(
590
+ warnLine(
591
+ `${bold(String(outcome.plugins.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
592
+ ),
593
+ );
594
+ for (const m of outcome.plugins.mismatches) {
595
+ console.log(
596
+ noteLine(
597
+ `${m.pluginId} (${m.scope}): manifest pins ${m.pinned}, installed ${m.actual ?? "unknown"}`,
598
+ ),
599
+ );
600
+ }
601
+ console.log(
602
+ noteLine(
603
+ "Recorded what is actually installed. Update the pin to match, or leave it as a record of intent.",
604
+ ),
605
+ );
606
+ }
607
+
608
+ if (failed.length > 0) {
609
+ console.error(failLine(`Failed: ${failed.join(", ")}`));
610
+ return 1;
611
+ }
612
+ return 0;
613
+ }