claudeup 6.3.2 → 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.
- package/package.json +4 -4
- package/src/__tests__/cli-live.test.ts +9 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gitignore-prerun.test.ts +6 -13
- 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__/manifest.test.ts +134 -0
- package/src/__tests__/model-visuals.test.tsx +789 -0
- package/src/__tests__/models-adapter.test.ts +317 -0
- package/src/__tests__/models-cli.test.ts +173 -0
- package/src/__tests__/models-core.test.ts +640 -0
- package/src/__tests__/models-manager.test.ts +497 -0
- package/src/__tests__/models-screen-state.test.ts +259 -0
- package/src/__tests__/profile-materializer.test.ts +46 -0
- package/src/__tests__/resolver.test.ts +36 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/symlink-manager.test.ts +65 -1
- package/src/__tests__/tabbar-layout.test.ts +40 -2
- package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
- package/src/cli/doctor.ts +90 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/models.ts +214 -0
- package/src/cli/router.ts +12 -0
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/models-presets.ts +281 -0
- package/src/data/predefined-profiles.ts +9 -0
- package/src/data/settings-catalog.ts +11 -4
- package/src/main.tsx +51 -82
- package/src/services/hook-registration.ts +218 -0
- package/src/services/manifest.ts +84 -0
- package/src/services/models-core.ts +628 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +11 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/styles-manager.ts +23 -45
- package/src/services/symlink-manager.ts +57 -11
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +21 -0
- package/src/types/index.ts +14 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/modelsAdapter.ts +170 -0
- package/src/ui/components/TabBar.tsx +9 -4
- package/src/ui/components/layout/FooterHints.tsx +20 -3
- package/src/ui/components/layout/ScreenLayout.tsx +87 -7
- package/src/ui/components/primitives/MetaText.tsx +27 -1
- package/src/ui/renderers/modelRenderers.tsx +1004 -0
- package/src/ui/renderers/modelVisuals.tsx +853 -0
- package/src/ui/renderers/skillRenderers.tsx +13 -3
- package/src/ui/renderers/styleRenderers.tsx +7 -3
- package/src/ui/screens/ModelsScreen.tsx +478 -0
- package/src/ui/screens/StylesScreen.tsx +8 -13
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +94 -0
- package/src/ui/state/types.ts +65 -2
- package/src/ui/theme-mode.ts +116 -0
- 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);
|
package/src/services/resolver.ts
CHANGED
|
@@ -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])],
|