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,374 @@
1
+ /**
2
+ * live.ts — an in-place animated block that leaves its record in scrollback.
3
+ *
4
+ * `magus update` spends most of its wall clock inside three calls that print
5
+ * nothing: git pulls over the network, a catalog read, then serial binary
6
+ * probes. Then the apply loop prints one line per plugin, each only once that
7
+ * plugin has finished installing. The terminal sat frozen for the slow parts,
8
+ * which reads as a hang.
9
+ *
10
+ * This is the fix, and it is deliberately NOT a renderer. Two channels:
11
+ *
12
+ * - `note()` writes a PERMANENT line. It is the record, it scrolls, and it is
13
+ * printed in every mode including a pipe. Anything a user might paste into
14
+ * a bug report goes here.
15
+ * - `start(render)` drives a TRANSIENT block under that record, repainted on
16
+ * a timer so a spinner keeps moving while an await is outstanding. It is
17
+ * erased before the next note and again on `stop()`.
18
+ *
19
+ * The split is what lets the same command be both animated and pipeable. In a
20
+ * pipe, in CI, or under NO_COLOR the transient block is simply never painted;
21
+ * the notes still print, in order, exactly as before.
22
+ *
23
+ * ## Why the region hijacks `console`
24
+ *
25
+ * Anything printed while the region is live must go through `note()`. A bare
26
+ * `console.log` lands inside the block, and the next repaint's cursor-up count
27
+ * is then short by however many rows it wrote — so the erase starts partway
28
+ * INTO the block, leaves orphaned rows on screen, and scrolls the message that
29
+ * caused it out of reach.
30
+ *
31
+ * That was documented as a rule and it was not enough. `getAvailablePlugins`
32
+ * reaches `plugin-manager.ts`'s stale-marketplace advisory through two call
33
+ * layers, and it printed with a bare `console.log` from inside the catalog
34
+ * step: the advisory was erased unread and the frame smeared, every time a
35
+ * marketplace clone was stale. A rule that has to hold across a call graph this
36
+ * file does not own is not a rule, it is a hope.
37
+ *
38
+ * So the region CAPTURES `console` for as long as it is painting, and routes
39
+ * every write through the same erase-write-repaint path `note()` uses. The
40
+ * invariant is now a mechanism. Streams are preserved: `log`/`info` keep going
41
+ * to stdout and `warn`/`error` to stderr, because stderr is legitimately
42
+ * redirected on its own and silently moving it to stdout would break that.
43
+ */
44
+
45
+ import { format } from "node:util";
46
+ import { colorEnabled, truncate, width } from "./ansi.js";
47
+
48
+ const HIDE_CURSOR = "\x1b[?25l";
49
+ const SHOW_CURSOR = "\x1b[?25h";
50
+ /** Erase from the cursor to the end of the display. */
51
+ const ERASE_BELOW = "\x1b[0J";
52
+
53
+ /**
54
+ * Cursor visibility and a patched `console` are process-global state, so
55
+ * restoring them is too.
56
+ *
57
+ * A region killed by Ctrl+C or a crash without this leaves the user typing
58
+ * blind in their shell — a genuinely hostile failure they have to fix with
59
+ * `reset`. Registered once, on first use, and never removed.
60
+ */
61
+ let restoreHooked = false;
62
+ const liveRegions = new Set<LiveRegion>();
63
+
64
+ function hookRestore(): void {
65
+ if (restoreHooked) return;
66
+ restoreHooked = true;
67
+ const restore = () => {
68
+ for (const region of liveRegions) region.forceRestore();
69
+ };
70
+ process.on("exit", restore);
71
+ // 128 + signum is the shell convention: SIGINT 130, SIGTERM 143, SIGHUP 129.
72
+ // Reporting 130 for all three told a CI runner "interrupted" when it had
73
+ // actually sent SIGTERM.
74
+ for (const [signal, signum] of [
75
+ ["SIGINT", 2],
76
+ ["SIGTERM", 15],
77
+ ["SIGHUP", 1],
78
+ ] as const) {
79
+ process.on(signal, () => {
80
+ restore();
81
+ process.exit(128 + signum);
82
+ });
83
+ }
84
+ }
85
+
86
+ export interface LiveOptions {
87
+ stream?: NodeJS.WriteStream;
88
+ /** Stderr sink for captured `console.warn`/`console.error`. */
89
+ errorStream?: NodeJS.WriteStream;
90
+ /** Repaints per second. 12 reads as motion without strobing the spinner. */
91
+ fps?: number;
92
+ /** Force the transient block on or off. Default: TTY and colour capable. */
93
+ animate?: boolean;
94
+ /**
95
+ * Capture `console` while painting. On by default — see the file header.
96
+ * Off only for tests that need to assert on the real console.
97
+ */
98
+ captureConsole?: boolean;
99
+ }
100
+
101
+ /** A frame, rebuilt from scratch each repaint. `tick` advances once per frame. */
102
+ export type RenderFrame = (tick: number) => string[];
103
+
104
+ type ConsoleMethod = "log" | "info" | "warn" | "error";
105
+
106
+ export class LiveRegion {
107
+ private readonly stream: NodeJS.WriteStream;
108
+ private readonly errorStream: NodeJS.WriteStream;
109
+ private readonly interval: number;
110
+ private readonly wantsConsoleCapture: boolean;
111
+ /** Rows the transient block currently occupies. 0 means nothing is painted. */
112
+ private painted = 0;
113
+ private timer: ReturnType<typeof setInterval> | null = null;
114
+ private render: RenderFrame | null = null;
115
+ private tick = 0;
116
+ private cursorHidden = false;
117
+ /** True between `pause()` and `resume()`: something else owns the terminal. */
118
+ private paused = false;
119
+ private restoreConsole: (() => void) | null = null;
120
+ private onResize: (() => void) | null = null;
121
+
122
+ /** Whether the transient block is painted at all. */
123
+ readonly animated: boolean;
124
+
125
+ constructor(opts: LiveOptions = {}) {
126
+ this.stream = opts.stream ?? process.stdout;
127
+ this.errorStream = opts.errorStream ?? process.stderr;
128
+ this.interval = Math.max(1, Math.round(1000 / (opts.fps ?? 12)));
129
+ this.wantsConsoleCapture = opts.captureConsole ?? true;
130
+ this.animated =
131
+ opts.animate ?? (this.stream.isTTY === true && colorEnabled());
132
+ liveRegions.add(this);
133
+ }
134
+
135
+ /** Begin animating `render`. Replaces any frame already running. */
136
+ start(render: RenderFrame): void {
137
+ this.render = render;
138
+ if (!this.animated) return;
139
+ this.hideCursor();
140
+ this.captureConsole();
141
+ this.watchResize();
142
+ this.paint();
143
+ this.startTimer();
144
+ }
145
+
146
+ /** Repaint now rather than waiting for the next tick, after a state change. */
147
+ refresh(): void {
148
+ if (this.animated && this.render && !this.paused) this.paint();
149
+ }
150
+
151
+ /**
152
+ * Write permanent lines above the block: the scrollback record.
153
+ *
154
+ * Erase, write, repaint — in that order. Writing first would put the line
155
+ * below the block, where the next repaint's cursor-up arithmetic would
156
+ * count it as part of the frame and start eating the record.
157
+ */
158
+ note(...lines: string[]): void {
159
+ this.writeThrough(this.stream, lines);
160
+ }
161
+
162
+ /** The same, on stderr — for captured `console.warn` / `console.error`. */
163
+ noteError(...lines: string[]): void {
164
+ this.writeThrough(this.errorStream, lines);
165
+ }
166
+
167
+ /**
168
+ * Hand the terminal to something else — a readline prompt, or a subprocess
169
+ * inheriting stdio. Both move the cursor themselves, so the block has to be
170
+ * gone and the cursor visible before either starts.
171
+ *
172
+ * `console` goes back to normal here too: whatever now owns the terminal is
173
+ * writing to it directly, so routing through a region that is not painting
174
+ * would reorder its output.
175
+ */
176
+ pause(): void {
177
+ this.paused = true;
178
+ this.stopTimer();
179
+ this.erase();
180
+ this.releaseConsole();
181
+ this.showCursor();
182
+ }
183
+
184
+ /** Take the terminal back after `pause()`. */
185
+ resume(): void {
186
+ this.paused = false;
187
+ if (!this.animated || !this.render) return;
188
+ this.hideCursor();
189
+ this.captureConsole();
190
+ this.paint();
191
+ this.startTimer();
192
+ }
193
+
194
+ /**
195
+ * Stop animating. `final` is written as a permanent record — the transient
196
+ * frame is erased first, so the last thing on screen is the record and not a
197
+ * spinner frozen mid-rotation.
198
+ */
199
+ stop(final: string[] = []): void {
200
+ this.stopTimer();
201
+ this.erase();
202
+ this.render = null;
203
+ this.paused = false;
204
+ this.releaseConsole();
205
+ this.unwatchResize();
206
+ for (const line of final) this.stream.write(`${line}\n`);
207
+ this.showCursor();
208
+ liveRegions.delete(this);
209
+ }
210
+
211
+ /** Exit hook only: put global state back without touching anything else. */
212
+ forceRestore(): void {
213
+ this.releaseConsole();
214
+ this.showCursor();
215
+ }
216
+
217
+ // -- internals -------------------------------------------------------------
218
+
219
+ private startTimer(): void {
220
+ if (this.timer) return;
221
+ this.timer = setInterval(() => {
222
+ this.tick++;
223
+ this.paint();
224
+ }, this.interval);
225
+ // Never let the repaint timer be the reason the process stays alive.
226
+ this.timer.unref?.();
227
+ }
228
+
229
+ private stopTimer(): void {
230
+ if (this.timer) {
231
+ clearInterval(this.timer);
232
+ this.timer = null;
233
+ }
234
+ }
235
+
236
+ /** Erase, write the lines to `to`, repaint. The one safe write order. */
237
+ private writeThrough(to: NodeJS.WriteStream, lines: string[]): void {
238
+ if (lines.length === 0) return;
239
+ this.erase();
240
+ for (const line of lines) to.write(`${line}\n`);
241
+ // Never repaint while paused: something else owns the terminal, and a
242
+ // frame drawn under its output is both corrupt and in the way.
243
+ if (this.animated && this.render && !this.paused) this.paint();
244
+ }
245
+
246
+ /**
247
+ * Route `console` through this region for as long as it is painting.
248
+ *
249
+ * Multi-line and format-string calls are handled by `util.format`, then
250
+ * split, so `console.log("a\nb")` becomes two permanent rows rather than one
251
+ * row containing a newline the erase arithmetic cannot see.
252
+ */
253
+ private captureConsole(): void {
254
+ if (this.restoreConsole || !this.animated || !this.wantsConsoleCapture)
255
+ return;
256
+ const original: Record<ConsoleMethod, (...args: unknown[]) => void> = {
257
+ log: console.log,
258
+ info: console.info,
259
+ warn: console.warn,
260
+ error: console.error,
261
+ };
262
+ const route =
263
+ (method: ConsoleMethod, toStderr: boolean) =>
264
+ (...args: unknown[]) => {
265
+ if (this.paused || !this.render) {
266
+ original[method](...args);
267
+ return;
268
+ }
269
+ const lines = format(...args).split("\n");
270
+ if (toStderr) this.noteError(...lines);
271
+ else this.note(...lines);
272
+ };
273
+ console.log = route("log", false);
274
+ console.info = route("info", false);
275
+ console.warn = route("warn", true);
276
+ console.error = route("error", true);
277
+ this.restoreConsole = () => {
278
+ console.log = original.log;
279
+ console.info = original.info;
280
+ console.warn = original.warn;
281
+ console.error = original.error;
282
+ this.restoreConsole = null;
283
+ };
284
+ }
285
+
286
+ private releaseConsole(): void {
287
+ this.restoreConsole?.();
288
+ }
289
+
290
+ /**
291
+ * A resize invalidates `painted`.
292
+ *
293
+ * `painted` is a row count measured at the OLD width, so after a narrowing
294
+ * the block occupies more rows than it says and the cursor-up erase lands
295
+ * inside it. There is no way to recover the true count, so the old frame is
296
+ * abandoned where it stands — one stale block left in scrollback, once —
297
+ * rather than mis-erased on every subsequent frame.
298
+ */
299
+ private watchResize(): void {
300
+ if (this.onResize || typeof this.stream.on !== "function") return;
301
+ this.onResize = () => {
302
+ this.painted = 0;
303
+ if (this.animated && this.render && !this.paused) this.paint();
304
+ };
305
+ this.stream.on("resize", this.onResize);
306
+ }
307
+
308
+ private unwatchResize(): void {
309
+ if (!this.onResize) return;
310
+ this.stream.off?.("resize", this.onResize);
311
+ this.onResize = null;
312
+ }
313
+
314
+ private hideCursor(): void {
315
+ if (this.cursorHidden || !this.animated) return;
316
+ this.stream.write(HIDE_CURSOR);
317
+ this.cursorHidden = true;
318
+ // Only now is there global state worth restoring on the way out.
319
+ hookRestore();
320
+ }
321
+
322
+ private showCursor(): void {
323
+ if (!this.cursorHidden) return;
324
+ this.stream.write(SHOW_CURSOR);
325
+ this.cursorHidden = false;
326
+ }
327
+
328
+ private erase(): void {
329
+ if (this.painted === 0) return;
330
+ this.stream.write(`\x1b[${this.painted}A${ERASE_BELOW}`);
331
+ this.painted = 0;
332
+ }
333
+
334
+ /**
335
+ * Repaint the block.
336
+ *
337
+ * Two clamps, both load-bearing. Lines are clipped one column short of the
338
+ * terminal width, because a line that reaches the last column auto-wraps in
339
+ * most terminals and then occupies two rows while `painted` still counts it
340
+ * as one — the block smears a row further down the screen every frame. And
341
+ * the block is capped one row short of the terminal height, because a block
342
+ * taller than the viewport scrolls the terminal and moves the rows the next
343
+ * cursor-up was aiming at.
344
+ */
345
+ private paint(): void {
346
+ if (!this.animated || !this.render) return;
347
+ const cols = Math.max(20, this.stream.columns ?? 80);
348
+ const rows = Math.max(4, this.stream.rows ?? 24);
349
+ let lines = this.render(this.tick).map((l) =>
350
+ width(l) > cols - 1 ? truncate(l, cols - 1) : l,
351
+ );
352
+ if (lines.length > rows - 1) lines = lines.slice(0, rows - 1);
353
+ this.erase();
354
+ if (lines.length === 0) return;
355
+ this.stream.write(`${lines.join("\n")}\n`);
356
+ this.painted = lines.length;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Run `work` with the terminal handed back — for readline and for subprocesses
362
+ * that inherit stdio. Always resumes, including when `work` throws.
363
+ */
364
+ export async function withPaused<T>(
365
+ region: LiveRegion,
366
+ work: () => Promise<T>,
367
+ ): Promise<T> {
368
+ region.pause();
369
+ try {
370
+ return await work();
371
+ } finally {
372
+ region.resume();
373
+ }
374
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * `magus models list | use <preset> | status | off`
3
+ *
4
+ * The user-facing half of per-subagent model routing. Everything it prints is
5
+ * derived from `services/models-manager.ts`; this file owns wording and exit
6
+ * codes only.
7
+ *
8
+ * Exit codes: 1 for an unknown preset or a config that fails validation, 0
9
+ * otherwise. An unknown preset LISTS the valid names rather than near-matching
10
+ * one — `fable-lead` and `fable-advisor` differ by which model leads, and
11
+ * silently picking the closer string would route every subagent differently
12
+ * from what was asked.
13
+ */
14
+
15
+ import {
16
+ BUILT_IN_PRESETS,
17
+ DEFAULT_PRESET,
18
+ presetLabel,
19
+ presetNames,
20
+ } from "../data/models-presets.js";
21
+ import {
22
+ loadMateCatalog,
23
+ stalenessNote,
24
+ unknownBindings,
25
+ } from "../services/mate-catalog.js";
26
+ import {
27
+ GRADES,
28
+ type ModelsConfig,
29
+ type ModelsState,
30
+ boundModel,
31
+ isMate,
32
+ mateEffort,
33
+ matesInUse,
34
+ sameRouting,
35
+ } from "../services/models-core.js";
36
+ import {
37
+ applyModelPreset,
38
+ clearModels,
39
+ configPath,
40
+ readModelsConfig,
41
+ readModelsStatus,
42
+ } from "../services/models-manager.js";
43
+ import { ensureManifest } from "./bootstrap.js";
44
+
45
+ const HEADLINE: Record<ModelsState, string> = {
46
+ off: "off — no .claude/models.json, subagents inherit the session model",
47
+ on: "on",
48
+ invalid: "invalid — nothing is routed",
49
+ unhooked: "unhooked — the config is there, but nothing runs it",
50
+ };
51
+
52
+ /**
53
+ * `opus (medium)`, or `opus` when no effort is set.
54
+ *
55
+ * A mate prints the SLOT and what it is bound to — `mate1 → grok-4.6` — because both are
56
+ * answers to different questions the reader has: which role this tier plays, and which model
57
+ * plays it. The slot alone was all that could be printed before the `mates` block existed,
58
+ * and a bare `kangaroo` in a column of `opus (xhigh)` read as a model id nobody recognises.
59
+ *
60
+ * An effort on a mate is qualified `(declared)`, for the reason spelled out at
61
+ * `DECLARED_SUFFIX`: magus writes it for claudish and nothing applies it yet, so printing
62
+ * a bare `(high)` beside a tier where `(high)` IS in force would claim they do the same
63
+ * thing.
64
+ */
65
+ function describeSpec(
66
+ config: ModelsConfig,
67
+ spec: { model: string; effort?: string },
68
+ ): string {
69
+ if (isMate(spec.model)) {
70
+ const bound = boundModel(config, spec.model);
71
+ const effort = spec.effort ?? mateEffort(config, spec.model);
72
+ const target = bound ?? "unbound";
73
+ const note = effort ? `${effort} declared, via claudish` : "via claudish";
74
+ return `${spec.model} → ${target} (${note})`;
75
+ }
76
+ return spec.effort ? `${spec.model} (${spec.effort})` : spec.model;
77
+ }
78
+
79
+ /**
80
+ * A preset headed by what a person would call it, with the id beside it.
81
+ *
82
+ * Both, deliberately: the label is what you choose between ("Opus with Fable help" says which
83
+ * model leads and which helps), and the id is what you TYPE into `models use` and what lands
84
+ * in the committed config. Printing only the label would leave the command unguessable.
85
+ */
86
+ function printPreset(preset: ModelsConfig, active: boolean): void {
87
+ const mark = active ? "●" : " ";
88
+ const isDefault = preset.preset === DEFAULT_PRESET ? " (default)" : "";
89
+ console.log(
90
+ `${mark} ${presetLabel(preset.preset)} — ${preset.preset}${isDefault}`,
91
+ );
92
+ console.log(` main ${describeSpec(preset, preset.main)}`);
93
+ for (const grade of GRADES) {
94
+ console.log(
95
+ ` ${grade.padEnd(10)} ${describeSpec(preset, preset.grades[grade])}`,
96
+ );
97
+ }
98
+ }
99
+
100
+ async function runList(projectPath: string): Promise<number> {
101
+ const { config } = await readModelsConfig(projectPath);
102
+
103
+ // Has the project diverged from the built-in it NAMES?
104
+ //
105
+ // The test used to be "does it name a preset the built-ins do not", which misses the
106
+ // ordinary case: `magus models use opus-lead` writes that name, and every hand edit
107
+ // afterwards leaves it in place. So a config with a `mates` block was reported as the
108
+ // built-in `opus-lead`, and the `●` sat on a preset that is not what runs. Compared by
109
+ // CONTENT, shared with the TUI through `sameRouting` so the two cannot disagree about
110
+ // which routing is in force.
111
+ const builtInNamed = BUILT_IN_PRESETS.find(
112
+ (preset) => preset.preset === config?.preset,
113
+ );
114
+ const diverged =
115
+ config !== null && (!builtInNamed || !sameRouting(config, builtInNamed));
116
+
117
+ console.log("\nModel tiers:\n");
118
+ for (const preset of BUILT_IN_PRESETS) {
119
+ printPreset(preset, !diverged && config?.preset === preset.preset);
120
+ console.log();
121
+ }
122
+ if (config && diverged) {
123
+ // The project's own config gets its tiers PRINTED, not just its name.
124
+ //
125
+ // It is the routing actually in force, and it is the only config here that can name a
126
+ // mate — no built-in does — so printing the name alone hid the one line a reader came
127
+ // for. The built-ins above are shown in full; showing the active one in less detail
128
+ // than the ones it replaced was backwards.
129
+ printPreset(config, true);
130
+ console.log(" (this project's own, not a built-in)\n");
131
+ }
132
+
133
+ // Which agents leave Claude Code entirely.
134
+ //
135
+ // `models list` prints tiers, never agents, so a mate assigned per-agent — the ordinary
136
+ // way to use one — appears nowhere else in this command's output. It is also the one
137
+ // routing decision here that magus does not carry out itself, which makes it exactly
138
+ // the thing worth naming rather than leaving to be discovered in the file.
139
+ const mated = Object.entries(config?.agents ?? {}).filter(
140
+ ([, assignment]) =>
141
+ typeof assignment !== "string" && isMate(assignment.model),
142
+ );
143
+ if (config && mated.length > 0) {
144
+ console.log("Routed outside Claude Code, through claudish:\n");
145
+ for (const [agent, assignment] of mated) {
146
+ const slot =
147
+ typeof assignment === "string" ? assignment : assignment.model;
148
+ // The SLOT and the model it names. The slot is what the file says and what the
149
+ // reader greps for; the bound id is what actually runs. Printing only the slot was
150
+ // the CLI's version of the screen's `mate1 mate1 —` — a role where a model belongs.
151
+ const bound = isMate(slot) ? boundModel(config, slot) : null;
152
+ const target = bound ?? "unbound — add it to the mates block";
153
+ console.log(` ${agent.padEnd(28)} ${slot.padEnd(10)} ${target}`);
154
+ }
155
+ console.log();
156
+
157
+ // The staleness advisory, on the same terms as everywhere else: ADVISORY, and silent
158
+ // when the catalogue could not be read. `unknownBindings` returns nothing for an empty
159
+ // catalogue, so a machine without claudish prints no note rather than flagging every
160
+ // binding it cannot check.
161
+ const bound = matesInUse(config)
162
+ .map((mate) => boundModel(config, mate))
163
+ .filter((id): id is string => id !== null);
164
+ const note = stalenessNote(unknownBindings(bound, await loadMateCatalog()));
165
+ if (note) console.log(`⚠ ${note}\n`);
166
+ }
167
+
168
+ console.log(
169
+ "Tiers name what a subagent is FOR: `smart` takes the judgement calls, `cheap`",
170
+ );
171
+ console.log(
172
+ "the searching. Which model serves each is the preset's call. Apply one with\n`magus models use <name>`.\n",
173
+ );
174
+ return 0;
175
+ }
176
+
177
+ async function runStatus(projectPath: string): Promise<number> {
178
+ const status = await readModelsStatus(projectPath);
179
+ console.log(`\nModel tiers: ${HEADLINE[status.state]}`);
180
+ if (status.preset)
181
+ console.log(
182
+ `Preset: ${presetLabel(status.preset)} (${status.preset})`,
183
+ );
184
+ console.log(`Config: ${configPath(projectPath)}`);
185
+
186
+ if (status.drift.length > 0) {
187
+ console.log(status.state === "invalid" ? "\nErrors:" : "\nDrift:");
188
+ for (const line of status.drift) console.log(` ✗ ${line}`);
189
+ if (status.state === "unhooked") console.log("\n Fix: magus doctor --fix");
190
+ }
191
+ for (const warning of status.warnings) console.log(`\n⚠ ${warning}`);
192
+ console.log();
193
+ return status.state === "invalid" ? 1 : 0;
194
+ }
195
+
196
+ async function runUse(projectPath: string, name?: string): Promise<number> {
197
+ if (!name) {
198
+ console.error("Usage: magus models use <preset>");
199
+ console.error(`Available: ${presetNames().join(", ")}`);
200
+ return 1;
201
+ }
202
+ if (!presetNames().includes(name)) {
203
+ // Never near-match. See the file header.
204
+ console.error(`Unknown preset "${name}".`);
205
+ console.error(`Available: ${presetNames().join(", ")}`);
206
+ return 1;
207
+ }
208
+
209
+ let result: Awaited<ReturnType<typeof applyModelPreset>>;
210
+ try {
211
+ result = await applyModelPreset({
212
+ projectPath,
213
+ preset: name,
214
+ ensureManifest: (p) => ensureManifest(p),
215
+ });
216
+ } catch (error) {
217
+ console.error(error instanceof Error ? error.message : String(error));
218
+ return 1;
219
+ }
220
+
221
+ console.log(`\n✓ Applied preset "${name}".`);
222
+ console.log(` ${configPath(projectPath)}`);
223
+ console.log(` ${result.settingsPath}`);
224
+ for (const [key, value] of Object.entries(result.wrote)) {
225
+ console.log(` ${key} = ${JSON.stringify(value)}`);
226
+ }
227
+ console.log(
228
+ ` hook: ${result.hook === "registered" ? "registered at user scope" : "already registered"}`,
229
+ );
230
+
231
+ if (result.recordedInManifest && result.profile) {
232
+ console.log(
233
+ `\nRecorded in .claude/profiles.json under profiles.${result.profile}.`,
234
+ );
235
+ console.log("Commit .claude/profiles.json to share this with the team.");
236
+ } else {
237
+ console.log(
238
+ "\nNo active profile — written straight to .claude/models.json, which is",
239
+ );
240
+ console.log(
241
+ "generated and gitignored. Run `magus install` to adopt a profile if the",
242
+ );
243
+ console.log("team should share this routing.");
244
+ }
245
+ console.log();
246
+ return 0;
247
+ }
248
+
249
+ async function runOff(projectPath: string): Promise<number> {
250
+ await clearModels(projectPath);
251
+ console.log(
252
+ "\n✓ Model tiers off. Subagents inherit the session model again.",
253
+ );
254
+ console.log(
255
+ " The hook stays registered — it is a no-op without a models.json.\n",
256
+ );
257
+ return 0;
258
+ }
259
+
260
+ function printUsage(): void {
261
+ console.log(`Usage: magus models <command>
262
+
263
+ list Show the built-in tier presets; ● marks the active one
264
+ use <preset> Apply a preset to this project and register the hook
265
+ status What is routed right now, and any drift
266
+ off Turn model tiers off (removes the config, keeps the hook)
267
+
268
+ Available presets: ${presetNames().join(", ")}`);
269
+ }
270
+
271
+ /**
272
+ * `projectPath` defaults to the cwd, which is what the router passes. It is a
273
+ * parameter so tests can drive a temp project without moving the process —
274
+ * `cli/profile.ts` takes the same shape for the same reason.
275
+ */
276
+ export async function runModelsCommand(
277
+ args: string[],
278
+ projectPath: string = process.cwd(),
279
+ ): Promise<number> {
280
+ const [command, ...rest] = args;
281
+
282
+ switch (command) {
283
+ case undefined:
284
+ case "status":
285
+ return runStatus(projectPath);
286
+ case "list":
287
+ return runList(projectPath);
288
+ case "use":
289
+ return runUse(projectPath, rest[0]);
290
+ case "off":
291
+ return runOff(projectPath);
292
+ default:
293
+ console.error(`Unknown models command "${command}".`);
294
+ printUsage();
295
+ return 1;
296
+ }
297
+ }