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,391 @@
1
+ /**
2
+ * Tests for the live region.
3
+ *
4
+ * The invariant worth defending is the ORDER of writes around a permanent line.
5
+ * `note()` must erase the animated block, write, then repaint. Writing first
6
+ * puts the line inside the block, where the next repaint's cursor-up counts it
7
+ * as a frame row and starts eating the scrollback record — a data-loss bug that
8
+ * looks like a flicker.
9
+ *
10
+ * The console-capture suite exists because that ordering was documented as a
11
+ * rule and the rule was broken in production by code two call layers away
12
+ * (`plugin-manager`'s stale-marketplace advisory, reached through
13
+ * `getAvailablePlugins`). A rule spanning a call graph this module does not own
14
+ * has to be a mechanism, so these tests pin the mechanism.
15
+ *
16
+ * The clamps are the other half. A frame line that reaches the terminal's last
17
+ * column auto-wraps into two rows while the repainter still counts it as one,
18
+ * so the block walks down the screen a row per frame.
19
+ */
20
+
21
+ import { afterEach, describe, expect, test } from "bun:test";
22
+ import { width } from "../cli/ansi.js";
23
+ import { LiveRegion, withPaused } from "../cli/live.js";
24
+
25
+ /** A write stream that records instead of drawing. */
26
+ function fakeStream(
27
+ opts: { isTTY?: boolean; columns?: number; rows?: number } = {},
28
+ ) {
29
+ const writes: string[] = [];
30
+ const listeners = new Map<string, Array<() => void>>();
31
+ const stream = {
32
+ write: (chunk: string) => {
33
+ writes.push(chunk);
34
+ return true;
35
+ },
36
+ on(event: string, fn: () => void) {
37
+ const list = listeners.get(event) ?? [];
38
+ list.push(fn);
39
+ listeners.set(event, list);
40
+ return this;
41
+ },
42
+ off(event: string, fn: () => void) {
43
+ const list = (listeners.get(event) ?? []).filter((f) => f !== fn);
44
+ listeners.set(event, list);
45
+ return this;
46
+ },
47
+ isTTY: opts.isTTY ?? true,
48
+ columns: opts.columns ?? 80,
49
+ rows: opts.rows ?? 24,
50
+ } as unknown as NodeJS.WriteStream & {
51
+ columns: number;
52
+ rows: number;
53
+ };
54
+ const emit = (event: string) => {
55
+ for (const fn of listeners.get(event) ?? []) fn();
56
+ };
57
+ return { stream, writes, emit, all: () => writes.join("") };
58
+ }
59
+
60
+ /**
61
+ * An animated region that needs no timer: paint via `refresh()`.
62
+ *
63
+ * `captureConsole` is OFF here. These tests assert on stream writes, and a
64
+ * region that hijacks the global console would also hijack the test runner's
65
+ * own reporting. The capture suite below opts back in deliberately.
66
+ */
67
+ function region(opts: Parameters<typeof fakeStream>[0] = {}) {
68
+ const fake = fakeStream(opts);
69
+ const live = new LiveRegion({
70
+ stream: fake.stream,
71
+ animate: true,
72
+ fps: 1000,
73
+ captureConsole: false,
74
+ });
75
+ return { ...fake, live };
76
+ }
77
+
78
+ describe("non-animated mode — a pipe, CI, or NO_COLOR", () => {
79
+ test("the transient block is never painted, but the record still prints", () => {
80
+ const fake = fakeStream({ isTTY: false });
81
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
82
+ live.start(() => ["spinning"]);
83
+ live.refresh();
84
+ live.note("permanent one");
85
+ live.stop(["final"]);
86
+
87
+ expect(fake.writes).toEqual(["permanent one\n", "final\n"]);
88
+ });
89
+
90
+ test("no escape sequence of any kind reaches a pipe", () => {
91
+ const fake = fakeStream({ isTTY: false });
92
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
93
+ live.start(() => ["frame"]);
94
+ live.note("line");
95
+ live.pause();
96
+ live.resume();
97
+ live.stop(["done"]);
98
+ expect(fake.all()).not.toContain("\x1b");
99
+ });
100
+
101
+ test("console is never captured when nothing is being painted", () => {
102
+ const fake = fakeStream({ isTTY: false });
103
+ const original = console.log;
104
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
105
+ live.start(() => ["frame"]);
106
+ try {
107
+ expect(console.log).toBe(original);
108
+ } finally {
109
+ live.stop();
110
+ }
111
+ });
112
+ });
113
+
114
+ describe("animated mode", () => {
115
+ test("start hides the cursor and paints the frame", () => {
116
+ const { live, all } = region();
117
+ live.start(() => ["row one", "row two"]);
118
+ expect(all()).toContain("\x1b[?25l");
119
+ expect(all()).toContain("row one\nrow two\n");
120
+ live.stop();
121
+ });
122
+
123
+ test("a repaint erases exactly the rows it painted", () => {
124
+ const { live, writes } = region();
125
+ live.start(() => ["a", "b", "c"]);
126
+ writes.length = 0;
127
+ live.refresh();
128
+ // Three rows painted, so three rows up and erase below.
129
+ expect(writes[0]).toBe("\x1b[3A\x1b[0J");
130
+ live.stop();
131
+ });
132
+
133
+ test("note erases BEFORE writing, then repaints", () => {
134
+ const { live, writes } = region();
135
+ live.start(() => ["frame"]);
136
+ writes.length = 0;
137
+ live.note("kept");
138
+
139
+ expect(writes[0]).toBe("\x1b[1A\x1b[0J");
140
+ expect(writes[1]).toBe("kept\n");
141
+ expect(writes[2]).toBe("frame\n");
142
+ });
143
+
144
+ test("a frame that shrinks does not leave orphaned rows", () => {
145
+ let rows = ["a", "b", "c"];
146
+ const { live, writes } = region();
147
+ live.start(() => rows);
148
+ rows = ["a"];
149
+ writes.length = 0;
150
+ live.refresh();
151
+ // Erase-below clears the two rows the shorter frame no longer covers.
152
+ expect(writes[0]).toBe("\x1b[3A\x1b[0J");
153
+ expect(writes[1]).toBe("a\n");
154
+ live.stop();
155
+ });
156
+
157
+ test("a line is clipped one column short of the terminal width", () => {
158
+ const { live, writes } = region({ columns: 20 });
159
+ live.start(() => ["x".repeat(40)]);
160
+ const painted = writes.at(-1) ?? "";
161
+ // DISPLAY width, not `.length`. `truncate` appends a reset when colour is
162
+ // on, and colour is on exactly when stdout is a real terminal — so a
163
+ // `.length` assertion passed piped and failed the moment anyone ran the
164
+ // suite in a terminal, over four zero-width bytes. The claim being made
165
+ // here is about columns, so measure columns.
166
+ expect(width(painted.replace(/\n$/, ""))).toBe(19);
167
+ live.stop();
168
+ });
169
+
170
+ test("the block is capped one row short of the terminal height", () => {
171
+ const { live, writes } = region({ rows: 6 });
172
+ live.start(() => Array.from({ length: 20 }, (_, i) => `row ${i}`));
173
+ const painted = (writes.at(-1) ?? "").split("\n").filter(Boolean);
174
+ expect(painted).toHaveLength(5);
175
+ live.stop();
176
+ });
177
+
178
+ test("stop erases the frame and restores the cursor", () => {
179
+ const { live, writes } = region();
180
+ live.start(() => ["frame"]);
181
+ writes.length = 0;
182
+ live.stop(["record"]);
183
+ expect(writes[0]).toBe("\x1b[1A\x1b[0J");
184
+ expect(writes[1]).toBe("record\n");
185
+ expect(writes[2]).toBe("\x1b[?25h");
186
+ });
187
+
188
+ test("stop with nothing painted still shows the cursor", () => {
189
+ const { live, all } = region();
190
+ live.start(() => []);
191
+ live.stop();
192
+ expect(all()).toContain("\x1b[?25h");
193
+ });
194
+ });
195
+
196
+ describe("resize", () => {
197
+ test("a resize abandons the old block instead of mis-erasing it", () => {
198
+ // `painted` is a row count measured at the OLD width. After a narrowing,
199
+ // the block occupies more rows than it says, so a cursor-up of `painted`
200
+ // lands inside it and smears. The only safe move is to stop claiming
201
+ // ownership of those rows.
202
+ const { live, writes, emit, stream } = region({ columns: 80 });
203
+ live.start(() => ["x".repeat(70)]);
204
+ writes.length = 0;
205
+ (stream as unknown as { columns: number }).columns = 40;
206
+ emit("resize");
207
+
208
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
209
+ const CURSOR_UP = /\x1b\[\d+A/;
210
+ expect(writes.some((w) => CURSOR_UP.test(w))).toBe(false);
211
+ // Display width, for the reason given on the clipping test above.
212
+ expect(width((writes.at(-1) ?? "").replace(/\n$/, ""))).toBe(39);
213
+ live.stop();
214
+ });
215
+
216
+ test("the resize listener is removed on stop", () => {
217
+ const { live, writes, emit } = region();
218
+ live.start(() => ["frame"]);
219
+ live.stop();
220
+ writes.length = 0;
221
+ emit("resize");
222
+ expect(writes).toEqual([]);
223
+ });
224
+ });
225
+
226
+ describe("handing the terminal over", () => {
227
+ test("pause erases the block and shows the cursor before the subprocess", () => {
228
+ const { live, writes } = region();
229
+ live.start(() => ["frame"]);
230
+ writes.length = 0;
231
+ live.pause();
232
+ expect(writes).toEqual(["\x1b[1A\x1b[0J", "\x1b[?25h"]);
233
+ });
234
+
235
+ test("note does NOT repaint while paused", () => {
236
+ // A repaint here draws the block underneath output from whatever now owns
237
+ // the terminal — corrupt, and in the way of a readline prompt.
238
+ const { live, writes } = region();
239
+ live.start(() => ["FRAME"]);
240
+ live.pause();
241
+ writes.length = 0;
242
+ live.note("subprocess said something");
243
+ expect(writes).toEqual(["subprocess said something\n"]);
244
+ });
245
+
246
+ test("resume repaints and re-hides", () => {
247
+ const { live, writes } = region();
248
+ live.start(() => ["frame"]);
249
+ live.pause();
250
+ writes.length = 0;
251
+ live.resume();
252
+ expect(writes[0]).toBe("\x1b[?25l");
253
+ expect(writes[1]).toBe("frame\n");
254
+ live.stop();
255
+ });
256
+
257
+ test("withPaused resumes even when the work throws", async () => {
258
+ const { live, writes } = region();
259
+ live.start(() => ["frame"]);
260
+ await expect(
261
+ withPaused(live, async () => {
262
+ throw new Error("installer failed");
263
+ }),
264
+ ).rejects.toThrow("installer failed");
265
+ // A resume repaint after the throw proves the region was handed back.
266
+ expect(writes.at(-1)).toBe("frame\n");
267
+ live.stop();
268
+ });
269
+
270
+ test("withPaused returns the work's value", async () => {
271
+ const { live } = region();
272
+ live.start(() => ["frame"]);
273
+ expect(await withPaused(live, async () => 42)).toBe(42);
274
+ live.stop();
275
+ });
276
+ });
277
+
278
+ describe("console capture — the invariant as a mechanism", () => {
279
+ /** Console is process-global: every test here must put it back. */
280
+ let restore: (() => void) | null = null;
281
+ afterEach(() => {
282
+ restore?.();
283
+ restore = null;
284
+ });
285
+
286
+ function capturing(opts: Parameters<typeof fakeStream>[0] = {}) {
287
+ const out = fakeStream(opts);
288
+ const err = fakeStream(opts);
289
+ const live = new LiveRegion({
290
+ stream: out.stream,
291
+ errorStream: err.stream,
292
+ animate: true,
293
+ fps: 1000,
294
+ });
295
+ const original = {
296
+ log: console.log,
297
+ info: console.info,
298
+ warn: console.warn,
299
+ error: console.error,
300
+ };
301
+ restore = () => {
302
+ live.stop();
303
+ Object.assign(console, original);
304
+ };
305
+ return { live, out, err };
306
+ }
307
+
308
+ test("a bare console.log from anywhere is routed, not left inside the block", () => {
309
+ // This is the production bug: plugin-manager's stale-marketplace advisory
310
+ // prints from two call layers below the catalog step. Without capture it
311
+ // lands under the block, the next cursor-up is short by a row, and the
312
+ // advisory is erased unread.
313
+ const { live, out } = capturing();
314
+ live.start(() => ["FRAME"]);
315
+ out.writes.length = 0;
316
+ console.log("ℹ Marketplace magus is stale");
317
+
318
+ expect(out.writes).toEqual([
319
+ "\x1b[1A\x1b[0J",
320
+ "ℹ Marketplace magus is stale\n",
321
+ "FRAME\n",
322
+ ]);
323
+ });
324
+
325
+ test("without capture the same call corrupts the block", () => {
326
+ // The negative control. This is what the code did before the fix, and it
327
+ // is why documenting the rule was not enough.
328
+ const out = fakeStream();
329
+ const live = new LiveRegion({
330
+ stream: out.stream,
331
+ animate: true,
332
+ fps: 1000,
333
+ captureConsole: false,
334
+ });
335
+ live.start(() => ["FRAME"]);
336
+ out.writes.length = 0;
337
+ out.stream.write("ℹ Marketplace magus is stale\n"); // an uncaptured write
338
+ live.refresh();
339
+ // One row up from a cursor now two rows below the block's start: the
340
+ // erase begins inside the frame rather than at its top.
341
+ expect(out.writes[1]).toBe("\x1b[1A\x1b[0J");
342
+ live.stop();
343
+ });
344
+
345
+ test("stderr stays on stderr", () => {
346
+ // Silently moving warn/error to stdout would break `2>err.log`.
347
+ const { live, out, err } = capturing();
348
+ live.start(() => ["FRAME"]);
349
+ out.writes.length = 0;
350
+ err.writes.length = 0;
351
+ console.warn("a warning");
352
+ console.error("an error");
353
+
354
+ expect(err.writes.filter((w) => !w.startsWith("\x1b"))).toEqual([
355
+ "a warning\n",
356
+ "an error\n",
357
+ ]);
358
+ expect(out.writes.some((w) => w.includes("a warning"))).toBe(false);
359
+ });
360
+
361
+ test("format specifiers and multi-line writes survive", () => {
362
+ // A newline inside one write is a row the erase arithmetic cannot see,
363
+ // so it has to be split into separate notes.
364
+ const { live, out } = capturing();
365
+ live.start(() => ["FRAME"]);
366
+ out.writes.length = 0;
367
+ console.log("%s has %d plugins\nsecond line", "magus", 12);
368
+
369
+ expect(out.writes).toContain("magus has 12 plugins\n");
370
+ expect(out.writes).toContain("second line\n");
371
+ });
372
+
373
+ test("console is released on stop", () => {
374
+ const original = console.log;
375
+ const { live } = capturing();
376
+ live.start(() => ["FRAME"]);
377
+ expect(console.log).not.toBe(original);
378
+ live.stop();
379
+ expect(console.log).toBe(original);
380
+ });
381
+
382
+ test("console is released while paused, so a subprocess writes directly", () => {
383
+ const original = console.log;
384
+ const { live } = capturing();
385
+ live.start(() => ["FRAME"]);
386
+ live.pause();
387
+ expect(console.log).toBe(original);
388
+ live.resume();
389
+ expect(console.log).not.toBe(original);
390
+ });
391
+ });
@@ -0,0 +1,240 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import { parseThemeFlag, route } from "../cli/router.js";
5
+
6
+ // Silence the help banner and capture what the router prints, without
7
+ // touching the network (--version) or spawning processes (claude/update).
8
+ let logSpy: ReturnType<typeof mock>;
9
+ let errSpy: ReturnType<typeof mock>;
10
+ let logs: string[];
11
+ let errs: string[];
12
+
13
+ beforeEach(() => {
14
+ logs = [];
15
+ errs = [];
16
+ logSpy = mock((...a: unknown[]) => {
17
+ logs.push(a.join(" "));
18
+ });
19
+ errSpy = mock((...a: unknown[]) => {
20
+ errs.push(a.join(" "));
21
+ });
22
+ console.log = logSpy as any;
23
+ console.error = errSpy as any;
24
+ });
25
+
26
+ afterEach(() => {
27
+ mock.restore();
28
+ });
29
+
30
+ describe("route() dispatch", () => {
31
+ test("bare invocation falls through to the TUI", async () => {
32
+ const r = await route([], "1.2.3");
33
+ expect(r.handled).toBe(false);
34
+ expect(r.launchTui).toBe(true);
35
+ });
36
+
37
+ test("an unrecognized token falls through to the TUI", async () => {
38
+ const r = await route(["totally-unknown"], "1.2.3");
39
+ expect(r.handled).toBe(false);
40
+ expect(r.launchTui).toBe(true);
41
+ });
42
+
43
+ test("--help is handled, exits 0, and does not launch the TUI", async () => {
44
+ const r = await route(["--help"], "9.9.9");
45
+ expect(r).toEqual({ handled: true, exitCode: 0, launchTui: false });
46
+ expect(logs.join("\n")).toContain("magus v9.9.9");
47
+ // The corrected help must not carry the stale tab names.
48
+ expect(logs.join("\n")).not.toContain("[3] Status");
49
+ expect(logs.join("\n")).toContain("[3] MCP");
50
+ });
51
+
52
+ test("-h is treated the same as --help", async () => {
53
+ const r = await route(["-h"], "1.0.0");
54
+ expect(r.handled).toBe(true);
55
+ expect(r.exitCode).toBe(0);
56
+ });
57
+
58
+ test("install, profile, and doctor are routed to real handlers (not stubs)", async () => {
59
+ // The router maps these to their real command handlers, which are covered
60
+ // by their services' unit tests. We don't invoke them here to avoid
61
+ // touching the filesystem / running global installs.
62
+ const mod = await import("../cli/router.js");
63
+ expect(typeof mod.route).toBe("function");
64
+ });
65
+ });
66
+
67
+ describe("update / upgrade split", () => {
68
+ // `update` used to self-update magus. It now means what it means in every
69
+ // package manager — bring THIS PROJECT's dependencies up to date — and
70
+ // self-update moved to `upgrade`. Both halves are asserted here because a
71
+ // silent regression either way sends the user to the wrong command.
72
+
73
+ test("`upgrade` is the self-update command", async () => {
74
+ const mod = await import("../cli/upgrade.js");
75
+ expect(typeof mod.runUpgradeCommand).toBe("function");
76
+ });
77
+
78
+ test("`update` is the dependency command and takes args", async () => {
79
+ const mod = await import("../cli/update.js");
80
+ expect(typeof mod.runUpdateCommand).toBe("function");
81
+ // It takes `args`. The old self-update took none, and `Function.length`
82
+ // stops counting at the first defaulted parameter — so `projectPath`
83
+ // does not show up here, but a regression to the no-arg form would.
84
+ expect(mod.runUpdateCommand.length).toBe(1);
85
+ expect(mod.runUpdateCommand.length).toBeGreaterThan(
86
+ (await import("../cli/upgrade.js")).runUpgradeCommand.length,
87
+ );
88
+ });
89
+
90
+ test("--version points at `magus upgrade`, not `magus update`", async () => {
91
+ await route(["--version"], "1.0.0");
92
+ const out = logs.join("\n");
93
+ // Only assert when an update was actually offered; the check hits the
94
+ // network and may report nothing.
95
+ if (out.includes("Update available")) {
96
+ expect(out).toContain("magus upgrade");
97
+ expect(out).not.toContain("Run: magus update");
98
+ }
99
+ });
100
+
101
+ test("help documents both, and describes update as the dependency command", async () => {
102
+ await route(["--help"], "1.0.0");
103
+ const help = logs.join("\n");
104
+ expect(help).toContain("update [profile]");
105
+ expect(help).toContain("upgrade");
106
+ expect(help).toContain("Update magus ITSELF");
107
+ expect(help).toContain("profile init");
108
+ // The old wording sent people to `update` for a self-update.
109
+ expect(help).not.toContain("update Update magus itself");
110
+ });
111
+ });
112
+
113
+ describe("--theme", () => {
114
+ test("T-flag-ok: --theme light launches the TUI carrying the theme", async () => {
115
+ const r = await route(["--theme", "light"], "1.0.0");
116
+ expect(r).toEqual({ handled: false, launchTui: true, theme: "light" });
117
+ });
118
+
119
+ test("T-flag-ok: --theme=dark is the same flag", async () => {
120
+ const r = await route(["--theme=dark"], "1.0.0");
121
+ expect(r).toEqual({ handled: false, launchTui: true, theme: "dark" });
122
+ });
123
+
124
+ test("no --theme leaves the outcome without a theme key", async () => {
125
+ const r = await route([], "1.0.0");
126
+ expect("theme" in r).toBe(false);
127
+ });
128
+
129
+ test.each([
130
+ [["--theme", "solarized"]],
131
+ [["--theme", "purple"]],
132
+ [["--theme", "Dark"]],
133
+ [["--theme", "auto"]],
134
+ [["--theme"]],
135
+ [["--theme", "--no-refresh"]],
136
+ [["--theme="]],
137
+ ])(
138
+ "T-flag-bad: %j is handled with exit 1 and names both words",
139
+ async (argv) => {
140
+ const r = await route(argv, "1.0.0");
141
+
142
+ expect(r).toEqual({ handled: true, exitCode: 1, launchTui: false });
143
+ const err = errs.join("\n");
144
+ expect(err).toContain("light");
145
+ expect(err).toContain("dark");
146
+ expect(err).toContain("--theme");
147
+ },
148
+ );
149
+
150
+ test("T-flag-cmd: --theme dark update strips the flag and dispatches update", () => {
151
+ // The seam: parseThemeFlag is what route() dispatches on. Asserting on
152
+ // `rest` proves `update` is the command token that reaches the switch,
153
+ // without running the real update handler against this machine.
154
+ const parsed = parseThemeFlag(["--theme", "dark", "update", "--dry-run"]);
155
+
156
+ expect(parsed).toEqual({
157
+ ok: true,
158
+ theme: "dark",
159
+ rest: ["update", "--dry-run"],
160
+ });
161
+ });
162
+
163
+ test("T-flag-cmd-route: route() hands update the stripped argv, not the original", async () => {
164
+ // The parse test above cannot catch a route() that re-reads the original
165
+ // argv inside the switch. Mock the command seam and assert what reached it.
166
+ // mock.module is process-global and survives mock.restore(), so the real
167
+ // module is put back at the end — a later file in the same `bun test`
168
+ // process must not inherit the fake.
169
+ const actual = { ...(await import("../cli/update.js")) };
170
+ const calls: string[][] = [];
171
+ mock.module("../cli/update.js", () => ({
172
+ ...actual,
173
+ runUpdateCommand: async (args: string[]) => {
174
+ calls.push(args);
175
+ return 0;
176
+ },
177
+ }));
178
+ try {
179
+ const { route: routeWithMock } = await import("../cli/router.js");
180
+ const r = await routeWithMock(
181
+ ["--theme", "dark", "update", "--dry-run"],
182
+ "1.0.0",
183
+ );
184
+
185
+ expect(r).toEqual({ handled: true, exitCode: 0, launchTui: false });
186
+ expect(calls).toEqual([["--dry-run"]]);
187
+ } finally {
188
+ mock.module("../cli/update.js", () => actual);
189
+ }
190
+ });
191
+
192
+ test("T-flag-bad: an invalid --theme does not launch the TUI even when a command follows", async () => {
193
+ const r = await route(["--theme", "purple", "update"], "1.0.0");
194
+
195
+ expect(r).toEqual({ handled: true, exitCode: 1, launchTui: false });
196
+ expect(errs.join("\n")).toContain("purple");
197
+ });
198
+
199
+ test("T-flag-cmd: the flag may follow the command too", () => {
200
+ expect(parseThemeFlag(["update", "--theme=light"])).toEqual({
201
+ ok: true,
202
+ theme: "light",
203
+ rest: ["update"],
204
+ });
205
+ });
206
+
207
+ test("T-flag-help: --help wins over a bad --theme, and documents the flag", async () => {
208
+ const r = await route(["--theme", "bogus", "--help"], "1.0.0");
209
+
210
+ expect(r).toEqual({ handled: true, exitCode: 0, launchTui: false });
211
+ const help = logs.join("\n");
212
+ expect(help).toContain("--theme light|dark");
213
+ expect(help).toContain("MAGUS_THEME");
214
+ expect(help).toContain("TERM_THEME");
215
+ expect(errs).toEqual([]);
216
+ });
217
+
218
+ test("T-flag-live: `bun src/main.tsx --theme bogus` exits 1 before the renderer", () => {
219
+ // Exits in the router, so no tty is needed. stdin is a pipe on purpose:
220
+ // were the renderer reached, createCliRenderer would fail differently.
221
+ // --no-env-file: Bun autoloads the cwd's .env before user code, and the
222
+ // theme snapshot must precede any .env merge — same flag as the launchers.
223
+ const result = spawnSync(
224
+ "bun",
225
+ [
226
+ "--no-env-file",
227
+ path.join(import.meta.dir, "..", "main.tsx"),
228
+ "--theme",
229
+ "bogus",
230
+ ],
231
+ { encoding: "utf-8", input: "" },
232
+ );
233
+
234
+ expect(result.status).toBe(1);
235
+ expect(result.stderr).toContain("--theme");
236
+ expect(result.stderr).toContain("light");
237
+ expect(result.stderr).toContain("dark");
238
+ expect(result.stderr).toContain("bogus");
239
+ });
240
+ });