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.
- 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 +38 -2
- 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/__tests__/version-snapshot.test.ts +2 -4
- 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 +16 -7
- 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
|
@@ -4,13 +4,18 @@ import { TABS, barWidth, layoutTabs } from "../ui/components/TabBar.js";
|
|
|
4
4
|
const FULL = TABS.map((t) => `${t.key}:${t.label}`);
|
|
5
5
|
const FULL_WIDTH = barWidth(FULL);
|
|
6
6
|
|
|
7
|
+
/** The widest the compacted bar ever gets — whichever active label is longest. */
|
|
8
|
+
const WIDEST_COMPACT = Math.max(
|
|
9
|
+
...TABS.map((tab) => barWidth(layoutTabs(TABS, tab.screen, 0))),
|
|
10
|
+
);
|
|
11
|
+
|
|
7
12
|
describe("tab bar layout", () => {
|
|
8
13
|
test("shows every label when the bar fits", () => {
|
|
9
14
|
expect(layoutTabs(TABS, "plugins", FULL_WIDTH)).toEqual(FULL);
|
|
10
15
|
});
|
|
11
16
|
|
|
12
17
|
test("drops inactive labels when the bar does not fit", () => {
|
|
13
|
-
// A 94-column pane leaves ~90 usable, and
|
|
18
|
+
// A 94-column pane leaves ~90 usable, and ten full tabs need ~110 — the
|
|
14
19
|
// width at which OpenTUI clipped each cell into a meaningless stub.
|
|
15
20
|
const texts = layoutTabs(TABS, "styles", 90);
|
|
16
21
|
expect(texts).toContain("9:Styles");
|
|
@@ -24,6 +29,26 @@ describe("tab bar layout", () => {
|
|
|
24
29
|
expect(barWidth(layoutTabs(TABS, "styles", 90))).toBeLessThanOrEqual(90);
|
|
25
30
|
});
|
|
26
31
|
|
|
32
|
+
test("the full bar no longer fits a standard window, so compacting is the norm", () => {
|
|
33
|
+
// The tenth tab pushed the full bar past 100 columns. That is not a
|
|
34
|
+
// regression — it is why the compact form has to stay readable, and why
|
|
35
|
+
// the test below measures it rather than assuming.
|
|
36
|
+
expect(FULL_WIDTH).toBeGreaterThan(100);
|
|
37
|
+
expect(layoutTabs(TABS, "models", 100)).not.toEqual(FULL);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the compact bar fits an 80-column terminal, on every tab", () => {
|
|
41
|
+
// The real question a tenth tab raises. ScreenLayout pads the bar by 1
|
|
42
|
+
// each side inside a container padded by 1, so an 80-column terminal
|
|
43
|
+
// leaves 76 — and the widest compact bar is well under it.
|
|
44
|
+
expect(WIDEST_COMPACT).toBeLessThanOrEqual(76);
|
|
45
|
+
for (const tab of TABS) {
|
|
46
|
+
expect(barWidth(layoutTabs(TABS, tab.screen, 76))).toBeLessThanOrEqual(
|
|
47
|
+
76,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
27
52
|
test("keeps the label of whichever tab is active", () => {
|
|
28
53
|
for (const tab of TABS) {
|
|
29
54
|
const texts = layoutTabs(TABS, tab.screen, 40);
|
|
@@ -46,7 +71,9 @@ describe("tab bar layout", () => {
|
|
|
46
71
|
|
|
47
72
|
test("every tab key is a digit the global handler binds", () => {
|
|
48
73
|
// The compact bar shows only the number, so the number must be the key
|
|
49
|
-
// that actually navigates.
|
|
74
|
+
// that actually navigates. Models is keyed "0" and sits last, because the
|
|
75
|
+
// number row reads 1…9 then 0 — the order here and the order under the
|
|
76
|
+
// fingers have to agree.
|
|
50
77
|
expect(TABS.map((t) => t.key)).toEqual([
|
|
51
78
|
"1",
|
|
52
79
|
"2",
|
|
@@ -57,6 +84,17 @@ describe("tab bar layout", () => {
|
|
|
57
84
|
"7",
|
|
58
85
|
"8",
|
|
59
86
|
"9",
|
|
87
|
+
"0",
|
|
60
88
|
]);
|
|
89
|
+
expect(TABS.at(-1)).toEqual({
|
|
90
|
+
key: "0",
|
|
91
|
+
label: "Models",
|
|
92
|
+
screen: "models",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("no two tabs share a key or a screen", () => {
|
|
97
|
+
expect(new Set(TABS.map((t) => t.key)).size).toBe(TABS.length);
|
|
98
|
+
expect(new Set(TABS.map((t) => t.screen)).size).toBe(TABS.length);
|
|
61
99
|
});
|
|
62
100
|
});
|
|
@@ -96,7 +96,18 @@ describe("contrast maths", () => {
|
|
|
96
96
|
|
|
97
97
|
// ─── The palette ──────────────────────────────────────────────────────────────
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Inks we paint on a fill WE own, so they are measured against that fill and
|
|
101
|
+
* not against the terminal. They cannot satisfy the both-backgrounds rule and
|
|
102
|
+
* are not supposed to: `selectionDim` is a light grey chosen for the selection
|
|
103
|
+
* purple, which on a cream terminal would be invisible — and is never drawn
|
|
104
|
+
* there.
|
|
105
|
+
*/
|
|
106
|
+
const OWN_FILL_INKS = new Set(["ink", "selectionDim"]);
|
|
107
|
+
|
|
108
|
+
const ACCENTS = Object.entries(brand).filter(
|
|
109
|
+
([name]) => !OWN_FILL_INKS.has(name),
|
|
110
|
+
);
|
|
100
111
|
|
|
101
112
|
describe("accents are legible on light AND dark terminals", () => {
|
|
102
113
|
test("the palette is non-empty (guards the loops below)", () => {
|
|
@@ -131,6 +142,42 @@ describe("accents are legible on light AND dark terminals", () => {
|
|
|
131
142
|
expect(failures).toEqual([]);
|
|
132
143
|
});
|
|
133
144
|
|
|
145
|
+
test("a SELECTED row has two legible inks, and the page palette is not among them", () => {
|
|
146
|
+
// Found on a screenshot: `(default)` was drawn in `theme.colors.muted` on
|
|
147
|
+
// the selection purple and simply was not there. Every page-level tone
|
|
148
|
+
// fails the same way — they are chosen against the terminal's background,
|
|
149
|
+
// and the selection fill is neither of the two references.
|
|
150
|
+
const bg = theme.selection.bg;
|
|
151
|
+
expect(contrastRatio(theme.selection.fg, bg)).toBeGreaterThanOrEqual(
|
|
152
|
+
INK_CONTRAST,
|
|
153
|
+
);
|
|
154
|
+
expect(contrastRatio(theme.selection.dim, bg)).toBeGreaterThanOrEqual(
|
|
155
|
+
UI_CONTRAST,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// And it must READ as secondary next to the primary ink, or the
|
|
159
|
+
// distinction it exists to make is not visible.
|
|
160
|
+
expect(
|
|
161
|
+
contrastRatio(theme.selection.dim, theme.selection.fg),
|
|
162
|
+
).toBeGreaterThan(1.2);
|
|
163
|
+
expect(contrastRatio(theme.selection.dim, bg)).toBeLessThan(
|
|
164
|
+
contrastRatio(theme.selection.fg, bg),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("the meta tones are exactly why a selected row cannot use them", () => {
|
|
169
|
+
// The negative half of the rule above, kept measurable: if a future
|
|
170
|
+
// palette change made one of these legible on the selection, the
|
|
171
|
+
// selection-aware branch in MetaText would still be right, but this test
|
|
172
|
+
// would tell us the reasoning had changed.
|
|
173
|
+
const unreadable = Object.entries(theme.meta).filter(
|
|
174
|
+
([, hex]) => contrastRatio(hex, theme.selection.bg) < UI_CONTRAST,
|
|
175
|
+
);
|
|
176
|
+
expect(unreadable.map(([name]) => name).sort()).toEqual(
|
|
177
|
+
Object.keys(theme.meta).sort(),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
134
181
|
test("the three scope colours are perceptually far apart", () => {
|
|
135
182
|
// They render as single-character squares, where a subtle shift is not
|
|
136
183
|
// readable at all. Deferring to ANSI put project and local on slots 2 and 3,
|
|
@@ -150,12 +150,10 @@ describe("per-project baselines", () => {
|
|
|
150
150
|
|
|
151
151
|
test("a project with no baseline of its own reports nothing", async () => {
|
|
152
152
|
await withSandbox(async () => {
|
|
153
|
-
await saveSeenVersions("/proj/a", { "
|
|
153
|
+
await saveSeenVersions("/proj/a", { "setup@magus": "2.2.0" });
|
|
154
154
|
const baseline = await loadSeenVersions("/proj/b");
|
|
155
155
|
expect(baseline).toBeNull();
|
|
156
|
-
expect(diffVersions(baseline, { "
|
|
157
|
-
[],
|
|
158
|
-
);
|
|
156
|
+
expect(diffVersions(baseline, { "setup@magus": "2.5.0" })).toEqual([]);
|
|
159
157
|
});
|
|
160
158
|
});
|
|
161
159
|
|
package/src/cli/doctor.ts
CHANGED
|
@@ -6,15 +6,26 @@
|
|
|
6
6
|
* This is the check that would have caught a dead `tmux-mcp`.
|
|
7
7
|
* 2. Profile symlink integrity — the active profile's links resolve.
|
|
8
8
|
* 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
|
|
9
|
+
* 4. Model routing — config validity, hook registration, and drift.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import fs from "fs-extra";
|
|
13
14
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
14
15
|
import { checkProject, fixDoctorIssues } from "../services/doctor.js";
|
|
16
|
+
import {
|
|
17
|
+
isAgentModelHookRegistered,
|
|
18
|
+
registerAgentModelHook,
|
|
19
|
+
} from "../services/hook-registration.js";
|
|
15
20
|
import { readManifest } from "../services/manifest.js";
|
|
21
|
+
import {
|
|
22
|
+
readModelsConfig,
|
|
23
|
+
readModelsStatus,
|
|
24
|
+
reapplyModels,
|
|
25
|
+
} from "../services/models-manager.js";
|
|
16
26
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
17
27
|
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
28
|
+
import { resolveExecutable } from "../utils/command-utils.js";
|
|
18
29
|
|
|
19
30
|
export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
20
31
|
const fix = args.includes("--fix");
|
|
@@ -98,10 +109,89 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
|
98
109
|
}
|
|
99
110
|
}
|
|
100
111
|
|
|
112
|
+
// 4. Model routing.
|
|
113
|
+
problems += await checkModels(projectPath, fix);
|
|
114
|
+
|
|
101
115
|
if (problems === 0) console.log("\n✓ No problems found.");
|
|
102
116
|
return problems > 0 && !fix ? 1 : 0;
|
|
103
117
|
}
|
|
104
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Model routing health. Silent when the project has no routing at all — an
|
|
121
|
+
* absent `models.json` is the normal state, not a finding.
|
|
122
|
+
*
|
|
123
|
+
* Ordered by what blocks what: an invalid config routes nothing and cannot be
|
|
124
|
+
* fixed from here (the values are a human's decision); an unregistered hook
|
|
125
|
+
* routes nothing no matter how good the config is; drift only matters once
|
|
126
|
+
* both of those are sound.
|
|
127
|
+
*/
|
|
128
|
+
async function checkModels(projectPath: string, fix: boolean): Promise<number> {
|
|
129
|
+
const { config, errors, path: file } = await readModelsConfig(projectPath);
|
|
130
|
+
if (config === null && errors.length === 0) return 0;
|
|
131
|
+
|
|
132
|
+
console.log("\nModel routing:");
|
|
133
|
+
let problems = 0;
|
|
134
|
+
|
|
135
|
+
if (errors.length > 0) {
|
|
136
|
+
console.log(` ✗ ${file} is invalid — nothing is routed:`);
|
|
137
|
+
for (const e of errors) {
|
|
138
|
+
console.log(` ${e.path || "(root)"}: ${e.message}`);
|
|
139
|
+
}
|
|
140
|
+
console.log(
|
|
141
|
+
" Not auto-fixable: these are choices, not damage. Edit the file (or",
|
|
142
|
+
);
|
|
143
|
+
console.log(" re-run `claudeup models use <preset>`) and check again.");
|
|
144
|
+
// An invalid config is worth exactly one problem, not one per message —
|
|
145
|
+
// the count drives the exit code, not the severity.
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The hook is what applies the routing. Without it the config is inert.
|
|
150
|
+
if (await isAgentModelHookRegistered()) {
|
|
151
|
+
console.log(" ✓ agent-model hook registered at user scope.");
|
|
152
|
+
} else if (fix) {
|
|
153
|
+
await registerAgentModelHook();
|
|
154
|
+
console.log(" ✓ agent-model hook registered at user scope (fixed).");
|
|
155
|
+
} else {
|
|
156
|
+
console.log(
|
|
157
|
+
" ✗ agent-model hook is not registered — the config is there, but nothing runs it.",
|
|
158
|
+
);
|
|
159
|
+
console.log(" Fix: claudeup doctor --fix");
|
|
160
|
+
problems++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The hook runs as a bare `claudeup`, deliberately (an absolute path dies on
|
|
164
|
+
// the next upgrade) — so PATH has to carry it. Warn only: PATH inside a
|
|
165
|
+
// Claude Code session is not necessarily PATH here.
|
|
166
|
+
if (!(await resolveExecutable("claudeup"))) {
|
|
167
|
+
console.log(
|
|
168
|
+
" • `claudeup` is not on PATH in this shell. The hook invokes it by name,",
|
|
169
|
+
);
|
|
170
|
+
console.log(
|
|
171
|
+
" so it must resolve in the shell Claude Code spawns hooks from.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const status = await readModelsStatus(projectPath);
|
|
176
|
+
if (status.state === "stale") {
|
|
177
|
+
console.log(" ✗ settings have drifted from the config:");
|
|
178
|
+
for (const line of status.drift) console.log(` ${line}`);
|
|
179
|
+
if (fix) {
|
|
180
|
+
await reapplyModels(projectPath);
|
|
181
|
+
console.log(" Re-applied the config (fixed).");
|
|
182
|
+
} else {
|
|
183
|
+
console.log(" Fix: claudeup doctor --fix");
|
|
184
|
+
problems++;
|
|
185
|
+
}
|
|
186
|
+
} else if (status.state === "on") {
|
|
187
|
+
console.log(` ✓ preset "${status.preset}" applied, no drift.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const warning of status.warnings) console.log(` ⚠ ${warning}`);
|
|
191
|
+
|
|
192
|
+
return problems;
|
|
193
|
+
}
|
|
194
|
+
|
|
105
195
|
/** Which of the active profile's expected symlinks are missing or dangling. */
|
|
106
196
|
async function danglingLinks(
|
|
107
197
|
name: string,
|
package/src/cli/hook.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claudeup hook agent-model` — the PreToolUse hook Claude Code runs on every Agent call.
|
|
3
|
+
*
|
|
4
|
+
* This is the hottest path claudeup has: Claude Code spawns it once per subagent dispatch, in
|
|
5
|
+
* every session on the machine, and waits for it. Two consequences shape the whole file.
|
|
6
|
+
*
|
|
7
|
+
* IT MUST BE FAST. `main.tsx` routes here before it loads the project's `.env` and before it
|
|
8
|
+
* imports the router or the TUI, so nothing in this module may import `src/ui/` or anything
|
|
9
|
+
* that pulls in `@opentui`. `src/__tests__/hook-import-policy.test.ts` fails the build if that
|
|
10
|
+
* ever regresses. Measured: 0.02s on this path against 0.16s once the router is loaded.
|
|
11
|
+
*
|
|
12
|
+
* IT MUST FAIL OPEN. Every error path exits 0 with empty stdout, which Claude Code reads as
|
|
13
|
+
* "no opinion". A hook that throws, blocks, or exits non-zero takes the user's subagents with
|
|
14
|
+
* it, and the failure would surface as delegation mysteriously not working — in every project,
|
|
15
|
+
* not just the one with the bad config. Silence is always the safe answer, so it is the
|
|
16
|
+
* default and the fallback.
|
|
17
|
+
*
|
|
18
|
+
* It never writes a file, never exits 2, and never reads a `.env`.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join, parse } from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
type ModelsConfig,
|
|
24
|
+
evaluateAgentHook,
|
|
25
|
+
validateModelsConfig,
|
|
26
|
+
} from "../services/models-core.js";
|
|
27
|
+
|
|
28
|
+
/** How far up the tree to look for a config before giving up. */
|
|
29
|
+
const MAX_WALK_UP = 32;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Find the nearest `.claude/models.json` at or above `startDir`.
|
|
33
|
+
*
|
|
34
|
+
* The walk matters: a session's cwd is frequently a subdirectory of the project, and this repo
|
|
35
|
+
* in particular runs most work inside git worktrees. Anchoring on the hook payload's `cwd`
|
|
36
|
+
* rather than `process.cwd()` is deliberate — Claude Code tells us where the session is, and
|
|
37
|
+
* that is not necessarily where this process was started.
|
|
38
|
+
*/
|
|
39
|
+
export function findModelsConfig(startDir: string): string | null {
|
|
40
|
+
let dir = startDir;
|
|
41
|
+
const root = parse(dir).root;
|
|
42
|
+
for (let i = 0; i < MAX_WALK_UP; i += 1) {
|
|
43
|
+
const candidate = join(dir, ".claude", "models.json");
|
|
44
|
+
if (existsSync(candidate)) return candidate;
|
|
45
|
+
if (dir === root) return null;
|
|
46
|
+
const parent = dirname(dir);
|
|
47
|
+
if (parent === dir) return null;
|
|
48
|
+
dir = parent;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read and validate a config. Returns null for every unusable state — missing, unreadable,
|
|
55
|
+
* unparseable, or invalid — because the hook's answer to all four is the same: do nothing.
|
|
56
|
+
*
|
|
57
|
+
* An invalid config is NOT an error here even though it is one everywhere else. `models use`
|
|
58
|
+
* refuses to write one, `claudeup install` refuses to apply one, and `doctor` reports one; by
|
|
59
|
+
* the time a session is running, the only useful behaviour left is to get out of the way.
|
|
60
|
+
*/
|
|
61
|
+
export function loadConfig(path: string): ModelsConfig | null {
|
|
62
|
+
let raw: unknown;
|
|
63
|
+
try {
|
|
64
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (validateModelsConfig(raw).length > 0) return null;
|
|
69
|
+
return raw as ModelsConfig;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readStdin(): Promise<string> {
|
|
73
|
+
// A hook always receives its payload on stdin. Guard the TTY case anyway so that a human
|
|
74
|
+
// typing the subcommand by hand gets an immediate no-op instead of a hang.
|
|
75
|
+
if (process.stdin.isTTY) return "";
|
|
76
|
+
const chunks: Uint8Array[] = [];
|
|
77
|
+
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
|
78
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runHookCommand(args: string[]): Promise<number> {
|
|
82
|
+
// `hook` with no recognised name is a no-op rather than a usage error: this process is
|
|
83
|
+
// wired into someone's session, and printing to stdout would be interpreted as a decision.
|
|
84
|
+
if (args[0] !== "agent-model") {
|
|
85
|
+
await readStdin();
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const raw = await readStdin();
|
|
91
|
+
if (raw.trim().length === 0) return 0;
|
|
92
|
+
|
|
93
|
+
let payload: Record<string, unknown>;
|
|
94
|
+
try {
|
|
95
|
+
payload = JSON.parse(raw) as Record<string, unknown>;
|
|
96
|
+
} catch {
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `cwd` is the session's directory, which is not this process's cwd.
|
|
101
|
+
const cwd =
|
|
102
|
+
typeof payload.cwd === "string" && payload.cwd.length > 0
|
|
103
|
+
? payload.cwd
|
|
104
|
+
: process.cwd();
|
|
105
|
+
const configPath = findModelsConfig(cwd);
|
|
106
|
+
const config = configPath === null ? null : loadConfig(configPath);
|
|
107
|
+
|
|
108
|
+
const decision = evaluateAgentHook(
|
|
109
|
+
{
|
|
110
|
+
tool_name:
|
|
111
|
+
typeof payload.tool_name === "string" ? payload.tool_name : undefined,
|
|
112
|
+
tool_input:
|
|
113
|
+
typeof payload.tool_input === "object" && payload.tool_input !== null
|
|
114
|
+
? (payload.tool_input as Record<string, unknown>)
|
|
115
|
+
: undefined,
|
|
116
|
+
cwd,
|
|
117
|
+
},
|
|
118
|
+
config,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (decision.kind === "update")
|
|
122
|
+
process.stdout.write(JSON.stringify(decision.output));
|
|
123
|
+
return 0;
|
|
124
|
+
} catch {
|
|
125
|
+
// Belt and braces. Nothing above should throw, and if it ever does the session must
|
|
126
|
+
// not notice.
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -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,6 +14,10 @@ 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/",
|