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.
- 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 +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -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 +445 -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 +174 -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 +335 -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__/moved-marketplace.test.ts +86 -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 +336 -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 +281 -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 +1373 -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 +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -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 +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -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 +1589 -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 +587 -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 +253 -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 +878 -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 +344 -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 +863 -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 +538 -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 +1464 -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 +352 -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 +144 -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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* models-manager — the filesystem half of per-subagent model routing.
|
|
3
|
+
*
|
|
4
|
+
* Every test works in a temp project AND a temp Claude config dir. The config
|
|
5
|
+
* dir matters: `applyModelPreset` registers the hook at user scope, so a test
|
|
6
|
+
* that did not isolate `CLAUDE_CONFIG_DIR` would write into the operator's real
|
|
7
|
+
* `~/.claude/settings.json`. `hook-registration.ts` refuses to resolve a path
|
|
8
|
+
* under `bun test` without it, so forgetting is loud rather than damaging.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import fs from "fs-extra";
|
|
15
|
+
import { findPreset } from "../data/models-presets.js";
|
|
16
|
+
import { getManifestPath, writeManifest } from "../services/manifest.js";
|
|
17
|
+
import type { ModelsConfig } from "../services/models-core.js";
|
|
18
|
+
import {
|
|
19
|
+
applyModelPreset,
|
|
20
|
+
clearModels,
|
|
21
|
+
configPath,
|
|
22
|
+
readModelsConfig,
|
|
23
|
+
readModelsStatus,
|
|
24
|
+
resolveFullModelId,
|
|
25
|
+
} from "../services/models-manager.js";
|
|
26
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
27
|
+
|
|
28
|
+
let project: string;
|
|
29
|
+
let configDir: string;
|
|
30
|
+
let previousConfigDir: string | undefined;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
project = await mkdtemp(join(tmpdir(), "models-project-"));
|
|
34
|
+
configDir = await mkdtemp(join(tmpdir(), "models-config-"));
|
|
35
|
+
previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
36
|
+
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
// biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
|
|
41
|
+
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
42
|
+
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
|
43
|
+
await rm(project, { recursive: true, force: true });
|
|
44
|
+
await rm(configDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const PRESET = "fable-advisor";
|
|
48
|
+
|
|
49
|
+
function manifestWith(profile: string, extra = {}): ProfileManifest {
|
|
50
|
+
return {
|
|
51
|
+
version: 2,
|
|
52
|
+
profiles: { [profile]: { name: profile, ...extra } },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stage a project that already has a profile ACTIVE, the way `magus install`
|
|
58
|
+
* leaves one: a materialized dir and `.claude/settings.json` symlinked into it.
|
|
59
|
+
* Built by hand rather than by running install, which needs the claude CLI.
|
|
60
|
+
*/
|
|
61
|
+
async function stageActiveProfile(
|
|
62
|
+
name: string,
|
|
63
|
+
entry: Record<string, unknown> = {},
|
|
64
|
+
settings: Record<string, unknown> = {},
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
await writeManifest(manifestWith(name, entry), project);
|
|
67
|
+
const dir = join(project, ".claude", "_profiles", name);
|
|
68
|
+
await fs.outputJson(join(dir, "settings.json"), settings, { spaces: 2 });
|
|
69
|
+
await fs.ensureDir(join(project, ".claude"));
|
|
70
|
+
await fs.symlink(
|
|
71
|
+
join("_profiles", name, "settings.json"),
|
|
72
|
+
join(project, ".claude", "settings.json"),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readSettings(): Promise<Record<string, unknown>> {
|
|
77
|
+
return fs.readJson(join(project, ".claude", "settings.json"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("applyModelPreset", () => {
|
|
81
|
+
test("writes the config, the settings, and the manifest entry", async () => {
|
|
82
|
+
await stageActiveProfile("team");
|
|
83
|
+
|
|
84
|
+
const result = await applyModelPreset({
|
|
85
|
+
projectPath: project,
|
|
86
|
+
preset: PRESET,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.profile).toBe("team");
|
|
90
|
+
expect(result.recordedInManifest).toBe(true);
|
|
91
|
+
|
|
92
|
+
// The generated config the hook reads.
|
|
93
|
+
const written = await fs.readJson(configPath(project));
|
|
94
|
+
expect(written.preset).toBe(PRESET);
|
|
95
|
+
expect(written.grades.smart.model).toBe("fable");
|
|
96
|
+
|
|
97
|
+
// The settings the main thread reads.
|
|
98
|
+
const settings = await readSettings();
|
|
99
|
+
expect(settings.model).toBe("opus");
|
|
100
|
+
expect(settings.effortLevel).toBe("xhigh");
|
|
101
|
+
expect((settings.modelSettings as Record<string, unknown>).fable).toEqual({
|
|
102
|
+
effortLevel: "medium",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The committed record — without this, the next `magus install`
|
|
106
|
+
// rewrites settings.json from the manifest and reverts all of the above.
|
|
107
|
+
const manifest = await fs.readJson(getManifestPath(project));
|
|
108
|
+
expect(manifest.profiles.team.models.preset).toBe(PRESET);
|
|
109
|
+
expect(manifest.profiles.team.settings.model).toBe("opus");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("registers the agent-model hook at user scope", async () => {
|
|
113
|
+
await stageActiveProfile("team");
|
|
114
|
+
const result = await applyModelPreset({
|
|
115
|
+
projectPath: project,
|
|
116
|
+
preset: PRESET,
|
|
117
|
+
});
|
|
118
|
+
expect(result.hook).toBe("registered");
|
|
119
|
+
|
|
120
|
+
const userSettings = await fs.readJson(join(configDir, "settings.json"));
|
|
121
|
+
expect(userSettings.hooks.PreToolUse[0].matcher).toBe("Agent");
|
|
122
|
+
expect(userSettings.hooks.PreToolUse[0].hooks[0].command).toBe(
|
|
123
|
+
"magus hook agent-model",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Second apply finds it already there.
|
|
127
|
+
const again = await applyModelPreset({
|
|
128
|
+
projectPath: project,
|
|
129
|
+
preset: PRESET,
|
|
130
|
+
});
|
|
131
|
+
expect(again.hook).toBe("already");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// The symlink is how the active profile is wired. `updateSettingsFile` writes
|
|
135
|
+
// through it on purpose; a remove-then-create would silently detach the
|
|
136
|
+
// project from its profile and leave the next read looking at a fresh file.
|
|
137
|
+
test("keeps the profile symlink and updates profiles.json under it", async () => {
|
|
138
|
+
await stageActiveProfile("team", { plugins: { "dev@magus": "latest" } });
|
|
139
|
+
|
|
140
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
141
|
+
|
|
142
|
+
const link = join(project, ".claude", "settings.json");
|
|
143
|
+
expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
|
|
144
|
+
expect(await fs.readlink(link)).toBe(
|
|
145
|
+
join("_profiles", "team", "settings.json"),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// The write landed in the profile's copy, and re-materializing did not
|
|
149
|
+
// cost the profile the plugins its manifest entry declares.
|
|
150
|
+
const inProfile = await fs.readJson(
|
|
151
|
+
join(project, ".claude", "_profiles", "team", "settings.json"),
|
|
152
|
+
);
|
|
153
|
+
expect(inProfile.model).toBe("opus");
|
|
154
|
+
expect(inProfile.enabledPlugins["dev@magus"]).toBe(true);
|
|
155
|
+
|
|
156
|
+
// And .claude/models.json is a link into the same profile dir, not a copy.
|
|
157
|
+
const modelsLink = join(project, ".claude", "models.json");
|
|
158
|
+
expect((await fs.lstat(modelsLink)).isSymbolicLink()).toBe(true);
|
|
159
|
+
expect(await fs.readlink(modelsLink)).toBe(
|
|
160
|
+
join("_profiles", "team", "models.json"),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("without a profile it writes a plain models.json and records nothing", async () => {
|
|
165
|
+
const result = await applyModelPreset({
|
|
166
|
+
projectPath: project,
|
|
167
|
+
preset: PRESET,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.profile).toBeNull();
|
|
171
|
+
expect(result.recordedInManifest).toBe(false);
|
|
172
|
+
expect((await fs.lstat(configPath(project))).isSymbolicLink()).toBe(false);
|
|
173
|
+
expect((await fs.readJson(configPath(project))).preset).toBe(PRESET);
|
|
174
|
+
expect((await readSettings()).model).toBe("opus");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// updateSettingsFile refuses a file it cannot parse — a human is probably
|
|
178
|
+
// mid-edit, and writing our keys back over `{}` is how a whole config
|
|
179
|
+
// disappears.
|
|
180
|
+
test("refuses a settings.json it cannot parse, and changes nothing", async () => {
|
|
181
|
+
const settingsPath = join(project, ".claude", "settings.json");
|
|
182
|
+
await fs.outputFile(settingsPath, "{ not json");
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
applyModelPreset({ projectPath: project, preset: PRESET }),
|
|
186
|
+
).rejects.toThrow(/not valid JSON/);
|
|
187
|
+
|
|
188
|
+
expect(await fs.readFile(settingsPath, "utf8")).toBe("{ not json");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("refuses an invalid config and names every error", async () => {
|
|
192
|
+
const bad = {
|
|
193
|
+
...(findPreset(PRESET) as ModelsConfig),
|
|
194
|
+
grades: {
|
|
195
|
+
smart: { model: "gpt-5" },
|
|
196
|
+
normal: { model: "opus" },
|
|
197
|
+
cheap: { model: "sonnet" },
|
|
198
|
+
},
|
|
199
|
+
} as unknown as ModelsConfig;
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
applyModelPreset({ projectPath: project, preset: bad }),
|
|
203
|
+
).rejects.toThrow(/grades.smart.model/);
|
|
204
|
+
expect(await fs.pathExists(configPath(project))).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("an unknown preset name lists the valid ones", async () => {
|
|
208
|
+
await expect(
|
|
209
|
+
applyModelPreset({ projectPath: project, preset: "fable-leed" }),
|
|
210
|
+
).rejects.toThrow(/Available: .*fable-lead/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("clearModels", () => {
|
|
215
|
+
test("removes the config, the link, and the manifest entry", async () => {
|
|
216
|
+
await stageActiveProfile("team");
|
|
217
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
218
|
+
|
|
219
|
+
await clearModels(project);
|
|
220
|
+
|
|
221
|
+
expect(await fs.pathExists(configPath(project))).toBe(false);
|
|
222
|
+
// lstat, not pathExists: a DANGLING symlink is the failure this guards.
|
|
223
|
+
await expect(fs.lstat(configPath(project))).rejects.toThrow();
|
|
224
|
+
expect(
|
|
225
|
+
await fs.pathExists(
|
|
226
|
+
join(project, ".claude", "_profiles", "team", "models.json"),
|
|
227
|
+
),
|
|
228
|
+
).toBe(false);
|
|
229
|
+
|
|
230
|
+
const manifest = await fs.readJson(getManifestPath(project));
|
|
231
|
+
expect(manifest.profiles.team.models).toBeUndefined();
|
|
232
|
+
expect(manifest.profiles.team.settings.model).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// The user's own `/effort` entries live in the same modelSettings object.
|
|
236
|
+
// Deleting the object would take their settings with ours.
|
|
237
|
+
test("leaves unrelated modelSettings keys and unrelated settings intact", async () => {
|
|
238
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
239
|
+
|
|
240
|
+
const settingsPath = join(project, ".claude", "settings.json");
|
|
241
|
+
const before = await fs.readJson(settingsPath);
|
|
242
|
+
await fs.writeJson(
|
|
243
|
+
settingsPath,
|
|
244
|
+
{
|
|
245
|
+
...before,
|
|
246
|
+
outputStyle: "composed-team",
|
|
247
|
+
modelSettings: {
|
|
248
|
+
...(before.modelSettings as Record<string, unknown>),
|
|
249
|
+
"claude-something-else": { effortLevel: "low" },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{ spaces: 2 },
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
await clearModels(project);
|
|
256
|
+
|
|
257
|
+
const after = await fs.readJson(settingsPath);
|
|
258
|
+
expect(after.model).toBeUndefined();
|
|
259
|
+
expect(after.effortLevel).toBeUndefined();
|
|
260
|
+
expect(after.outputStyle).toBe("composed-team");
|
|
261
|
+
expect(after.modelSettings).toEqual({
|
|
262
|
+
"claude-something-else": { effortLevel: "low" },
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("removes modelSettings entirely once nothing is left in it", async () => {
|
|
267
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
268
|
+
await clearModels(project);
|
|
269
|
+
expect(
|
|
270
|
+
(await fs.readJson(join(project, ".claude", "settings.json")))
|
|
271
|
+
.modelSettings,
|
|
272
|
+
).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("is a no-op on a project that never had routing", async () => {
|
|
276
|
+
await clearModels(project);
|
|
277
|
+
expect(await fs.pathExists(configPath(project))).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("readModelsConfig", () => {
|
|
282
|
+
test("a missing file is off, not an error", async () => {
|
|
283
|
+
const result = await readModelsConfig(project);
|
|
284
|
+
expect(result.config).toBeNull();
|
|
285
|
+
expect(result.errors).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("an invalid file reports errors and yields no config", async () => {
|
|
289
|
+
await fs.outputJson(configPath(project), { version: 1, preset: "x" });
|
|
290
|
+
const result = await readModelsConfig(project);
|
|
291
|
+
expect(result.config).toBeNull();
|
|
292
|
+
expect(result.errors.map((e) => e.path)).toContain("main");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("unparseable JSON is an error, not an empty config", async () => {
|
|
296
|
+
await fs.outputFile(configPath(project), "{ nope");
|
|
297
|
+
const result = await readModelsConfig(project);
|
|
298
|
+
expect(result.config).toBeNull();
|
|
299
|
+
expect(result.errors[0]?.message).toMatch(/not valid JSON/);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("readModelsStatus", () => {
|
|
304
|
+
test("reports applied once the preset is written and the hook is on", async () => {
|
|
305
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
306
|
+
const status = await readModelsStatus(project);
|
|
307
|
+
expect(status.state).toBe("on");
|
|
308
|
+
expect(status.preset).toBe(PRESET);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("settings differing from the config stays `on` — that is a custom config", () => {
|
|
312
|
+
// Nothing to report. A project running a model no preset names has made a choice, and
|
|
313
|
+
// the list shows it as a `Custom` row rather than the status calling it a problem.
|
|
314
|
+
return (async () => {
|
|
315
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
316
|
+
const settingsPath = join(project, ".claude", "settings.json");
|
|
317
|
+
await fs.writeJson(settingsPath, {
|
|
318
|
+
...(await fs.readJson(settingsPath)),
|
|
319
|
+
model: "haiku",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const status = await readModelsStatus(project);
|
|
323
|
+
expect(status.state).toBe("on");
|
|
324
|
+
expect(status.drift).toEqual([]);
|
|
325
|
+
})();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// A config with no hook routes NOTHING, which is a fault and keeps its drift line.
|
|
329
|
+
test("reports unhooked when the hook is not registered", async () => {
|
|
330
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
331
|
+
await fs.writeJson(join(configDir, "settings.json"), {});
|
|
332
|
+
|
|
333
|
+
const status = await readModelsStatus(project);
|
|
334
|
+
expect(status.state).toBe("unhooked");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("warns that CLAUDE_CODE_SUBAGENT_MODEL is now inert", async () => {
|
|
338
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
339
|
+
const previous = process.env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
340
|
+
process.env.CLAUDE_CODE_SUBAGENT_MODEL = "haiku";
|
|
341
|
+
try {
|
|
342
|
+
const status = await readModelsStatus(project);
|
|
343
|
+
expect(status.warnings.join(" ")).toContain("CLAUDE_CODE_SUBAGENT_MODEL");
|
|
344
|
+
} finally {
|
|
345
|
+
// biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
|
|
346
|
+
if (previous === undefined) delete process.env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
347
|
+
else process.env.CLAUDE_CODE_SUBAGENT_MODEL = previous;
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("resolveFullModelId", () => {
|
|
353
|
+
/** Write a transcript line the way Claude Code records one. */
|
|
354
|
+
async function transcript(
|
|
355
|
+
home: string,
|
|
356
|
+
name: string,
|
|
357
|
+
models: string[],
|
|
358
|
+
): Promise<void> {
|
|
359
|
+
await fs.outputFile(
|
|
360
|
+
join(home, ".claude", "projects", "-tmp-x", `${name}.jsonl`),
|
|
361
|
+
`${models
|
|
362
|
+
.map((m) => JSON.stringify({ message: { model: m } }))
|
|
363
|
+
.join("\n")}\n`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
test("returns null when the home has no transcripts at all", async () => {
|
|
368
|
+
const home = await mkdtemp(join(tmpdir(), "models-home-"));
|
|
369
|
+
try {
|
|
370
|
+
expect(resolveFullModelId("opus", { home })).toBeNull();
|
|
371
|
+
} finally {
|
|
372
|
+
await rm(home, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("returns null when nothing in the transcripts names that alias", async () => {
|
|
377
|
+
const home = await mkdtemp(join(tmpdir(), "models-home-"));
|
|
378
|
+
try {
|
|
379
|
+
await transcript(home, "a", ["claude-sonnet-9-20260101"]);
|
|
380
|
+
expect(resolveFullModelId("opus", { home })).toBeNull();
|
|
381
|
+
} finally {
|
|
382
|
+
await rm(home, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// The plainest spelling wins: transcripts record `claude-opus-5` while usage
|
|
387
|
+
// envelopes record `claude-opus-5[1m]`, and a context-window suffix is a
|
|
388
|
+
// DIFFERENT modelSettings key — writing it would set effort on a model the
|
|
389
|
+
// session is not running.
|
|
390
|
+
test("finds the id and prefers the spelling with no bracketed suffix", async () => {
|
|
391
|
+
const home = await mkdtemp(join(tmpdir(), "models-home-"));
|
|
392
|
+
try {
|
|
393
|
+
await transcript(home, "a", [
|
|
394
|
+
"claude-opus-7-20260401[1m]",
|
|
395
|
+
"claude-opus-7-20260401[1m]",
|
|
396
|
+
"claude-opus-7-20260401[1m]",
|
|
397
|
+
"claude-opus-7-20260401",
|
|
398
|
+
]);
|
|
399
|
+
expect(resolveFullModelId("opus", { home })).toBe(
|
|
400
|
+
"claude-opus-7-20260401",
|
|
401
|
+
);
|
|
402
|
+
} finally {
|
|
403
|
+
await rm(home, { recursive: true, force: true });
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("takes the most frequent spelling, not the first seen", async () => {
|
|
408
|
+
const home = await mkdtemp(join(tmpdir(), "models-home-"));
|
|
409
|
+
try {
|
|
410
|
+
await transcript(home, "a", [
|
|
411
|
+
"claude-haiku-4-5-20251001",
|
|
412
|
+
"claude-haiku-9-20260701",
|
|
413
|
+
"claude-haiku-9-20260701",
|
|
414
|
+
]);
|
|
415
|
+
expect(resolveFullModelId("haiku", { home })).toBe(
|
|
416
|
+
"claude-haiku-9-20260701",
|
|
417
|
+
);
|
|
418
|
+
} finally {
|
|
419
|
+
await rm(home, { recursive: true, force: true });
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Write a transcript of roughly `mb` megabytes that names `model` ONCE, at the very
|
|
425
|
+
* start, followed by padding. Shaped to defeat a tail-only reader on purpose.
|
|
426
|
+
*/
|
|
427
|
+
async function bigTranscript(
|
|
428
|
+
home: string,
|
|
429
|
+
name: string,
|
|
430
|
+
model: string | null,
|
|
431
|
+
mb: number,
|
|
432
|
+
): Promise<void> {
|
|
433
|
+
const head = model
|
|
434
|
+
? `${JSON.stringify({ message: { model } })}\n`
|
|
435
|
+
: `${JSON.stringify({ message: { note: "no model here" } })}\n`;
|
|
436
|
+
// Padding carries no `"model"` key, so a reader that only sees it finds nothing.
|
|
437
|
+
const line = `${JSON.stringify({ message: { text: "x".repeat(900) } })}\n`;
|
|
438
|
+
const pad = line.repeat(Math.ceil((mb * 1024 * 1024) / line.length));
|
|
439
|
+
await fs.outputFile(
|
|
440
|
+
join(home, ".claude", "projects", "-tmp-big", `${name}.jsonl`),
|
|
441
|
+
head + pad,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* MEASURED, and the reason the window is a byte budget rather than a file count: this
|
|
447
|
+
* machine holds 2995 transcripts, and the newest SIXTY spanned fifty minutes, because
|
|
448
|
+
* every live session appends to its own file. `claude-sonnet-5` sat at rank 85 and so
|
|
449
|
+
* could not resolve at all — while `claude-opus-5`, running right then, resolved fine.
|
|
450
|
+
* An alias is not gone because it is an hour old.
|
|
451
|
+
*/
|
|
452
|
+
test("resolves an alias whose newest mention is far down the ranking", async () => {
|
|
453
|
+
const home = await mkdtemp(join(tmpdir(), "models-home-"));
|
|
454
|
+
try {
|
|
455
|
+
// 120 newer transcripts that never name sonnet, then the one that does.
|
|
456
|
+
for (let i = 0; i < 120; i += 1) {
|
|
457
|
+
await transcript(home, `newer-${String(i).padStart(3, "0")}`, [
|
|
458
|
+
"claude-opus-5",
|
|
459
|
+
]);
|
|
460
|
+
}
|
|
461
|
+
await transcript(home, "older", ["claude-sonnet-5"]);
|
|
462
|
+
// Make the sonnet one the OLDEST, so only a wide window reaches it.
|
|
463
|
+
const file = join(home, ".claude", "projects", "-tmp-x", "older.jsonl");
|
|
464
|
+
const old = new Date(Date.now() - 86_400_000);
|
|
465
|
+
await fs.utimes(file, old, old);
|
|
466
|
+
|
|
467
|
+
expect(resolveFullModelId("sonnet", { home })).toBe("claude-sonnet-5");
|
|
468
|
+
} finally {
|
|
469
|
+
await rm(home, { recursive: true, force: true });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The other half of the same defect. Reading whole files here was a memory bomb: the
|
|
475
|
+
* largest single transcript on this machine is 597 MB, and `readFileSync(file, "utf8")`
|
|
476
|
+
* on the newest sixty took 6.5 s per `models use`. Capping only the TOTAL was not enough
|
|
477
|
+
* either — one huge decoy consumed the whole budget before the scan reached the file
|
|
478
|
+
* that had the answer, so the cap is per file as well.
|
|
479
|
+
*/
|
|
480
|
+
test("a huge transcript with no match does not hide a later one", async () => {
|
|
481
|
+
const home = await mkdtemp(join(tmpdir(), "models-home-"));
|
|
482
|
+
try {
|
|
483
|
+
await bigTranscript(home, "decoy", null, 12);
|
|
484
|
+
await transcript(home, "real", ["claude-fable-5-1"]);
|
|
485
|
+
const older = join(home, ".claude", "projects", "-tmp-x", "real.jsonl");
|
|
486
|
+
const old = new Date(Date.now() - 86_400_000);
|
|
487
|
+
await fs.utimes(older, old, old);
|
|
488
|
+
|
|
489
|
+
const started = performance.now();
|
|
490
|
+
const id = resolveFullModelId("fable", { home });
|
|
491
|
+
const elapsed = performance.now() - started;
|
|
492
|
+
|
|
493
|
+
expect(id).toBe("claude-fable-5-1");
|
|
494
|
+
// Generous: the point is that it does not read 12 MB looking for nothing.
|
|
495
|
+
expect(elapsed).toBeLessThan(2000);
|
|
496
|
+
} finally {
|
|
497
|
+
await rm(home, { recursive: true, force: true });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent tier table and the workflow list are keyed by agent id, and nothing else
|
|
3
|
+
* checks those ids.
|
|
4
|
+
*
|
|
5
|
+
* `DEFAULT_AGENT_GRADES` in `data/models-presets.ts` names
|
|
6
|
+
* agents as `plugin:name`. A rename in the marketplace (`code-search:analyze` →
|
|
7
|
+
* something else, `dev:synthesizer` → `dev:aggregator`) breaks the key with no signal: the
|
|
8
|
+
* renamed agent silently falls to the preset's fallback, and the stale key routes nothing.
|
|
9
|
+
* This test reads the marketplace source tree beside this repo and fails on either drift.
|
|
10
|
+
*
|
|
11
|
+
* Two directions:
|
|
12
|
+
* (a) every preset key names an agent that exists (or is a Claude Code built-in);
|
|
13
|
+
* (b) every marketplace agent is classified — in the tier table, in the frontmatter map,
|
|
14
|
+
* a vision agent, or explicitly left to the fallback in FALLBACK_BY_DESIGN below.
|
|
15
|
+
* Absence is a legitimate routing choice (fallback = `normal`), but it must be a
|
|
16
|
+
* choice: a new agent fails here until someone decides.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from "bun:test";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_AGENT_GRADES,
|
|
23
|
+
VISION_AGENTS,
|
|
24
|
+
WORKFLOWS,
|
|
25
|
+
} from "../data/models-presets.js";
|
|
26
|
+
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
27
|
+
|
|
28
|
+
const REPO = resolve(import.meta.dir, "..", "..", "..", "..");
|
|
29
|
+
const MARKETPLACE = join(REPO, ".claude-plugin", "marketplace.json");
|
|
30
|
+
const inMagusSrc = existsSync(MARKETPLACE) && existsSync(join(REPO, "plugins"));
|
|
31
|
+
|
|
32
|
+
/** Claude Code's own agents: not in any marketplace, addressed by bare name. */
|
|
33
|
+
const BUILT_IN = new Set([
|
|
34
|
+
"Explore",
|
|
35
|
+
"Plan",
|
|
36
|
+
"general-purpose",
|
|
37
|
+
"statusline-setup",
|
|
38
|
+
"claude-code-guide",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Marketplace agents deliberately left to the preset's `fallback` grade. Each entry is a
|
|
43
|
+
* decision, not an omission: remove the line when the agent gets a tier. A NEW agent is not
|
|
44
|
+
* on this list, so adding one to the marketplace fails (b) until it is placed somewhere.
|
|
45
|
+
*/
|
|
46
|
+
const FALLBACK_BY_DESIGN = new Set([
|
|
47
|
+
"dev:developer",
|
|
48
|
+
"dev:devops",
|
|
49
|
+
"dev:frontend-developer",
|
|
50
|
+
"dev:researcher",
|
|
51
|
+
"dev:qa-engineer",
|
|
52
|
+
"madbench:bench",
|
|
53
|
+
"video-editing:timeline-builder",
|
|
54
|
+
"video-editing:transcriber",
|
|
55
|
+
"video-editing:video-processor",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
interface MarketplaceAgent {
|
|
59
|
+
id: string;
|
|
60
|
+
file: string;
|
|
61
|
+
frontmatterModel: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every `plugins/<p>/agents/*.md` of every plugin the marketplace lists, by frontmatter name. */
|
|
65
|
+
function marketplaceAgents(): MarketplaceAgent[] {
|
|
66
|
+
const manifest = JSON.parse(readFileSync(MARKETPLACE, "utf8")) as {
|
|
67
|
+
plugins: { name: string; source: string }[];
|
|
68
|
+
};
|
|
69
|
+
const out: MarketplaceAgent[] = [];
|
|
70
|
+
for (const plugin of manifest.plugins) {
|
|
71
|
+
const agentsDir = join(REPO, plugin.source, "agents");
|
|
72
|
+
if (!existsSync(agentsDir)) continue;
|
|
73
|
+
for (const entry of readdirSync(agentsDir)) {
|
|
74
|
+
if (!entry.endsWith(".md")) continue;
|
|
75
|
+
const file = join(agentsDir, entry);
|
|
76
|
+
const text = readFileSync(file, "utf8");
|
|
77
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
78
|
+
if (!fm) continue;
|
|
79
|
+
const name = fm[1].match(/^name:\s*(.+?)\s*$/m)?.[1];
|
|
80
|
+
if (!name) continue;
|
|
81
|
+
const model = fm[1].match(/^model:\s*(.+?)\s*$/m)?.[1];
|
|
82
|
+
out.push({
|
|
83
|
+
id: `${plugin.name}:${name}`,
|
|
84
|
+
file: join(plugin.source, "agents", entry),
|
|
85
|
+
frontmatterModel: model,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe.skipIf(!inMagusSrc)(
|
|
93
|
+
"models-presets agent ids track the marketplace",
|
|
94
|
+
() => {
|
|
95
|
+
const agents = marketplaceAgents();
|
|
96
|
+
const ids = new Set(agents.map((a) => a.id));
|
|
97
|
+
const presetKeys = [...Object.keys(DEFAULT_AGENT_GRADES)];
|
|
98
|
+
|
|
99
|
+
test("reads a non-trivial marketplace", () => {
|
|
100
|
+
expect(agents.length).toBeGreaterThan(10);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("(a) every preset key names an existing agent or a built-in", () => {
|
|
104
|
+
const dead = presetKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
|
|
105
|
+
expect(dead).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* (c) `WORKFLOWS` names agent ids too, and until now nothing checked them.
|
|
110
|
+
*
|
|
111
|
+
* These ids drive the per-workflow spread graphic. A stale one matches no agent, so
|
|
112
|
+
* its segment silently vanishes and the chart goes on drawing a confident answer
|
|
113
|
+
* about a workflow it has mis-measured — the same silent-drift failure (a) exists to
|
|
114
|
+
* catch, one array over. Measured: the 13.0.0 `code-analysis` → `code-search` rename
|
|
115
|
+
* reached this array through a rebase conflict rather than through this gate.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT folded into `presetKeys`. Being named in a workflow is not a
|
|
118
|
+
* routing classification, so counting it in (b) would let a workflow mention excuse
|
|
119
|
+
* an agent from ever being given a tier.
|
|
120
|
+
*/
|
|
121
|
+
test("(c) every workflow agent names an existing agent or a built-in", () => {
|
|
122
|
+
const workflowKeys = WORKFLOWS.flatMap((w) => [
|
|
123
|
+
...w.agents,
|
|
124
|
+
...w.external.map((step) => step.agent),
|
|
125
|
+
]);
|
|
126
|
+
const dead = workflowKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
|
|
127
|
+
expect(dead).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("(b) every marketplace agent is classified somewhere", () => {
|
|
131
|
+
const classified = new Set<string>([
|
|
132
|
+
...presetKeys,
|
|
133
|
+
...VISION_AGENTS,
|
|
134
|
+
...FALLBACK_BY_DESIGN,
|
|
135
|
+
]);
|
|
136
|
+
const unplaced = agents
|
|
137
|
+
.filter((a) => !classified.has(a.id))
|
|
138
|
+
.map((a) => a.id);
|
|
139
|
+
expect(unplaced).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("FALLBACK_BY_DESIGN and VISION_AGENTS name agents that still exist", () => {
|
|
143
|
+
const stale = [...FALLBACK_BY_DESIGN, ...VISION_AGENTS].filter(
|
|
144
|
+
(k) => !ids.has(k),
|
|
145
|
+
);
|
|
146
|
+
expect(stale).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// A profile's magusPlugins is an install list, not documentation: resolver.ts expands
|
|
150
|
+
// each entry to `<name>@<marketplace>: latest`. A plugin rename moves under it silently,
|
|
151
|
+
// which is how 13.0.0's code-analysis -> code-search rename left six profiles installing
|
|
152
|
+
// a plugin the marketplace no longer lists. The agent checks above never read this array.
|
|
153
|
+
test("(d) every predefined profile names plugins the marketplace still lists", () => {
|
|
154
|
+
const marketplace = JSON.parse(readFileSync(MARKETPLACE, "utf8")) as {
|
|
155
|
+
plugins: { name: string }[];
|
|
156
|
+
};
|
|
157
|
+
const known = new Set(marketplace.plugins.map((p) => p.name));
|
|
158
|
+
const dead: string[] = [];
|
|
159
|
+
for (const profile of PREDEFINED_PROFILES) {
|
|
160
|
+
for (const entry of profile.magusPlugins ?? []) {
|
|
161
|
+
const name = entry.split("@")[0];
|
|
162
|
+
if (!known.has(name)) dead.push(`${profile.id}: ${entry}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
expect(dead).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
);
|