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.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +725 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +524 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +248 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +340 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +345 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +310 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1396 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +403 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +528 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +264 -0
- package/src/cli/update-view.ts +717 -0
- package/src/cli/update.ts +600 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1564 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +657 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +245 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +847 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +377 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +820 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +535 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1423 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +440 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +149 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
package/src/cli/live.ts
ADDED
|
@@ -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
|
+
}
|