claudeup 6.3.2 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-notice.test.ts +3 -3
  3. package/src/__tests__/cli-live.test.ts +9 -2
  4. package/src/__tests__/cli-update-view.test.ts +2 -2
  5. package/src/__tests__/footer-hints.test.ts +40 -0
  6. package/src/__tests__/gap-fill-versions.test.ts +24 -24
  7. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  8. package/src/__tests__/hook-import-policy.test.ts +90 -0
  9. package/src/__tests__/hook-process.test.ts +256 -0
  10. package/src/__tests__/hook-registration.test.ts +224 -0
  11. package/src/__tests__/manifest.test.ts +134 -0
  12. package/src/__tests__/marketplace-badge.test.ts +1 -1
  13. package/src/__tests__/marketplaces.test.ts +0 -1
  14. package/src/__tests__/model-visuals.test.tsx +793 -0
  15. package/src/__tests__/models-adapter.test.ts +317 -0
  16. package/src/__tests__/models-cli.test.ts +173 -0
  17. package/src/__tests__/models-core.test.ts +640 -0
  18. package/src/__tests__/models-manager.test.ts +497 -0
  19. package/src/__tests__/models-screen-state.test.ts +259 -0
  20. package/src/__tests__/moved-marketplace.test.ts +7 -8
  21. package/src/__tests__/plugin-contents.test.ts +1 -1
  22. package/src/__tests__/profile-adopt.test.ts +1 -1
  23. package/src/__tests__/profile-materializer.test.ts +48 -2
  24. package/src/__tests__/resolver.test.ts +43 -6
  25. package/src/__tests__/settings-file.test.ts +179 -0
  26. package/src/__tests__/symlink-manager.test.ts +65 -1
  27. package/src/__tests__/tabbar-layout.test.ts +40 -2
  28. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  29. package/src/__tests__/version-snapshot.test.ts +4 -4
  30. package/src/cli/doctor.ts +90 -0
  31. package/src/cli/hook.ts +129 -0
  32. package/src/cli/models.ts +214 -0
  33. package/src/cli/router.ts +12 -0
  34. package/src/data/gitignore-defaults.ts +4 -1
  35. package/src/data/gitignore-reasons.ts +0 -4
  36. package/src/data/marketplaces.ts +1 -15
  37. package/src/data/models-presets.ts +270 -0
  38. package/src/data/predefined-profiles.ts +12 -21
  39. package/src/data/settings-catalog.ts +11 -4
  40. package/src/main.tsx +51 -82
  41. package/src/services/hook-registration.ts +218 -0
  42. package/src/services/manifest.ts +84 -0
  43. package/src/services/models-core.ts +628 -0
  44. package/src/services/models-manager.ts +606 -0
  45. package/src/services/plugin-manager.ts +2 -3
  46. package/src/services/profile-materializer.ts +17 -0
  47. package/src/services/resolver.ts +13 -2
  48. package/src/services/settings-file.ts +69 -0
  49. package/src/services/styles-manager.ts +23 -45
  50. package/src/services/symlink-manager.ts +57 -11
  51. package/src/tui.tsx +112 -0
  52. package/src/types/bun.d.ts +21 -0
  53. package/src/types/index.ts +14 -0
  54. package/src/ui/App.tsx +15 -3
  55. package/src/ui/adapters/modelsAdapter.ts +170 -0
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/TabBar.tsx +9 -4
  58. package/src/ui/components/layout/FooterHints.tsx +20 -3
  59. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  60. package/src/ui/components/primitives/MetaText.tsx +27 -1
  61. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  62. package/src/ui/renderers/modelVisuals.tsx +853 -0
  63. package/src/ui/renderers/skillRenderers.tsx +13 -3
  64. package/src/ui/renderers/styleRenderers.tsx +7 -3
  65. package/src/ui/screens/ModelsScreen.tsx +478 -0
  66. package/src/ui/screens/PluginsScreen.tsx +1 -1
  67. package/src/ui/screens/StylesScreen.tsx +8 -13
  68. package/src/ui/screens/index.ts +1 -0
  69. package/src/ui/state/reducer.ts +94 -0
  70. package/src/ui/state/types.ts +65 -2
  71. package/src/ui/theme-mode.ts +116 -0
  72. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `claudeup 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
+ GRADES,
23
+ type ModelsConfig,
24
+ type ModelsState,
25
+ } from "../services/models-core.js";
26
+ import {
27
+ applyModelPreset,
28
+ clearModels,
29
+ configPath,
30
+ readModelsConfig,
31
+ readModelsStatus,
32
+ } from "../services/models-manager.js";
33
+ import { ensureManifest } from "./bootstrap.js";
34
+
35
+ const HEADLINE: Record<ModelsState, string> = {
36
+ off: "off — no .claude/models.json, subagents inherit the session model",
37
+ on: "on",
38
+ stale: "stale — settings no longer match the config",
39
+ invalid: "invalid — nothing is routed",
40
+ unhooked: "unhooked — the config is there, but nothing runs it",
41
+ };
42
+
43
+ /** `opus (medium)`, or `opus` when no effort is set. */
44
+ function describeSpec(spec: { model: string; effort?: string }): string {
45
+ return spec.effort ? `${spec.model} (${spec.effort})` : spec.model;
46
+ }
47
+
48
+ /**
49
+ * A preset headed by what a person would call it, with the id beside it.
50
+ *
51
+ * Both, deliberately: the label is what you choose between ("Opus with Fable help" says which
52
+ * model leads and which helps), and the id is what you TYPE into `models use` and what lands
53
+ * in the committed config. Printing only the label would leave the command unguessable.
54
+ */
55
+ function printPreset(preset: ModelsConfig, active: boolean): void {
56
+ const mark = active ? "●" : " ";
57
+ const isDefault = preset.preset === DEFAULT_PRESET ? " (default)" : "";
58
+ console.log(
59
+ `${mark} ${presetLabel(preset.preset)} — ${preset.preset}${isDefault}`,
60
+ );
61
+ console.log(` main ${describeSpec(preset.main)}`);
62
+ for (const grade of GRADES) {
63
+ console.log(
64
+ ` ${grade.padEnd(10)} ${describeSpec(preset.grades[grade])}`,
65
+ );
66
+ }
67
+ }
68
+
69
+ async function runList(projectPath: string): Promise<number> {
70
+ const { config } = await readModelsConfig(projectPath);
71
+ console.log("\nModel tiers:\n");
72
+ for (const preset of BUILT_IN_PRESETS) {
73
+ printPreset(preset, config?.preset === preset.preset);
74
+ console.log();
75
+ }
76
+ if (config && !presetNames().includes(config.preset)) {
77
+ console.log(`● ${config.preset} (this project's own, not a built-in)\n`);
78
+ }
79
+ console.log(
80
+ "Tiers name what a subagent is FOR: `smart` takes the judgement calls, `cheap`",
81
+ );
82
+ console.log(
83
+ "the searching. Which model serves each is the preset's call. Apply one with\n`claudeup models use <name>`.\n",
84
+ );
85
+ return 0;
86
+ }
87
+
88
+ async function runStatus(projectPath: string): Promise<number> {
89
+ const status = await readModelsStatus(projectPath);
90
+ console.log(`\nModel tiers: ${HEADLINE[status.state]}`);
91
+ if (status.preset)
92
+ console.log(
93
+ `Preset: ${presetLabel(status.preset)} (${status.preset})`,
94
+ );
95
+ console.log(`Config: ${configPath(projectPath)}`);
96
+
97
+ if (status.drift.length > 0) {
98
+ console.log(status.state === "invalid" ? "\nErrors:" : "\nDrift:");
99
+ for (const line of status.drift) console.log(` ✗ ${line}`);
100
+ if (status.state === "stale") {
101
+ console.log(
102
+ "\n Fix: claudeup models use <preset> (or claudeup doctor --fix)",
103
+ );
104
+ }
105
+ if (status.state === "unhooked")
106
+ console.log("\n Fix: claudeup doctor --fix");
107
+ }
108
+ for (const warning of status.warnings) console.log(`\n⚠ ${warning}`);
109
+ console.log();
110
+ return status.state === "invalid" ? 1 : 0;
111
+ }
112
+
113
+ async function runUse(projectPath: string, name?: string): Promise<number> {
114
+ if (!name) {
115
+ console.error("Usage: claudeup models use <preset>");
116
+ console.error(`Available: ${presetNames().join(", ")}`);
117
+ return 1;
118
+ }
119
+ if (!presetNames().includes(name)) {
120
+ // Never near-match. See the file header.
121
+ console.error(`Unknown preset "${name}".`);
122
+ console.error(`Available: ${presetNames().join(", ")}`);
123
+ return 1;
124
+ }
125
+
126
+ let result: Awaited<ReturnType<typeof applyModelPreset>>;
127
+ try {
128
+ result = await applyModelPreset({
129
+ projectPath,
130
+ preset: name,
131
+ ensureManifest: (p) => ensureManifest(p),
132
+ });
133
+ } catch (error) {
134
+ console.error(error instanceof Error ? error.message : String(error));
135
+ return 1;
136
+ }
137
+
138
+ console.log(`\n✓ Applied preset "${name}".`);
139
+ console.log(` ${configPath(projectPath)}`);
140
+ console.log(` ${result.settingsPath}`);
141
+ for (const [key, value] of Object.entries(result.wrote)) {
142
+ console.log(` ${key} = ${JSON.stringify(value)}`);
143
+ }
144
+ console.log(
145
+ ` hook: ${result.hook === "registered" ? "registered at user scope" : "already registered"}`,
146
+ );
147
+
148
+ if (result.recordedInManifest && result.profile) {
149
+ console.log(
150
+ `\nRecorded in .claude/profiles.json under profiles.${result.profile}.`,
151
+ );
152
+ console.log("Commit .claude/profiles.json to share this with the team.");
153
+ } else {
154
+ console.log(
155
+ "\nNo active profile — written straight to .claude/models.json, which is",
156
+ );
157
+ console.log(
158
+ "generated and gitignored. Run `claudeup install` to adopt a profile if the",
159
+ );
160
+ console.log("team should share this routing.");
161
+ }
162
+ console.log();
163
+ return 0;
164
+ }
165
+
166
+ async function runOff(projectPath: string): Promise<number> {
167
+ await clearModels(projectPath);
168
+ console.log(
169
+ "\n✓ Model tiers off. Subagents inherit the session model again.",
170
+ );
171
+ console.log(
172
+ " The hook stays registered — it is a no-op without a models.json.\n",
173
+ );
174
+ return 0;
175
+ }
176
+
177
+ function printUsage(): void {
178
+ console.log(`Usage: claudeup models <command>
179
+
180
+ list Show the built-in tier presets; ● marks the active one
181
+ use <preset> Apply a preset to this project and register the hook
182
+ status What is routed right now, and any drift
183
+ off Turn model tiers off (removes the config, keeps the hook)
184
+
185
+ Available presets: ${presetNames().join(", ")}`);
186
+ }
187
+
188
+ /**
189
+ * `projectPath` defaults to the cwd, which is what the router passes. It is a
190
+ * parameter so tests can drive a temp project without moving the process —
191
+ * `cli/profile.ts` takes the same shape for the same reason.
192
+ */
193
+ export async function runModelsCommand(
194
+ args: string[],
195
+ projectPath: string = process.cwd(),
196
+ ): Promise<number> {
197
+ const [command, ...rest] = args;
198
+
199
+ switch (command) {
200
+ case undefined:
201
+ case "status":
202
+ return runStatus(projectPath);
203
+ case "list":
204
+ return runList(projectPath);
205
+ case "use":
206
+ return runUse(projectPath, rest[0]);
207
+ case "off":
208
+ return runOff(projectPath);
209
+ default:
210
+ console.error(`Unknown models command "${command}".`);
211
+ printUsage();
212
+ return 1;
213
+ }
214
+ }
package/src/cli/router.ts CHANGED
@@ -12,6 +12,7 @@ import { parseThemeWord } from "../ui/theme-resolve.js";
12
12
  import { runClaudeCommand } from "./claude.js";
