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,524 @@
1
+ /**
2
+ * Tests for the `magus update` report.
3
+ *
4
+ * These assert the things a screenshot cannot: that every row lines up at every
5
+ * data shape, that turning colour off leaves a report someone can still read,
6
+ * and that no bar lies about what it measures.
7
+ *
8
+ * The alignment tests exist because the old report built each row with string
9
+ * concatenation and `String.padEnd`, which counts escape bytes as columns. Add
10
+ * one colour to one cell and every column below it shifts — invisible to a
11
+ * character-level assertion, obvious and ugly on screen.
12
+ */
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import { setColorEnabled, stripAnsi, width } from "../cli/ansi.js";
16
+ import {
17
+ type ApplyState,
18
+ type Step,
19
+ applyFrame,
20
+ applyRow,
21
+ header,
22
+ nothingToApplyNotice,
23
+ planReport,
24
+ stepFrame,
25
+ stepRecord,
26
+ summaryLine,
27
+ uncheckedGateNotice,
28
+ unknownToolsNotice,
29
+ } from "../cli/update-view.js";
30
+ import type { UpdatePlan } from "../services/update-plan.js";
31
+
32
+ afterEach(() => setColorEnabled(null));
33
+
34
+ // -- fixtures -----------------------------------------------------------------
35
+
36
+ function plan(over: Partial<UpdatePlan> = {}): UpdatePlan {
37
+ return {
38
+ profileId: "default",
39
+ plugins: [
40
+ {
41
+ pluginId: "agentdev@magus",
42
+ pinned: "latest",
43
+ installed: "1.7.0",
44
+ available: "1.7.0",
45
+ target: null,
46
+ action: "current",
47
+ scopes: ["project"],
48
+ },
49
+ {
50
+ pluginId: "dev@magus",
51
+ pinned: "latest",
52
+ installed: "4.6.1",
53
+ available: "5.0.0",
54
+ target: "5.0.0",
55
+ action: "update",
56
+ scopes: ["project"],
57
+ },
58
+ {
59
+ pluginId: "feature-dev@claude-plugins-official",
60
+ pinned: "latest",
61
+ installed: null,
62
+ available: null,
63
+ target: null,
64
+ action: "install",
65
+ scopes: [],
66
+ },
67
+ {
68
+ pluginId: "designer@magus",
69
+ pinned: "latest",
70
+ installed: "1.8.0",
71
+ available: null,
72
+ target: null,
73
+ action: "unknown",
74
+ scopes: ["project"],
75
+ note: "catalog unreachable",
76
+ },
77
+ ],
78
+ bins: [
79
+ {
80
+ name: "tmux-mcp",
81
+ action: "current",
82
+ reason: "at-pin",
83
+ version: "v1.7.1",
84
+ installed: "v1.7.1",
85
+ latest: null,
86
+ target: null,
87
+ sources: [],
88
+ },
89
+ {
90
+ name: "tmux",
91
+ action: "upgrade",
92
+ reason: "behind",
93
+ installed: "3.7b",
94
+ latest: "3.7c",
95
+ target: "3.7c",
96
+ note: "3.7c is available",
97
+ sources: [],
98
+ },
99
+ ],
100
+ skills: [
101
+ {
102
+ name: "release",
103
+ action: "install",
104
+ ref: { name: "release", repo: "MadAppGang/magus", path: "s/SKILL.md" },
105
+ },
106
+ ],
107
+ binSpecs: new Map([
108
+ ["tmux-mcp", { name: "tmux-mcp", via: "bun", sources: [] }],
109
+ ["tmux", { name: "tmux", via: "brew", formula: "tmux", sources: [] }],
110
+ ]),
111
+ ...over,
112
+ } as UpdatePlan;
113
+ }
114
+
115
+ function steps(): Step[] {
116
+ return [
117
+ {
118
+ label: "profile",
119
+ state: "done",
120
+ detail: "4 plugins",
121
+ startedAt: 0,
122
+ endedAt: 100,
123
+ },
124
+ {
125
+ label: "marketplaces",
126
+ state: "running",
127
+ detail: "pulling magus",
128
+ startedAt: 100,
129
+ progress: { done: 1, total: 4 },
130
+ },
131
+ { label: "catalog", state: "pending", detail: "" },
132
+ ];
133
+ }
134
+
135
+ // -- alignment ----------------------------------------------------------------
136
+
137
+ describe("alignment", () => {
138
+ test("every plugin row shares one column layout, colour or not", () => {
139
+ setColorEnabled(true);
140
+ const rows = planReport(plan())
141
+ .filter((l) => /@/.test(stripAnsi(l)))
142
+ .map((l) => stripAnsi(l));
143
+ // Every row's version column starts at the same visible offset, whatever
144
+ // mix of badges, dim text and notes the rows carry.
145
+ const offsets = rows.map((r) => r.indexOf("1.") + r.indexOf("latest"));
146
+ expect(offsets.length).toBeGreaterThan(0);
147
+ const versionCol = rows.map((r) =>
148
+ r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/),
149
+ );
150
+ expect(new Set(versionCol).size).toBe(1);
151
+ });
152
+
153
+ test("the same row is the same visible width with colour on and off", () => {
154
+ setColorEnabled(false);
155
+ const plain = planReport(plan()).map((l) => width(l));
156
+ setColorEnabled(true);
157
+ const painted = planReport(plan()).map((l) => width(l));
158
+ expect(painted).toEqual(plain);
159
+ });
160
+
161
+ test("a very long plugin id widens the column for every row", () => {
162
+ setColorEnabled(false);
163
+ const long = "a-very-long-plugin-name-indeed@some-marketplace";
164
+ const p = plan();
165
+ p.plugins[0]!.pluginId = long;
166
+ const rows = planReport(p).filter((l) => l.includes("@"));
167
+ const cols = rows.map((r) => r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/));
168
+ expect(new Set(cols).size).toBe(1);
169
+ // Not `rows[0]`: grouping means row order follows state, not plan order,
170
+ // and this fixture's long id is `current` — the LAST group. The claim is
171
+ // that the id is printed in full somewhere, not where it lands.
172
+ expect(rows.some((r) => r.includes(long))).toBe(true);
173
+ });
174
+ });
175
+
176
+ // -- readability without colour ----------------------------------------------
177
+
178
+ describe("NO_COLOR", () => {
179
+ test("the report emits no escape sequence at all", () => {
180
+ setColorEnabled(false);
181
+ const out = [
182
+ ...header("default"),
183
+ ...stepFrame(steps(), 3, 500),
184
+ ...stepRecord(steps(), 500),
185
+ ...planReport(plan()),
186
+ ...applyFrame(
187
+ { done: 1, total: 4, current: "dev@magus", startedAt: 0 },
188
+ 2,
189
+ 900,
190
+ ),
191
+ applyRow(true, "dev@magus", 20, "project", 400),
192
+ ...summaryLine(2, 5, 1, 0, 9000),
193
+ ].join("\n");
194
+ expect(out).not.toContain("\x1b");
195
+ });
196
+
197
+ test("every action stays distinguishable without colour", () => {
198
+ setColorEnabled(false);
199
+ const out = planReport(plan()).join("\n");
200
+ // Colour never carries meaning alone: the report has to survive a pipe
201
+ // and colour blindness. Plugins say their state as a GROUP HEADING and a
202
+ // glyph; the tool and skill sections still badge each row with its own
203
+ // action word.
204
+ for (const heading of ["Update", "Fix required", "Current"])
205
+ expect(out).toContain(heading);
206
+ for (const label of ["CURRENT", "UPGRADE", "INSTALL"])
207
+ expect(out).toContain(label);
208
+ for (const glyph of ["=", "↑", "+", "?"]) expect(out).toContain(glyph);
209
+ });
210
+
211
+ // Two states that an apply treats very differently, and which a reader must
212
+ // never have to tell apart by hue. `unknown` is grouped under Current and
213
+ // wears `?`; an update wears `↑` under its own heading.
214
+ test("an unverified row is not confusable with an up-to-date one", () => {
215
+ setColorEnabled(false);
216
+ const rows = planReport(plan());
217
+ const unknown = rows.find((l) => l.includes("designer@magus"))!;
218
+ const current = rows.find((l) => l.includes("agentdev@magus"))!;
219
+ expect(unknown).toContain("?");
220
+ expect(current).toContain("=");
221
+ expect(unknown).toContain("catalog unreachable");
222
+ });
223
+
224
+ test("a note rides along on the row it explains", () => {
225
+ setColorEnabled(false);
226
+ const row = planReport(plan()).find((l) => l.includes("designer@magus"))!;
227
+ expect(row).toContain("catalog unreachable");
228
+ });
229
+ });
230
+
231
+ // -- the bars tell the truth --------------------------------------------------
232
+
233
+ describe("bars", () => {
234
+ test("a running step with a denominator gets a determinate meter", () => {
235
+ setColorEnabled(false);
236
+ const frame = stepFrame(steps(), 0, 500);
237
+ // 1 of 4 → a quarter of the 18-cell bar filled.
238
+ const bar = frame[1]!.match(/[█░]+/)?.[0] ?? "";
239
+ expect(bar).toHaveLength(18);
240
+ expect(bar.split("█").length - 1).toBe(5);
241
+ });
242
+
243
+ test("a running step with no denominator gets a sweep, not a fake meter", () => {
244
+ setColorEnabled(false);
245
+ const s = steps();
246
+ s[1]!.progress = undefined;
247
+ const bar = stepFrame(s, 3, 500)[1]!.match(/[█░]+/)?.[0] ?? "";
248
+ expect(bar).toHaveLength(18);
249
+ // A sweep is partly filled at every tick; a "0%" meter would be all track.
250
+ expect(bar).toContain("█");
251
+ expect(bar).toContain("░");
252
+ });
253
+
254
+ test("only the running row carries a bar in the live frame", () => {
255
+ setColorEnabled(false);
256
+ const frame = stepFrame(steps(), 0, 500);
257
+ expect(frame[0]).not.toMatch(/[█░]/);
258
+ expect(frame[1]).toMatch(/[█░]/);
259
+ expect(frame[2]).not.toMatch(/[█░]/);
260
+ });
261
+
262
+ // The record carries NO bar: a share-of-the-slowest meter restated the
263
+ // millisecond column next to it. The durations themselves still have to be
264
+ // right, and still have to rank the steps.
265
+ test("the record reports each step's duration and draws no bar", () => {
266
+ setColorEnabled(false);
267
+ const record = stepRecord(
268
+ [
269
+ {
270
+ label: "fast",
271
+ state: "done",
272
+ detail: "",
273
+ startedAt: 0,
274
+ endedAt: 100,
275
+ },
276
+ {
277
+ label: "slow",
278
+ state: "done",
279
+ detail: "",
280
+ startedAt: 0,
281
+ endedAt: 1000,
282
+ },
283
+ ],
284
+ 1000,
285
+ );
286
+ for (const row of record) expect(row).not.toMatch(/[█░]/);
287
+ expect(record[0]).toContain("100ms");
288
+ expect(record[1]).toContain("1.0s");
289
+ });
290
+
291
+ test("a step that never ran reports no elapsed time, and does not throw", () => {
292
+ setColorEnabled(false);
293
+ const record = stepRecord(
294
+ [{ label: "x", state: "pending", detail: "" }],
295
+ 0,
296
+ );
297
+ expect(record[0]).toContain("x");
298
+ expect(record[0]).not.toMatch(/[█░]/);
299
+ });
300
+
301
+ test("the apply meter is full exactly when the work is done", () => {
302
+ setColorEnabled(false);
303
+ const state: ApplyState = { done: 4, total: 4, current: "", startedAt: 0 };
304
+ const bar = applyFrame(state, 0, 100)[1]!.match(/[█░]+/)![0];
305
+ expect(bar).toBe("█".repeat(36));
306
+ });
307
+
308
+ test("an empty apply reads as complete rather than as stuck at zero", () => {
309
+ setColorEnabled(false);
310
+ const state: ApplyState = { done: 0, total: 0, current: "", startedAt: 0 };
311
+ expect(applyFrame(state, 0, 100)[1]).toContain("█".repeat(36));
312
+ });
313
+
314
+ test("the summary bar survives an all-zero run", () => {
315
+ setColorEnabled(false);
316
+ const line = summaryLine(0, 0, 0, 0, 100)[1]!;
317
+ expect(line.match(/[█░]+/)![0]).toHaveLength(36);
318
+ });
319
+
320
+ test("unchanged is its own count, never folded into changed", () => {
321
+ setColorEnabled(false);
322
+ const line = summaryLine(1, 4, 0, 0, 100, 2)[1]!;
323
+ expect(line).toContain("1 changed");
324
+ expect(line).toContain("2 unchanged");
325
+ // Omitted when zero, and the parameter defaults to zero.
326
+ expect(summaryLine(1, 4, 0, 0, 100)[1]).not.toContain("unchanged");
327
+ });
328
+ });
329
+
330
+ // -- misc ---------------------------------------------------------------------
331
+
332
+ describe("sections", () => {
333
+ test("an empty section is omitted entirely, not printed as a heading", () => {
334
+ setColorEnabled(false);
335
+ const out = planReport(plan({ bins: [], skills: [] }) as UpdatePlan).join(
336
+ "\n",
337
+ );
338
+ expect(out).toContain("Plugins");
339
+ expect(out).not.toContain("CLI");
340
+ expect(out).not.toContain("Skills");
341
+ });
342
+
343
+ // The Plugins section is grouped by state, so its counts live on the group
344
+ // headings, not on one legend line. The invariant that matters is the one
345
+ // that broke once already: the counts printed must add up to the rows
346
+ // printed. A fixed heading order used to render an unnamed group as nothing
347
+ // while the total above still counted it — "Plugins 4" over three rows.
348
+ test("every plugin row sits under a heading, and the headings add up", () => {
349
+ setColorEnabled(false);
350
+ const out = planReport(plan());
351
+ const total = out.find((l) => /^\s+Plugins\s+\d+$/.test(l))!;
352
+ expect(total).toContain("4");
353
+
354
+ // Matches ANY "<words> <count>" heading, not a fixed alternation of the
355
+ // group names. A named list silently stops counting a group that is added
356
+ // later, which is the same blind spot as the fixed order above — the sum
357
+ // then holds against a subset and the test passes while rows go unheaded.
358
+ const headings = out.flatMap((l) => {
359
+ const m = l.match(/^\s+([A-Z][A-Za-z ]+?)\s\s+(\d+)$/);
360
+ return m && m[1] !== "Plugins"
361
+ ? [{ group: m[1], count: Number(m[2]) }]
362
+ : [];
363
+ });
364
+ // This fixture has one of each except Deprecated, whose row would need a
365
+ // `not-offered` reason. `designer@magus` is unknown with a note, so it is
366
+ // Unverified — NOT Current, which would claim a failed check succeeded.
367
+ expect(headings.map((h) => h.group)).toEqual([
368
+ "Update",
369
+ "Fix required",
370
+ "Unverified",
371
+ "Current",
372
+ ]);
373
+ // Every counted row is a row on screen: 1 + 1 + 1 + 1 === the 4 above.
374
+ const rows = out.filter((l) => l.includes("@"));
375
+ expect(headings.reduce((n, h) => n + h.count, 0)).toBe(rows.length);
376
+ expect(rows.length).toBe(4);
377
+ });
378
+
379
+ // The regression the reviewer caught before release: a pin naming a plugin no
380
+ // marketplace offers, with nothing installed, was filtered out of the table
381
+ // on the grounds that a notice printed it instead. That notice never existed.
382
+ // The row appeared nowhere while the closing lines still counted it.
383
+ test("a pin nothing offers still gets a row", () => {
384
+ setColorEnabled(false);
385
+ const p = plan();
386
+ p.plugins = [
387
+ {
388
+ pluginId: "agentdev@magus",
389
+ pinned: "latest",
390
+ installed: null,
391
+ available: null,
392
+ target: null,
393
+ action: "unknown",
394
+ reason: "not-offered",
395
+ scopes: [],
396
+ },
397
+ ...p.plugins,
398
+ ] as UpdatePlan["plugins"];
399
+
400
+ const out = planReport(p);
401
+ expect(out.join("\n")).toContain("agentdev@magus");
402
+ // And it is counted where it is shown, not somewhere else.
403
+ const total = out.find((l) => /^\s+Plugins\s+\d+$/.test(l))!;
404
+ expect(total).toContain("5");
405
+ expect(out.filter((l) => l.includes("@")).length).toBe(5);
406
+ });
407
+
408
+ test("a CLI upgrade shows the command that will run", () => {
409
+ setColorEnabled(false);
410
+ const row = planReport(plan()).find(
411
+ (l) => l.includes("tmux ") && l.includes("UPGRADE"),
412
+ )!;
413
+ expect(row).toContain("brew upgrade tmux");
414
+ expect(row).toContain("3.7b → 3.7c");
415
+ });
416
+
417
+ test("an unpinned CURRENT tool shows its version, not 'pinned —' (F8)", () => {
418
+ setColorEnabled(false);
419
+ const p = plan();
420
+ p.bins = [
421
+ {
422
+ name: "tmux",
423
+ action: "current",
424
+ reason: "up-to-date",
425
+ installed: "3.7c",
426
+ latest: null,
427
+ target: null,
428
+ sources: [],
429
+ },
430
+ ];
431
+ const row = planReport(p).find(
432
+ (l) => l.includes("tmux ") && l.includes("CURRENT"),
433
+ )!;
434
+ expect(row).toContain("3.7c");
435
+ expect(row).not.toContain("pinned");
436
+ });
437
+
438
+ test("a pinned current tool still names its pin", () => {
439
+ setColorEnabled(false);
440
+ const row = planReport(plan()).find((l) => l.includes("tmux-mcp"))!;
441
+ expect(row).toContain("pinned v1.7.1");
442
+ });
443
+
444
+ test("an UNKNOWN tool shows its reason and no command", () => {
445
+ setColorEnabled(false);
446
+ const p = plan();
447
+ p.bins = [
448
+ {
449
+ name: "crush",
450
+ action: "unknown",
451
+ reason: "not-managed",
452
+ note: "on PATH at /n/crush.js, which npm (root /l) does not own",
453
+ installed: "0.7.1",
454
+ latest: null,
455
+ target: null,
456
+ sources: [],
457
+ },
458
+ ];
459
+ p.binSpecs = new Map([
460
+ [
461
+ "crush",
462
+ { name: "crush", via: "npm", package: "@charmland/crush", sources: [] },
463
+ ],
464
+ ]);
465
+ const row = planReport(p).find((l) => l.includes("crush"))!;
466
+ expect(row).toContain("0.7.1");
467
+ expect(row).toContain("which npm (root /l) does not own");
468
+ expect(row).not.toContain("npm install");
469
+ });
470
+ });
471
+
472
+ // -- unverified notices -------------------------------------------------------
473
+
474
+ describe("unverified notices", () => {
475
+ // A plugin is `unknown` for several reasons, each printed on its own row. The
476
+ // closing notices must hold for every one of them. They used to say "re-run
477
+ // once the catalog is reachable", which is false advice for a plugin whose
478
+ // marketplace publishes no version — the case a real dry run showed five of.
479
+ const notices = () => [
480
+ nothingToApplyNotice(5),
481
+ unknownToolsNotice(1, ["crush"]),
482
+ uncheckedGateNotice(6),
483
+ ];
484
+
485
+ test("never advise a re-run, never blame the catalog", () => {
486
+ setColorEnabled(false);
487
+ for (const line of notices()) {
488
+ expect(line).not.toMatch(/re-run/i);
489
+ expect(line).not.toContain("catalog is reachable");
490
+ expect(line).toContain("Reasons are shown above.");
491
+ }
492
+ });
493
+
494
+ // There is no per-plugin notice under the plan any more: every unverified
495
+ // plugin is a row in the table, under a heading naming its state, so the
496
+ // line restated the table and miscounted it. `nothingToApplyNotice` is a
497
+ // different case — it fires when there IS no table worth reading.
498
+ test("an unverified plugin is never reported as up to date", () => {
499
+ setColorEnabled(false);
500
+ const nothing = nothingToApplyNotice(5);
501
+ expect(nothing.trimStart()).toStartWith("⚠ Nothing to apply.");
502
+ expect(nothing).toContain("5 plugin(s) could not be checked");
503
+ expect(nothing).toContain("They are NOT reported as up to date.");
504
+ });
505
+
506
+ test("the tool notice names the tools it could not check", () => {
507
+ setColorEnabled(false);
508
+ expect(unknownToolsNotice(2, ["crush", "tmux"])).toContain(
509
+ "2 tool(s) could not be checked: crush, tmux — not applied.",
510
+ );
511
+ });
512
+
513
+ test("the gate notice says the unknowns passed without being verified", () => {
514
+ // `--check` exits 0 with unknowns present, so this line carries the whole
515
+ // difference between "nothing is known to be behind" and "all verified".
516
+ setColorEnabled(false);
517
+ const line = uncheckedGateNotice(6);
518
+ expect(line).toContain("6 item(s) could not be checked");
519
+ expect(line).toContain("they do not fail this gate");
520
+ expect(line).toContain("They are NOT reported as up to date.");
521
+ // A warning, not a failure: the gate is about to pass.
522
+ expect(line.trimStart()).toStartWith("⚠");
523
+ });
524
+ });