claudeup 6.3.0 → 6.4.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 (59) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/cli-live.test.ts +9 -2
  3. package/src/__tests__/footer-hints.test.ts +40 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  5. package/src/__tests__/hook-import-policy.test.ts +90 -0
  6. package/src/__tests__/hook-process.test.ts +256 -0
  7. package/src/__tests__/hook-registration.test.ts +224 -0
  8. package/src/__tests__/manifest.test.ts +134 -0
  9. package/src/__tests__/model-visuals.test.tsx +789 -0
  10. package/src/__tests__/models-adapter.test.ts +317 -0
  11. package/src/__tests__/models-cli.test.ts +173 -0
  12. package/src/__tests__/models-core.test.ts +640 -0
  13. package/src/__tests__/models-manager.test.ts +497 -0
  14. package/src/__tests__/models-screen-state.test.ts +259 -0
  15. package/src/__tests__/profile-materializer.test.ts +46 -0
  16. package/src/__tests__/resolver.test.ts +38 -2
  17. package/src/__tests__/settings-file.test.ts +179 -0
  18. package/src/__tests__/symlink-manager.test.ts +65 -1
  19. package/src/__tests__/tabbar-layout.test.ts +40 -2
  20. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  21. package/src/__tests__/version-snapshot.test.ts +2 -4
  22. package/src/cli/doctor.ts +90 -0
  23. package/src/cli/hook.ts +129 -0
  24. package/src/cli/models.ts +214 -0
  25. package/src/cli/router.ts +12 -0
  26. package/src/data/gitignore-defaults.ts +4 -0
  27. package/src/data/models-presets.ts +281 -0
  28. package/src/data/predefined-profiles.ts +16 -7
  29. package/src/data/settings-catalog.ts +11 -4
  30. package/src/main.tsx +51 -82
  31. package/src/services/hook-registration.ts +218 -0
  32. package/src/services/manifest.ts +84 -0
  33. package/src/services/models-core.ts +628 -0
  34. package/src/services/models-manager.ts +606 -0
  35. package/src/services/profile-materializer.ts +17 -0
  36. package/src/services/resolver.ts +11 -0
  37. package/src/services/settings-file.ts +69 -0
  38. package/src/services/styles-manager.ts +23 -45
  39. package/src/services/symlink-manager.ts +57 -11
  40. package/src/tui.tsx +112 -0
  41. package/src/types/bun.d.ts +21 -0
  42. package/src/types/index.ts +14 -0
  43. package/src/ui/App.tsx +15 -3
  44. package/src/ui/adapters/modelsAdapter.ts +170 -0
  45. package/src/ui/components/TabBar.tsx +9 -4
  46. package/src/ui/components/layout/FooterHints.tsx +20 -3
  47. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  48. package/src/ui/components/primitives/MetaText.tsx +27 -1
  49. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  50. package/src/ui/renderers/modelVisuals.tsx +853 -0
  51. package/src/ui/renderers/skillRenderers.tsx +13 -3
  52. package/src/ui/renderers/styleRenderers.tsx +7 -3
  53. package/src/ui/screens/ModelsScreen.tsx +478 -0
  54. package/src/ui/screens/StylesScreen.tsx +8 -13
  55. package/src/ui/screens/index.ts +1 -0
  56. package/src/ui/state/reducer.ts +94 -0
  57. package/src/ui/state/types.ts +65 -2
  58. package/src/ui/theme-mode.ts +116 -0
  59. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Per-subagent model routing — everything that touches disk.