13
13
  import { runDoctorCommand } from "./doctor.js";
14
14
  import { runInstallCommand } from "./install.js";
15
+ import { runModelsCommand } from "./models.js";
15
16
  import { runProfileCommand } from "./profile.js";
16
17
  import { runUpdateCommand } from "./update.js";
17
18
  import { runUpgradeCommand } from "./upgrade.js";
@@ -125,6 +126,8 @@ export async function route(
125
126
  return HANDLED(await runInstallCommand(rest));
126
127
  case "profile":
127
128
  return HANDLED(await runProfileCommand(rest));
129
+ case "models":
130
+ return HANDLED(await runModelsCommand(rest));
128
131
  case "doctor":
129
132
  return HANDLED(await runDoctorCommand(rest));
130
133
  default:
@@ -180,6 +183,15 @@ without one is offered adoption on first run. See docs/team-configuration.md.
180
183
  doctor Check binary deps, profile symlinks, and conventions
181
184
  --fix Apply the repairs it can make itself
182
185
 
186
+ Per-subagent model routing — give each subagent its own model and effort. The
187
+ routing lives on the active profile (committed in .claude/profiles.json) and is
188
+ applied by a PreToolUse hook claudeup registers at user scope.
189
+ models list Show the built-in presets; ● marks the active one
190
+ models use <p> Apply a preset, record it on the active profile, and
191
+ register the hook
192
+ models status What is routed now, plus any drift from the config
193
+ models off Stop routing; subagents inherit the session model again
194
+
183
195
  Other commands:
184
196
  claude [args...] Check for plugin updates (1h cache), then run claude
185
197
  -f, --force Force update check (bypass 1h cache)
@@ -14,10 +14,13 @@ export const BUILTIN_DEFAULTS: GitignoreManifest = {
14
14
  ".claude/.statusline-worktree-*",
15
15
  // Generated, per-developer profile build output (like node_modules).
16
16
  ".claude/_profiles/",
17
+ // Generated from profiles.json (a symlink into _profiles/ when a profile
18
+ // is active). The routing itself IS committed — in profiles.json, under
19
+ // the profile that declares it.
20
+ ".claude/models.json",
17
21
  "ai-docs/sessions/",
18
22
  ".mnemex/",
19
23
  ".claudemem/",
20
- "gtd/sessions/",
21
24
  ".agents/",
22
25
  ],
23
26
  track: [
@@ -29,10 +29,6 @@ const RULE_REASONS = new Map<string, string>([
29
29
  key("ignore", ".claudemem/"),
30
30
  "Local claudemem indexes are generated from the repo and should not be committed.",
31
31
  ],
32
- [
33
- key("ignore", "gtd/sessions/"),
34
- "Local GTD session artifacts are personal workflow state.",
35
- ],
36
32
  [
37
33
  key("ignore", ".agents/"),
38
34
  "Local agent workspaces and runtime artifacts should stay out of team history.",
@@ -47,25 +47,11 @@ export const defaultMarketplaces: Marketplace[] = [
47
47
  source: "github",
48
48
  repo: "MadAppGang/magus-marketing",
49
49
  },
50
- description:
51
- "SEO, cold email outreach, AI image generation, and video editing plugins",
50
+ description: "AI image generation and video editing plugins",
52
51
  official: false,
53
52
  owned: true, // Ours: always listed even when uninstalled, sorted to the top
54
53
  // Not featured: a niche channel, so keep it collapsed by default.
55
54
  },
56
- {
57
- name: "magus-alpha",
58
- displayName: "Magus Alpha",
59
- source: {
60
- source: "github",
61
- repo: "MadAppGang/magus-alpha",
62
- },
63
- description:
64
- "Experimental plugins with evolving interfaces — may change or be withdrawn without notice",
65
- official: false,
66
- owned: true, // Ours: always listed even when uninstalled, sorted to the top
67
- // Not featured: experimental, so keep it collapsed by default.
68
- },
69
55
  {
70
56
  name: "claude-plugins-official",
71
57
  displayName: "Anthropic Official",
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Built-in presets and the shared agent → tier table.
3
+ *
4
+ * Hand-written constants rather than a glob over data files, for the same reason
5
+ * `src/data/styles/index.ts` hand-lists its embedded presets: a glob cannot resolve at
6
+ * `bun build --compile` time, and this module has to survive into the compiled binary — the
7
+ * hook path reads a project's own `models.json`, but `claudeup models list` and the TUI read
8
+ * these.
9
+ *
10
+ * Tiers name what a subagent is FOR, on one axis: `smart`, `normal`, `cheap`, plus `main` for
11
+ * the orchestrator. Which model serves each is the PRESET's call, and `smart` is not always
12
+ * its largest spend: `fable-advisor` sends the judgement calls to a different model (fable at
13
+ * `medium`) rather than a harder-working one. What holds everywhere is the other half: the
14
+ * thread dispatching the work is never strictly the biggest spend in the preset.
15
+ */
16
+ import type { Grade, ModelsConfig } from "../services/models-core.js";
17
+
18
+ /**
19
+ * Which tier each subagent gets, shared by every preset.
20
+ *
21
+ * Keys are `subagent_type` strings exactly as the Agent tool receives them: bare names for
22
+ * Claude Code's built-ins, `plugin:name` for plugin agents. A name absent from this table
23
+ * falls to the preset's `fallback`, so a newly installed plugin routes sensibly on day one
24
+ * instead of silently inheriting the orchestrator's model.
25
+ */
26
+ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
27
+ // `smart` is for JUDGEMENT — work where the agent decides something a person would
28
+ // argue about, and where being wrong is expensive to undo.
29
+ Plan: "smart",
30
+ "dev:architect": "smart",
31
+ "dev:reviewer": "smart",
32
+ "dev:debugger": "smart",
33
+ "multimodel:deep-analyst": "smart",
34
+
35
+ // `normal`: transforms material it is GIVEN. Real work, bounded judgement.
36
+ //
37
+ // `spec-writer` reads an interview log and writes it up as a spec — the decisions were
38
+ // made during the interview, and this turns answers into structure. `synthesizer` merges
39
+ // reviews it is handed into one report against thresholds it is handed. Neither decides
40
+ // what is true; both were on `smart` because "writes an important document" got confused
41
+ // with "makes an expensive judgement".
42
+ "dev:spec-writer": "normal",
43
+ "dev:synthesizer": "normal",
44
+
45
+ // Reading a lot is not analysing.
46
+ //
47
+ // `detective` is read-only: it locates implementations, traces a path and reports
48
+ // file:line. That is high-volume retrieval with light inference — the same shape as
49
+ // `Explore`, with more structure in the output. It sat on `smart` because "investigates"
50
+ // sounds like thinking; what it actually does is look.
51
+ "code-analysis:detective": "cheap",
52
+
53
+ // Search, transcription and bookkeeping: bounded, mechanical, high-volume.
54
+ Explore: "cheap",
55
+ "dev:scribe": "cheap",
56
+ "dev:stack-detector": "cheap",
57
+ "dev:docs": "cheap",
58
+ "terminal:tui-navigator": "cheap",
59
+ "statusline-setup": "cheap",
60
+ };
61
+
62
+ /**
63
+ * The three workflows the `dev` plugin actually runs, and the agents each one dispatches.
64
+ *
65
+ * A single "17 agents" bar counts every agent claudeup has ever heard of, weighted equally —
66
+ * which is not how a session spends anything. Nobody runs all seventeen; they run ONE of
67
+ * these, and what it costs depends on which handful it reaches for. `/dev:investigate`
68
+ * touching two cheap agents and `/dev:dev` touching five across three tiers are different
69
+ * answers to "what will this preset cost me", and the lump sum shows neither.
70
+ *
71
+ * Read out of `plugins/dev/commands/{dev,debug,investigate}.md` — agents only. Those files
72
+ * also name skills (`dev:context-detection`, `code-analysis:investigate`) which are not
73
+ * dispatched through the Agent tool and so are not routed at all.
74
+ */
75
+ export const WORKFLOWS: { name: string; agents: string[] }[] = [
76
+ {
77
+ name: "dev",
78
+ agents: [
79
+ "dev:stack-detector",
80
+ "dev:architect",
81
+ "dev:developer",
82
+ "dev:test-architect",
83
+ "dev:reviewer",
84
+ ],
85
+ },
86
+ {
87
+ name: "debug",
88
+ agents: [
89
+ "dev:stack-detector",
90
+ "code-analysis:detective",
91
+ "dev:debugger",
92
+ "dev:developer",
93
+ ],
94
+ },
95
+ {
96
+ name: "investigate",
97
+ agents: ["code-analysis:detective", "dev:researcher"],
98
+ },
99
+ ];
100
+
101
+ /**
102
+ * Agents whose model is a CAPABILITY choice, not a spend choice.
103
+ *
104
+ * The three tiers answer "how much model", and that is the wrong question for an agent that
105
+ * reads images. `designer:design-review` compares a rendered screen against a reference and
106
+ * `designer:ui` audits a screenshot for usability: what they need is the model that SEES
107
+ * best, which is not a point on a cheap-to-smart line and does not move when the preset
108
+ * changes.
109
+ *
110
+ * They are deliberately absent from the tier table above and from this map until the model
111
+ * is chosen from the live catalogue rather than from memory. Absent means they fall to the
112
+ * preset's `fallback`, which is a defensible default and an honest one; pinning them to a
113
+ * model id guessed here is neither.
114
+ */
115
+ export const VISION_AGENTS = ["designer:design-review", "designer:ui"] as const;
116
+
117
+ function preset(
118
+ name: string,
119
+ main: ModelsConfig["main"],
120
+ grades: ModelsConfig["grades"],
121
+ ): ModelsConfig {
122
+ return {
123
+ version: 1,
124
+ preset: name,
125
+ main,
126
+ grades,
127
+ agents: { ...DEFAULT_AGENT_GRADES },
128
+ fallback: "normal",
129
+ };
130
+ }
131
+
132
+ /**
133
+ * What each preset is CALLED on screen.
134
+ *
135
+ * The id (`fable-advisor`) is the machine name: it is what `models use` takes, what lands in
136
+ * the committed config, and what must never change. The label is what a person reads, and it
137
+ * says which model leads and which one helps — the thing you actually choose between. Kept
138
+ * beside the presets rather than in the UI so the CLI and the TUI cannot disagree.
139
+ */
140
+ export const PRESET_LABELS: Record<string, string> = {
141
+ "fable-advisor": "Opus with Fable help",
142
+ "fable-lead": "Fable main",
143
+ "fable-top": "Fable top",
144
+ "opus-lead": "Opus main",
145
+ "sonnet-economy": "Sonnet",
146
+ };
147
+
148
+ /** The screen name for a preset id, falling back to the id for a hand-edited config. */
149
+ export function presetLabel(id: string): string {
150
+ return PRESET_LABELS[id] ?? id;
151
+ }
152
+
153
+ /**
154
+ * The shipped presets.
155
+ *
156
+ * Effort is set per seat and reaches subagents through `modelSettings` — measured by
157
+ * `benches/agent-model-routing/` (AMR-1), which found alias keys honoured and a routed
158
+ * subagent running at its model's effort while the main thread kept its own.
159
+ *
160
+ * ## Effort belongs to the MODEL, so a preset gets one value per model
161
+ *
162
+ * `modelSettings` is keyed by model. Two seats naming the same model therefore cannot carry
163
+ * different efforts, and the validator rejects that rather than letting one silently win.
164
+ * That is the only cross-seat rule here: **within one preset, a model appears at exactly one
165
+ * effort.** Which effort is a per-preset choice — `fable-lead` runs fable at `medium` and
166
+ * `fable-top` runs it at `xhigh`, and that difference is the whole distinction between them.
167
+ *
168
+ * Presets never collide with each other, because only one is ever applied to a project.
169
+ *
170
+ * ## The levelling these presets apply
171
+ *
172
+ * Each one fixes every model it names at a single effort, which is what makes it writable:
173
+ *
174
+ * opus xhigh wherever it appears
175
+ * fable medium in `fable-lead`, xhigh in `fable-top`
176
+ * sonnet high in the `cheap` seat, xhigh when it holds the whole preset
177
+ * haiku not used
178
+ *
179
+ * A tier therefore differs from its neighbour by MODEL, not by a throttle on one model. That
180
+ * is not a style choice: a ramp like "fable xhigh on `smart`, fable low on `cheap`" is exactly
181
+ * what the `modelSettings` keying cannot express.
182
+ */
183
+ export const BUILT_IN_PRESETS: ModelsConfig[] = [
184
+ // The default: opus runs everything except the judgement calls, which go to fable.
185
+ //
186
+ // Opus takes `main` and `normal` at `xhigh`, fable takes `smart` at its ordinary `medium`,
187
+ // and sonnet picks up the cheap seat at `high`. The id says what fable DOES here — it
188
+ // advises on the hard work rather than running the thread.
189
+ preset(
190
+ "fable-advisor",
191
+ { model: "opus", effort: "xhigh" },
192
+ {
193
+ smart: { model: "fable", effort: "medium" },
194
+ normal: { model: "opus", effort: "xhigh" },
195
+ cheap: { model: "sonnet", effort: "high" },
196
+ },
197
+ ),
198
+
199
+ // Fable runs everything it can, at its ordinary `medium`, with sonnet on the cheap seat.
200
+ //
201
+ // "Fable on the main orchestrator" — the request, literally, extended to the tiers it
202
+ // dispatches. Paired with `fable-top` below: the same model on `main` and `smart`, one
203
+ // effort apart.
204
+ preset(
205
+ "fable-lead",
206
+ { model: "fable", effort: "medium" },
207
+ {
208
+ smart: { model: "fable", effort: "medium" },
209
+ normal: { model: "fable", effort: "medium" },
210
+ cheap: { model: "sonnet", effort: "high" },
211
+ },
212
+ ),
213
+
214
+ // Fable at full stretch on the thread and the judgement calls; opus does the building.
215
+ //
216
+ // `fable-lead` runs fable at `medium` in three seats; this runs it at `xhigh` in two and
217
+ // hands `normal` — the highest-volume tier, since it is also the `fallback` — to opus.
218
+ // The difference between the two presets is fable's effort, which is why it has to BE two
219
+ // presets rather than one with a ramp.
220
+ preset(
221
+ "fable-top",
222
+ { model: "fable", effort: "xhigh" },
223
+ {
224
+ smart: { model: "fable", effort: "xhigh" },
225
+ normal: { model: "opus", effort: "xhigh" },
226
+ cheap: { model: "sonnet", effort: "high" },
227
+ },
228
+ ),
229
+
230
+ // Opus everywhere it is reached at all, at `xhigh`; sonnet only on the cheap seat.
231
+ //
232
+ // `main`, `smart` and `normal` are one spend decision wearing three names. That is legal
233
+ // precisely because they agree on effort, and it is the point of the preset rather than a
234
+ // compromise: the only thing NOT on opus is the searching and transcription.
235
+ preset(
236
+ "opus-lead",
237
+ { model: "opus", effort: "xhigh" },
238
+ {
239
+ smart: { model: "opus", effort: "xhigh" },
240
+ normal: { model: "opus", effort: "xhigh" },
241
+ cheap: { model: "sonnet", effort: "high" },
242
+ },
243
+ ),
244
+
245
+ // Sonnet, at `xhigh`, in all four seats.
246
+ //
247
+ // This preset deliberately has no tier differentiation: its saving is the model, and the
248
+ // effort is not throttled on top of it. Every routed agent runs sonnet at `xhigh`,
249
+ // including `cheap` — so the hook still pins the model (a session started on opus does
250
+ // not leak into its subagents) while the tier table itself makes no distinction.
251
+ preset(
252
+ "sonnet-economy",
253
+ { model: "sonnet", effort: "xhigh" },
254
+ {
255
+ smart: { model: "sonnet", effort: "xhigh" },
256
+ normal: { model: "sonnet", effort: "xhigh" },
257
+ cheap: { model: "sonnet", effort: "xhigh" },
258
+ },
259
+ ),
260
+ ];
261
+
262
+ export const DEFAULT_PRESET = "fable-advisor";
263
+
264
+ export function findPreset(name: string): ModelsConfig | undefined {
265
+ return BUILT_IN_PRESETS.find((p) => p.preset === name);
266
+ }
267
+
268
+ export function presetNames(): string[] {
269
+ return BUILT_IN_PRESETS.map((p) => p.preset);
270
+ }
@@ -1,3 +1,5 @@
1
+ import type { ModelsConfig } from "../services/models-core.js";
2
+
1
3
  export interface PredefinedProfile {
2
4
  id: string;
3
5
  name: string;
@@ -7,6 +9,13 @@ export interface PredefinedProfile {
7
9
  anthropicPlugins: string[];
8
10
  skills: string[];
9
11
  settings: Record<string, unknown>;
12
+ /**
13
+ * Per-subagent model routing a profile inherits via `extends`. None of the
14
+ * profiles below declare one — routing is chosen per project by `claudeup
15
+ * models use`, not bundled with a plugin set — but the field is here so the
16
+ * inheritance path in `resolveExtends` is real rather than notional.
17
+ */
18
+ models?: ModelsConfig;
10
19
  }
11
20
 
12
21
  export const PREDEFINED_PROFILES: PredefinedProfile[] = [
@@ -48,7 +57,6 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
48
57
  "browser-use",
49
58
  "designer",
50
59
  "terminal",
51
- "kanban",
52
60
  ],
53
61
  anthropicPlugins: [
54
62
  "claude-code-setup",
@@ -121,14 +129,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
121
129
  name: "Backend Forge",
122
130
  description: "API development, debugging, code quality, data workflows",
123
131
  icon: "★",
124
- magusPlugins: [
125
- "dev",
126
- "code-analysis",
127
- "terminal",
128
- "setup",
129
- "multimodel",
130
- "gtd",
131
- ],
132
+ magusPlugins: ["dev", "code-analysis", "terminal", "setup", "multimodel"],
132
133
  anthropicPlugins: [
133
134
  "feature-dev",
134
135
  "code-review",
@@ -162,7 +163,6 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
162
163
  "terminal",
163
164
  "setup",
164
165
  "multimodel",
165
- "gtd",
166
166
  "browser-use",
167
167
  ],
168
168
  anthropicPlugins: [
@@ -190,15 +190,13 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
190
190
  {
191
191
  id: "growth-marketer",
192
192
  name: "Growth Marketer",
193
- description:
194
- "SEO, website audits, content production, marketing automation",
193
+ description: "Website audits, content production, marketing automation",
195
194
  icon: "★",
196
195
  magusPlugins: [
197
196
  "dev",
198
197
  "code-analysis",
199
198
  "terminal",
200
199
  "setup",
201
- "seo@magus-marketing",
202
200
  "browser-use",
203
201
  "image-generate@magus-marketing",
204
202
  "video-editing@magus-marketing",
@@ -228,14 +226,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
228
226
  description:
229
227
  "Planning, code review, coordination, and broad repo visibility",
230
228
  icon: "★",
231
- magusPlugins: [
232
- "dev",
233
- "code-analysis",
234
- "terminal",
235
- "setup",
236
- "multimodel",
237
- "gtd",
238
- ],
229
+ magusPlugins: ["dev", "code-analysis", "terminal", "setup", "multimodel"],
239
230
  anthropicPlugins: [
240
231
  "code-review",
241
232
  "pr-review-toolkit",
@@ -66,6 +66,7 @@ export const SETTINGS_CATALOG: SettingDefinition[] = [
66
66
  { label: "Low", value: "low" },
67
67
  { label: "Medium", value: "medium" },
68
68
  { label: "High", value: "high" },
69
+ { label: "Extra high", value: "xhigh" },
69
70
  ],
70
71
  storage: { type: "setting", key: "effortLevel" },
71
72
  },
@@ -135,14 +136,20 @@ export const SETTINGS_CATALOG: SettingDefinition[] = [
135
136
  {
136
137
  id: "model",
137
138
  name: "Default Model",
138
- description: "Override the default model for Claude Code",
139
+ // Aliases, never pinned ids. A pinned id in a shipped list goes stale the
140
+ // day Anthropic moves the alias, and goes stale SILENTLY — the setting
141
+ // keeps naming a model that is a release behind, and nothing reports it.
142
+ // The alias always names whatever that tier currently is.
143
+ description:
144
+ "Override the default model for Claude Code. Aliases track the current model for each tier",
139
145
  category: "models",
140
146
  type: "select",
141
147
  options: [
142
148
  { label: "Default", value: "" },
143
- { label: "Sonnet", value: "claude-sonnet-4-6" },
144
- { label: "Opus", value: "claude-opus-4-6" },
145
- { label: "Haiku", value: "claude-haiku-4-5-20251001" },
149
+ { label: "Opus", value: "opus" },
150
+ { label: "Sonnet", value: "sonnet" },
151
+ { label: "Haiku", value: "haiku" },
152
+ { label: "Fable", value: "fable" },
146
153
  ],
147
154
  storage: { type: "setting", key: "model" },
148
155
  },