3
+ *
4
+ * `models-core.ts` holds the pure rules (what a config means, what settings it
5
+ * implies, what the hook should print) and is imported by the hot hook path.
6
+ * This module is the other half: reading and writing `.claude/models.json`,
7
+ * recording routing in the committed manifest, and resolving an alias to the
8
+ * full model id THIS machine runs.
9
+ *
10
+ * ## Where a config lives, and why there are two places
11
+ *
12
+ * The routing is a PROFILE property, committed in `.claude/profiles.json` under
13
+ * `profiles.<name>.models`. `.claude/models.json` is the generated view of it —
14
+ * a symlink into `_profiles/<active>/models.json` when a profile is active, a
15
+ * plain file when the project has no profiles. The hook reads the generated
16
+ * file (it walks up from the session's cwd looking for exactly that name); a
17
+ * teammate gets the routing by pulling profiles.json and running `claudeup
18
+ * install`.
19
+ *
20
+ * Writing only the generated file would be silently temporary: `claudeup
21
+ * install` rewrites `_profiles/<name>/` from the manifest, so the routing would
22
+ * disappear on the next install with nothing to explain why.
23
+ *
24
+ * ## Why the full model id is resolved live
25
+ *
26
+ * `modelSettings` is keyed by model, and MEASURED (AMR-1 `effort-conflict`) a
27
+ * full-id key beats an alias key when both are present and disagree. So both
28
+ * forms are written with the same value — which requires knowing the full id,
29
+ * and that id changes whenever Anthropic ships a new model behind an alias. A
30
+ * table in this repo would be wrong within weeks and would be wrong SILENTLY.
31
+ * `resolveFullModelId` reads it out of this machine's own session transcripts
32
+ * instead, and returns null rather than guessing.
33
+ */
34
+
35
+ import {
36
+ closeSync,
37
+ existsSync,
38
+ fstatSync,
39
+ openSync,
40
+ readFileSync,
41
+ readSync,
42
+ readdirSync,
43
+ statSync,
44
+ } from "node:fs";
45
+ import os from "node:os";
46
+ import path from "node:path";
47
+ import fs from "fs-extra";
48
+ import { findPreset, presetNames } from "../data/models-presets.js";
49
+ import type { ProfileManifest } from "../types/index.js";
50
+ import {
51
+ isAgentModelHookRegistered,
52
+ registerAgentModelHook,
53
+ } from "./hook-registration.js";
54
+ import { readManifest, updateProfileEntry } from "./manifest.js";
55
+ import {
56
+ type ModelAlias,
57
+ type ModelsConfig,
58
+ type ModelsStatus,
59
+ type ValidationError,
60
+ buildSettingsPatch,
61
+ computeModelsStatus,
62
+ validateModelsConfig,
63
+ } from "./models-core.js";
64
+ import { materializeProfile } from "./profile-materializer.js";
65
+ import { resolveAllProfiles, resolveProfile } from "./resolver.js";
66
+ import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
67
+ import { activeProfile, syncProfileLink } from "./symlink-manager.js";
68
+
69
+ /** `<projectPath>/.claude/models.json` — what the hook looks for. */
70
+ export function configPath(projectPath: string): string {
71
+ return path.join(projectPath, ".claude", "models.json");
72
+ }
73
+
74
+ /** `<projectPath>/.claude/settings.json` — a symlink when a profile is active. */
75
+ function settingsPathFor(projectPath: string): string {
76
+ return path.join(projectPath, ".claude", "settings.json");
77
+ }
78
+
79
+ export interface ReadModelsResult {
80
+ /** The config, or null when there is no usable one. */
81
+ config: ModelsConfig | null;
82
+ errors: ValidationError[];
83
+ path: string;
84
+ }
85
+
86
+ /**
87
+ * Read and validate the project's `models.json`.
88
+ *
89
+ * A missing file is `{config: null, errors: []}` — routing is off, which is not
90
+ * a problem. An unparseable or invalid one is `{config: null, errors: [...]}`,
91
+ * because it IS a problem: the config is committed, so a bad value is a bad
92
+ * value for the whole team.
93
+ */
94
+ export async function readModelsConfig(
95
+ projectPath: string,
96
+ ): Promise<ReadModelsResult> {
97
+ const file = configPath(projectPath);
98
+ if (!(await fs.pathExists(file))) {
99
+ return { config: null, errors: [], path: file };
100
+ }
101
+
102
+ let raw: unknown;
103
+ try {
104
+ raw = JSON.parse(await fs.readFile(file, "utf8"));
105
+ } catch (error) {
106
+ return {
107
+ config: null,
108
+ errors: [{ path: "", message: `not valid JSON: ${String(error)}` }],
109
+ path: file,
110
+ };
111
+ }
112
+
113
+ const errors = validateModelsConfig(raw);
114
+ return {
115
+ config: errors.length === 0 ? (raw as ModelsConfig) : null,
116
+ errors,
117
+ path: file,
118
+ };
119
+ }
120
+
121
+ // ─── Live alias → full model id ───────────────────────────────────────────────
122
+
123
+ /** Resolved ids, keyed by home+alias+scan. Only successes are cached. */
124
+ const fullIdCache = new Map<string, string>();
125
+
126
+ /**
127
+ * The scan reads transcripts BACKWARDS in bounded chunks, under a total byte budget.
128
+ *
129
+ * Reading whole files here was a memory bomb, not merely slow. Session transcripts are
130
+ * append-only and unbounded: on this machine `~/.claude/projects` is 4 GB and the largest
131
+ * single `.jsonl` is 597 MB, so `readFileSync(file, "utf8")` on the newest sixty allocated
132
+ * hundreds of megabytes of string to find a value that appears on every line. Measured, one
133
+ * `claudeup models use` spent 6.5 s in this function.
134
+ *
135
+ * A single fixed tail is not the fix on its own, and MEASURED why: with one 512 KB tail per
136
+ * file, `claude-opus-5` still resolved and `claude-sonnet-5` stopped resolving — an alias this
137
+ * machine had not used in the newest records simply was not in the last half megabyte of any
138
+ * of them. Falling back to the alias key alone is not free either: a user whose `/effort`
139
+ * already wrote a full-id entry would keep it, because a full id beats an alias (AMR-1
140
+ * `effort-conflict`).
141
+ *
142
+ * So: walk each file from the end in `CHUNK_BYTES` steps, stop that file at its first hit,
143
+ * and stop everything at `BUDGET_BYTES`. The common case — the alias appears in the newest
144
+ * records — costs one chunk. The rare case is bounded instead of unbounded, and only one
145
+ * chunk is resident at a time.
146
+ */
147
+ const CHUNK_BYTES = 512 * 1024;
148
+
149
+ /** Give up after reading this much in total, across every file in one call. */
150
+ const BUDGET_BYTES = 64 * 1024 * 1024;
151
+
152
+ /**
153
+ * Never spend more than this on ONE file.
154
+ *
155
+ * Without it a single huge transcript eats the whole budget and the scan never reaches the
156
+ * files that would have answered. MEASURED: the newest transcript here is 597 MB and contains
157
+ * only opus records, so a budget-only bound read 64 MB of it, found no sonnet, and stopped —
158
+ * `claude-sonnet-5` stayed unresolved for the same reason as before the fix. Breadth across
159
+ * files beats depth into one, because different files are where different aliases live.
160
+ */
161
+ const PER_FILE_BYTES = 4 * 1024 * 1024;
162
+
163
+ /** Stop once this many `"model"` values have been counted. */
164
+ const ENOUGH_HITS = 200;
165
+
166
+ /**
167
+ * How many transcripts to consider, newest first.
168
+ *
169
+ * This is a COUNT standing in for a time window, and the two come apart badly. MEASURED on
170
+ * this machine: 2995 transcripts, and the newest 60 spanned fifty minutes — because every
171
+ * live session appends to its own file, so concurrent work floods the top of the ranking.
172
+ * Under a 60-file window `claude-sonnet-5` could not resolve at all: its most recent
173
+ * appearance was rank 85. An hour of history is not a sample of what this machine runs.
174
+ *
175
+ * The real bound is `BUDGET_BYTES`, not this number. Pass 1 reads one chunk per file and
176
+ * stops at the budget, so raising the count widens the window without raising the worst
177
+ * case: a common alias still answers from the first file or two.
178
+ */
179
+ const DEFAULT_SCAN = 400;
180
+
181
+ /** Read `length` bytes at `position`, or null if the file cannot be read. */
182
+ function readAt(file: string, position: number, length: number): string | null {
183
+ let fd: number | undefined;
184
+ try {
185
+ fd = openSync(file, "r");
186
+ const buf = Buffer.allocUnsafe(length);
187
+ readSync(fd, buf, 0, length, position);
188
+ return buf.toString("utf8");
189
+ } catch {
190
+ return null;
191
+ } finally {
192
+ if (fd !== undefined) {
193
+ try {
194
+ closeSync(fd);
195
+ } catch {
196
+ // Closing a handle we already lost is not a failure worth reporting.
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ /** A file's size, or null if it cannot be stat'ed. */
203
+ function sizeOf(file: string): number | null {
204
+ try {
205
+ return statSync(file).size;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The full model id this machine currently runs for `alias`, or null.
213
+ *
214
+ * Read LIVE from the newest session transcripts under
215
+ * `<home>/.claude/projects/<session>/*.jsonl`, taking the most frequent spelling with
216
+ * no bracketed suffix. Never a hardcoded table: model ids change under an alias
217
+ * and a stale one is worse than none — `buildSettingsPatch` writes only the
218
+ * alias key when this returns null, which still works.
219
+ *
220
+ * The scan is bounded (newest `scan` files, default 60) because this is called
221
+ * from `models status`, `doctor` and every apply. Successes are memoized for
222
+ * the life of the process; failures are not, so a machine that gains its first
223
+ * transcript mid-run resolves on the next call.
224
+ *
225
+ * Adapted from `benches/agent-model-routing/stage-setups.ts`, which throws
226
+ * where this returns null — a bench wants to stop, a CLI wants to carry on.
227
+ */
228
+ export function resolveFullModelId(
229
+ alias: ModelAlias,
230
+ opts: { home?: string; scan?: number } = {},
231
+ ): string | null {
232
+ const home = opts.home ?? os.homedir();
233
+ const scan = opts.scan ?? DEFAULT_SCAN;
234
+ const cacheKey = `${home}\0${alias}\0${scan}`;
235
+ const cached = fullIdCache.get(cacheKey);
236
+ if (cached) return cached;
237
+
238
+ const projects = path.join(home, ".claude", "projects");
239
+ if (!existsSync(projects)) return null;
240
+
241
+ const files: { file: string; mtime: number }[] = [];
242
+ let encoded: string[];
243
+ try {
244
+ encoded = readdirSync(projects);
245
+ } catch {
246
+ return null;
247
+ }
248
+ for (const enc of encoded) {
249
+ const dir = path.join(projects, enc);
250
+ let entries: string[];
251
+ try {
252
+ entries = readdirSync(dir);
253
+ } catch {
254
+ continue;
255
+ }
256
+ for (const name of entries) {
257
+ if (!name.endsWith(".jsonl")) continue;
258
+ const file = path.join(dir, name);
259
+ try {
260
+ files.push({ file, mtime: statSync(file).mtimeMs });
261
+ } catch {
262
+ // A transcript that vanished between listing and stat is not an error.
263
+ }
264
+ }
265
+ }
266
+ files.sort((a, b) => b.mtime - a.mtime);
267
+
268
+ const rx = new RegExp(`"model":"(claude-${alias}[^"]*)"`, "g");
269
+ const counts = new Map<string, number>();
270
+ const newest = files.slice(0, scan).map((f) => f.file);
271
+ let hits = 0;
272
+ let budget = BUDGET_BYTES;
273
+
274
+ /** Scan one file backwards, at most `perFile` bytes, stopping at its first hit. */
275
+ const sweep = (file: string, perFile: number): void => {
276
+ const size = sizeOf(file);
277
+ if (size === null) return;
278
+ const floor = Math.max(0, size - perFile);
279
+ for (let end = size; end > floor && budget > 0; end -= CHUNK_BYTES) {
280
+ const length = Math.min(CHUNK_BYTES, end - floor);
281
+ const text = readAt(file, end - length, length);
282
+ budget -= length;
283
+ if (text === null) return;
284
+
285
+ let found = false;
286
+ for (const m of text.matchAll(rx)) {
287
+ const id = m[1] as string;
288
+ counts.set(id, (counts.get(id) ?? 0) + 1);
289
+ hits += 1;
290
+ found = true;
291
+ }
292
+ if (found) return;
293
+ }
294
+ };
295
+
296
+ // Pass 1 — BREADTH. One chunk from the end of every file. This answers for any alias
297
+ // used recently, which is the overwhelmingly common case, and costs at most
298
+ // scan × CHUNK_BYTES.
299
+ for (const file of newest) {
300
+ if (hits >= ENOUGH_HITS || budget <= 0) break;
301
+ sweep(file, CHUNK_BYTES);
302
+ }
303
+
304
+ // Pass 2 — DEPTH, and only when breadth found nothing. An alias this machine has not
305
+ // used lately still appears further back; without this, `claude-sonnet-5` went
306
+ // unresolved here while `claude-opus-5` resolved, which is worse than it sounds: the
307
+ // full-id key is what stops a user's own `/effort` entry from outranking the project's
308
+ // (AMR-1 `effort-conflict`). Depth is capped per file so one 597 MB transcript cannot
309
+ // consume the budget that the next file needed.
310
+ if (hits === 0) {
311
+ for (const file of newest) {
312
+ if (hits > 0 || budget <= 0) break;
313
+ sweep(file, PER_FILE_BYTES);
314
+ }
315
+ }
316
+
317
+ // Prefer the plainest spelling: Claude Code records `claude-opus-5` in
318
+ // transcripts and `claude-opus-5[1m]` in usage envelopes, and a
319
+ // context-window suffix is a DIFFERENT modelSettings key.
320
+ let best: string | null = null;
321
+ let bestCount = 0;
322
+ for (const [id, count] of counts) {
323
+ if (id.includes("[")) continue;
324
+ if (count > bestCount) {
325
+ best = id;
326
+ bestCount = count;
327
+ }
328
+ }
329
+ if (best) fullIdCache.set(cacheKey, best);
330
+ return best;
331
+ }
332
+
333
+ /** The alias → full-id resolver handed to the pure core. */
334
+ const liveResolver = (alias: ModelAlias): string | null =>
335
+ resolveFullModelId(alias);
336
+
337
+ // ─── Apply ────────────────────────────────────────────────────────────────────
338
+
339
+ export interface ApplyModelPresetArgs {
340
+ projectPath: string;
341
+ /** A built-in preset name, or a config object to write verbatim. */
342
+ preset: string | ModelsConfig;
343
+ /**
344
+ * Create `.claude/profiles.json` when the project has none.
345
+ *
346
+ * Injected rather than imported: `cli/bootstrap.ts`'s `ensureManifest`
347
+ * prompts, and services here never own the terminal (nothing under
348
+ * `services/` imports from `cli/`). `cli/models.ts` passes it; a caller that
349
+ * does not gets the manifest-free path — a plain `.claude/models.json` and
350
+ * `recordedInManifest: false`.
351
+ */
352
+ ensureManifest?: (projectPath: string) => Promise<ProfileManifest | null>;
353
+ }
354
+
355
+ export interface ApplyModelPresetResult {
356
+ /** The profile the routing was recorded in, or null when there is none. */
357
+ profile: string | null;
358
+ settingsPath: string;
359
+ recordedInManifest: boolean;
360
+ config: ModelsConfig;
361
+ /** Settings keys written, for the CLI to echo. */
362
+ wrote: Record<string, unknown>;
363
+ hook: "registered" | "already";
364
+ }
365
+
366
+ /**
367
+ * Apply a routing preset to a project.
368
+ *
369
+ * Validation comes first: an invalid config makes every subagent spawn fail, so
370
+ * it must never reach disk. After that the order of the numbered steps below is
371
+ * load-bearing — the manifest is written before materialization because
372
+ * materialization regenerates `_profiles/<n>/` FROM the manifest, and the hook
373
+ * is registered last because a hook pointing at a config that failed to write is
374
+ * worse than no hook at all.
375
+ */
376
+ export async function applyModelPreset(
377
+ args: ApplyModelPresetArgs,
378
+ ): Promise<ApplyModelPresetResult> {
379
+ const { projectPath } = args;
380
+
381
+ const config =
382
+ typeof args.preset === "string"
383
+ ? findPreset(args.preset)
384
+ : (args.preset as ModelsConfig | undefined);
385
+ if (!config) {
386
+ throw new Error(
387
+ `Unknown preset "${String(args.preset)}". Available: ${presetNames().join(", ")}`,
388
+ );
389
+ }
390
+
391
+ const errors = validateModelsConfig(config);
392
+ if (errors.length > 0) {
393
+ throw new Error(
394
+ `Refusing to write an invalid model config — a model the Agent tool rejects makes every subagent spawn fail:\n${errors
395
+ .map((e) => ` ${e.path || "(root)"}: ${e.message}`)
396
+ .join("\n")}`,
397
+ );
398
+ }
399
+
400
+ const patch = buildSettingsPatch(config, liveResolver);
401
+ const settingsPath = settingsPathFor(projectPath);
402
+
403
+ // 1. A manifest, if the project can have one.
404
+ let manifest = await readManifest(projectPath);
405
+ if (Object.keys(manifest.profiles).length === 0 && args.ensureManifest) {
406
+ manifest = (await args.ensureManifest(projectPath)) ?? manifest;
407
+ }
408
+ const profile = await activeProfile(projectPath);
409
+
410
+ // 2. Record BOTH halves in the manifest: the routing, and the settings it
411
+ // implies. Without the settings half the next `claudeup install` rewrites
412
+ // settings.json from the manifest and silently reverts the main thread's
413
+ // model — the routing would keep working and the orchestrator would not.
414
+ const recordedInManifest = await updateProfileEntry(
415
+ projectPath,
416
+ profile,
417
+ (entry) => {
418
+ entry.models = config;
419
+ entry.settings = { ...(entry.settings ?? {}), ...patch.set };
420
+ },
421
+ );
422
+
423
+ // 3. Regenerate the profile dir from the manifest, then point
424
+ // .claude/models.json at it. Only the models link is touched: repointing all
425
+ // four here would replace a project's real .mcp.json with a symlink as a side
426
+ // effect of choosing a model.
427
+ if (recordedInManifest && profile) {
428
+ // Re-read: `manifest` above predates the write, so resolving from it would
429
+ // materialize the OLD entry — which, having no `models`, would delete the
430
+ // file this call exists to create.
431
+ const written = await readManifest(projectPath);
432
+ await materializeProfile(
433
+ profile,
434
+ await resolveProfile(written, profile, { binResolver: noBins }),
435
+ projectPath,
436
+ await unionPluginIds(projectPath),
437
+ );
438
+ await syncProfileLink(profile, "models", projectPath);
439
+ } else {
440
+ // No profile: models.json is a plain committed-alongside file.
441
+ await fs.outputFile(
442
+ configPath(projectPath),
443
+ `${JSON.stringify(config, null, 2)}\n`,
444
+ );
445
+ }
446
+
447
+ // 4. The live settings file (a symlink into the profile when one is active,
448
+ // so this lands in the same place materialization just wrote).
449
+ await updateSettingsFile(settingsPath, (settings) => {
450
+ for (const [key, value] of Object.entries(patch.set)) {
451
+ settings[key] = value;
452
+ }
453
+ });
454
+
455
+ // 5. Without this, nothing routes.
456
+ const hook = await registerAgentModelHook();
457
+
458
+ return {
459
+ profile,
460
+ settingsPath,
461
+ recordedInManifest,
462
+ config,
463
+ wrote: patch.set,
464
+ hook,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Turn routing off: drop it from the profile, delete the generated config, and
470
+ * unwind the settings keys it claimed.
471
+ */
472
+ export async function clearModels(projectPath: string): Promise<void> {
473
+ // Read the config BEFORE deleting it — its patch names exactly which
474
+ // modelSettings keys were ours.
475
+ const { config } = await readModelsConfig(projectPath);
476
+ const claimed = config
477
+ ? Object.keys(
478
+ (buildSettingsPatch(config, liveResolver).set.modelSettings ??
479
+ {}) as Record<string, unknown>,
480
+ )
481
+ : [];
482
+
483
+ const profile = await activeProfile(projectPath);
484
+ const removed = await updateProfileEntry(projectPath, profile, (entry) => {
485
+ // biome-ignore lint/performance/noDelete: absence is the state, not undefined
486
+ delete entry.models;
487
+ if (entry.settings) {
488
+ // biome-ignore lint/performance/noDelete: same
489
+ delete entry.settings.model;
490
+ // biome-ignore lint/performance/noDelete: same
491
+ delete entry.settings.effortLevel;
492
+ unclaimModelSettings(entry.settings, claimed);
493
+ }
494
+ });
495
+
496
+ if (removed && profile) {
497
+ // Re-materializing with no `models` in the closure DELETES
498
+ // _profiles/<n>/models.json, and the relink then removes the now-dangling
499
+ // .claude/models.json. That is the whole mechanism by which `off` is off.
500
+ const manifest = await readManifest(projectPath);
501
+ await materializeProfile(
502
+ profile,
503
+ await resolveProfile(manifest, profile, { binResolver: noBins }),
504
+ projectPath,
505
+ await unionPluginIds(projectPath),
506
+ );
507
+ await syncProfileLink(profile, "models", projectPath);
508
+ }
509
+ await fs.remove(configPath(projectPath));
510
+
511
+ const settingsPath = settingsPathFor(projectPath);
512
+ if (await fs.pathExists(settingsPath)) {
513
+ await updateSettingsFile(settingsPath, (settings) => {
514
+ // biome-ignore lint/performance/noDelete: Claude Code reads presence
515
+ delete settings.model;
516
+ // biome-ignore lint/performance/noDelete: same
517
+ delete settings.effortLevel;
518
+ unclaimModelSettings(settings, claimed);
519
+ });
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Remove only the `modelSettings` keys this config claimed.
525
+ *
526
+ * NEVER the whole object: `/effort` writes a user's own per-model entries into
527
+ * the same place, and deleting the object would take their settings with ours.
528
+ * An object left empty is removed, so `off` leaves no husk.
529
+ */
530
+ function unclaimModelSettings(
531
+ settings: Record<string, unknown>,
532
+ claimed: readonly string[],
533
+ ): void {
534
+ const modelSettings = settings.modelSettings;
535
+ if (typeof modelSettings !== "object" || modelSettings === null) return;
536
+ const rec = modelSettings as Record<string, unknown>;
537
+ for (const key of claimed) {
538
+ delete rec[key];
539
+ }
540
+ if (Object.keys(rec).length === 0) {
541
+ // biome-ignore lint/performance/noDelete: same
542
+ delete settings.modelSettings;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Materialization needs no binary requirements — it writes settings, mcp,
548
+ * models and skills, and reads none of `closure.bins`. Skipping the resolver's
549
+ * per-plugin `plugin.json` reads keeps `models use` off the marketplace cache
550
+ * entirely.
551
+ */
552
+ const noBins = async () => [];
553
+
554
+ /**
555
+ * The manifest-wide union of plugin ids, which `materializeProfile` needs to
556
+ * keep writing the explicit `false` entries that make `profile switch`
557
+ * exclusive. Re-materializing without it would silently make switching additive
558
+ * again — a routing change must not have that side effect.
559
+ */
560
+ async function unionPluginIds(projectPath: string): Promise<string[]> {
561
+ const manifest = await readManifest(projectPath);
562
+ const union = await resolveAllProfiles(manifest, { binResolver: noBins });
563
+ return Object.keys(union.plugins);
564
+ }
565
+
566
+ // ─── Status ───────────────────────────────────────────────────────────────────
567
+
568
+ /** What `claudeup models status` and `claudeup doctor` report. */
569
+ export async function readModelsStatus(
570
+ projectPath: string,
571
+ ): Promise<ModelsStatus> {
572
+ const { config, errors } = await readModelsConfig(projectPath);
573
+ const settings = await readSettingsFileSafely(settingsPathFor(projectPath));
574
+ return computeModelsStatus({
575
+ config,
576
+ errors,
577
+ hookRegistered: await isAgentModelHookRegistered(),
578
+ settings,
579
+ resolveFullId: liveResolver,
580
+ subagentModelEnv: process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? null,
581
+ });
582
+ }
583
+
584
+ /**
585
+ * Settings for a read-only comparison. A file we cannot parse reads as `{}`
586
+ * here — every key then shows as drift, which is the truth: nothing we asked
587
+ * for is in effect. `updateSettingsFile` still refuses to WRITE it.
588
+ */
589
+ async function readSettingsFileSafely(
590
+ settingsPath: string,
591
+ ): Promise<Record<string, unknown>> {
592
+ try {
593
+ return await readSettingsFile(settingsPath);
594
+ } catch {
595
+ return {};
596
+ }
597
+ }
598
+
599
+ /** Re-apply the config already on disk. Used by `doctor --fix` to clear drift. */
600
+ export async function reapplyModels(
601
+ projectPath: string,
602
+ ): Promise<ApplyModelPresetResult | null> {
603
+ const { config } = await readModelsConfig(projectPath);
604
+ if (!config) return null;
605
+ return applyModelPreset({ projectPath, preset: config });
606
+ }
@@ -4,6 +4,9 @@
4
4
  * Writes the shared config the symlink manager later points at:
5
5
  * _profiles/<name>/settings.json — enabledPlugins + the profile's settings
6
6
  * _profiles/<name>/mcp.json — the profile's MCP servers
7
+ * _profiles/<name>/models.json — per-subagent model routing, when the
8
+ * profile declares any; REMOVED when it
9
+ * does not
7
10
  * _profiles/<name>/skills/ — created empty; skills install into it
8
11
  * (through the activated symlink)
9
12
  *
@@ -109,6 +112,20 @@ export async function materializeProfile(
109
112
  await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
110
113
  spaces: 2,
111
114
  });
115
+
116
+ // models.json is the one artifact that is REMOVED when the closure lacks it.
117
+ // Every other file here is unconditional, so "overwrite" is enough; routing
118
+ // is optional, and a profile that dropped it would otherwise keep routing
119
+ // from a file nothing writes any more — `claudeup models off` could not turn
120
+ // routing off, because the hook walks up to the nearest models.json and this
121
+ // one would still be there (and still linked).
122
+ const modelsPath = path.join(dir, "models.json");
123
+ if (closure.models) {
124
+ await fs.writeJson(modelsPath, closure.models, { spaces: 2 });
125
+ } else {
126
+ await fs.remove(modelsPath);
127
+ }
128
+
112
129
  const skillsDir = path.join(dir, "skills");
113
130
  await seedSkillsFromProject(skillsDir, projectPath);
114
131
  await fs.ensureDir(skillsDir);
@@ -69,6 +69,13 @@ export function resolveExtends(
69
69
  // Entry values win over inherited ones.
70
70
  plugins: { ...inheritedPlugins, ...(entry.plugins ?? {}) },
71
71
  settings: { ...base.settings, ...(entry.settings ?? {}) },
72
+ // `models` inherits by whole object, not key-by-key: a ModelsConfig is
73
+ // validated as a complete unit (every grade present, one effort per
74
+ // model), so a half-inherited merge could produce a config neither side
75
+ // wrote and neither side validated.
76
+ ...((entry.models ?? base.models)
77
+ ? { models: entry.models ?? base.models }
78
+ : {}),
72
79
  };
73
80
  }
74
81
 
@@ -198,6 +205,7 @@ export async function resolveProfile(
198
205
  bins,
199
206
  skills: [...(entry.skills ?? [])],
200
207
  settings: { ...(entry.settings ?? {}) },
208
+ ...(entry.models ? { models: entry.models } : {}),
201
209
  env: {
202
210
  required: [...(entry.env?.required ?? [])],
203
211
  optional: [...(entry.env?.optional ?? [])],
@@ -294,6 +302,9 @@ function mergeClosures(
294
302
  bins: [...binByName.values()],
295
303
  skills: [...skillMap.values()],
296
304
  settings: { ...b.settings, ...a.settings },
305
+ // Same precedence as settings: `a` wins. Whole-object, for the reason
306
+ // given in resolveExtends — half a routing config is not a routing config.
307
+ ...((a.models ?? b.models) ? { models: a.models ?? b.models } : {}),
297
308
  env: {
298
309
  required: [...new Set([...a.env.required, ...b.env.required])],
299
310
  optional: [...new Set([...a.env.optional, ...b.env.optional])],