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,317 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ BUILT_IN_PRESETS,
4
+ DEFAULT_PRESET,
5
+ findPreset,
6
+ } from "../data/models-presets.js";
7
+ import type { ModelsConfig, ModelsStatus } from "../services/models-core.js";
8
+ import {
9
+ type ModelsBrowserItem,
10
+ buildModelsItems,
11
+ firstSelectableIndex,
12
+ } from "../ui/adapters/modelsAdapter.js";
13
+
14
+ /**
15
+ * The adapter is PURE — no fs, no React, no clock. Every test here builds its
16
+ * inputs by hand, which is the whole reason the list shape is testable at all:
17
+ * the screen it feeds cannot be rendered without a terminal.
18
+ */
19
+
20
+ function status(over: Partial<ModelsStatus> = {}): ModelsStatus {
21
+ return {
22
+ state: "off",
23
+ preset: null,
24
+ drift: [],
25
+ warnings: [],
26
+ ...over,
27
+ };
28
+ }
29
+
30
+ /** A hand-edited config: a preset name no built-in carries. */
31
+ function customConfig(): ModelsConfig {
32
+ const base = findPreset(DEFAULT_PRESET);
33
+ if (!base) throw new Error("the default preset went missing");
34
+ return { ...base, preset: "custom" };
35
+ }
36
+
37
+ const presetRows = (items: ModelsBrowserItem[]) =>
38
+ items.filter((item) => item.kind === "preset");
39
+
40
+ describe("buildModelsItems shape", () => {
41
+ test("a status header first, then one row per shipped preset", () => {
42
+ const items = buildModelsItems({
43
+ status: status(),
44
+ config: null,
45
+ query: "",
46
+ });
47
+
48
+ expect(items[0]?.kind).toBe("status");
49
+ expect(items).toHaveLength(BUILT_IN_PRESETS.length + 1);
50
+ expect(presetRows(items).map((item) => item.config.preset)).toEqual(
51
+ BUILT_IN_PRESETS.map((preset) => preset.preset),
52
+ );
53
+ // Exactly one header. A second would be a section, and this list has none.
54
+ expect(items.filter((item) => item.kind === "status")).toHaveLength(1);
55
+ });
56
+
57
+ /**
58
+ * A row is NAMED for what you are choosing between, not for the id you type.
59
+ * `fable-advisor` only says which model leads if you already know `advisor` is a
60
+ * role in this vocabulary; "Opus with Fable help" says it outright.
61
+ */
62
+ test("a row reads as a human name, not as the config's id", () => {
63
+ const items = buildModelsItems({
64
+ presets: BUILT_IN_PRESETS,
65
+ status: status({ state: "off" }),
66
+ config: null,
67
+ query: "",
68
+ });
69
+
70
+ const row = presetRows(items).find(
71
+ (item) => item.config.preset === "fable-advisor",
72
+ );
73
+ expect(row?.label).toBe("Opus with Fable help");
74
+ // The id survives beside it — it is what `models use` takes.
75
+ expect(row?.config.preset).toBe("fable-advisor");
76
+ });
77
+
78
+ test("search finds a preset by either name", () => {
79
+ for (const query of ["fable-advisor", "Opus with Fable"]) {
80
+ const items = buildModelsItems({
81
+ presets: BUILT_IN_PRESETS,
82
+ status: status({ state: "off" }),
83
+ config: null,
84
+ query,
85
+ });
86
+ expect(presetRows(items).map((item) => item.config.preset)).toContain(
87
+ "fable-advisor",
88
+ );
89
+ }
90
+ });
91
+
92
+ test("no status means no list — the screen has nothing to say yet", () => {
93
+ expect(buildModelsItems({ status: null, config: null, query: "" })).toEqual(
94
+ [],
95
+ );
96
+ });
97
+
98
+ test("the header carries the counts its badge draws, not the lines", () => {
99
+ const items = buildModelsItems({
100
+ status: status({
101
+ state: "stale",
102
+ preset: "fable-advisor",
103
+ drift: ["model: settings has x, config wants y", "effortLevel: …"],
104
+ warnings: ["CLAUDE_CODE_SUBAGENT_MODEL is set"],
105
+ }),
106
+ config: findPreset("fable-advisor") ?? null,
107
+ query: "",
108
+ });
109
+
110
+ const header = items[0];
111
+ if (header?.kind !== "status") throw new Error("expected a status header");
112
+ expect(header.state).toBe("stale");
113
+ expect(header.preset).toBe("fable-advisor");
114
+ expect(header.driftCount).toBe(2);
115
+ expect(header.warningCount).toBe(1);
116
+ // The lines themselves stay on the ModelsStatus the detail renderer gets.
117
+ // Copying them onto the item is how the two end up disagreeing.
118
+ expect(Object.keys(header)).not.toContain("drift");
119
+ });
120
+
121
+ test("ids are unique, so the list's React keys cannot collide", () => {
122
+ const items = buildModelsItems({
123
+ status: status(),
124
+ config: customConfig(),
125
+ query: "",
126
+ });
127
+ expect(new Set(items.map((item) => item.id)).size).toBe(items.length);
128
+ });
129
+ });
130
+
131
+ describe("marking the active preset", () => {
132
+ test("the config's preset name is the one marked", () => {
133
+ const items = buildModelsItems({
134
+ status: status({ state: "on", preset: "opus-lead" }),
135
+ config: findPreset("opus-lead") ?? null,
136
+ query: "",
137
+ });
138
+
139
+ const active = presetRows(items).filter((item) => item.active);
140
+ expect(active.map((item) => item.config.preset)).toEqual(["opus-lead"]);
141
+ });
142
+
143
+ test("no config means nothing is marked", () => {
144
+ const items = buildModelsItems({
145
+ status: status(),
146
+ config: null,
147
+ query: "",
148
+ });
149
+ expect(presetRows(items).some((item) => item.active)).toBe(false);
150
+ });
151
+
152
+ test("the shipped default is flagged, and it is exactly one preset", () => {
153
+ const items = buildModelsItems({
154
+ status: status(),
155
+ config: null,
156
+ query: "",
157
+ });
158
+ const defaults = presetRows(items).filter((item) => item.isDefault);
159
+ expect(defaults.map((item) => item.config.preset)).toEqual([
160
+ DEFAULT_PRESET,
161
+ ]);
162
+ });
163
+
164
+ test("a hand-edited config gets a row of its own, marked active", () => {
165
+ // Without it the list shows four built-ins with none marked, which reads
166
+ // as "routing is off" for a project whose routing is emphatically on.
167
+ const config = customConfig();
168
+ const items = buildModelsItems({
169
+ status: status({ state: "on", preset: "custom" }),
170
+ config,
171
+ query: "",
172
+ });
173
+
174
+ const rows = presetRows(items);
175
+ expect(rows).toHaveLength(BUILT_IN_PRESETS.length + 1);
176
+ const mine = rows.at(-1);
177
+ expect(mine?.config.preset).toBe("custom");
178
+ expect(mine?.custom).toBe(true);
179
+ expect(mine?.active).toBe(true);
180
+ expect(mine?.isDefault).toBe(false);
181
+ // Its own config, so the detail pane shows what it actually routes.
182
+ expect(mine?.config).toBe(config);
183
+ // And it is the ONLY active row.
184
+ expect(rows.filter((item) => item.active)).toHaveLength(1);
185
+ });
186
+
187
+ test("a config naming a built-in adds no extra row", () => {
188
+ const items = buildModelsItems({
189
+ status: status({ state: "on", preset: "sonnet-economy" }),
190
+ config: findPreset("sonnet-economy") ?? null,
191
+ query: "",
192
+ });
193
+ expect(presetRows(items)).toHaveLength(BUILT_IN_PRESETS.length);
194
+ expect(presetRows(items).filter((item) => item.custom)).toEqual([]);
195
+ });
196
+ });
197
+
198
+ describe("filtering", () => {
199
+ test("matches on the preset name", () => {
200
+ const items = buildModelsItems({
201
+ status: status(),
202
+ config: null,
203
+ query: "economy",
204
+ });
205
+ expect(presetRows(items).map((item) => item.config.preset)).toEqual([
206
+ "sonnet-economy",
207
+ ]);
208
+ });
209
+
210
+ test("matches on a model the preset names, not just its title", () => {
211
+ // "sonnet" is what someone hunting for it would type, and `fable-advisor` names it in
212
+ // neither its id nor its label — only in the tier it routes there.
213
+ const items = buildModelsItems({
214
+ status: status(),
215
+ config: null,
216
+ query: "sonnet",
217
+ });
218
+ const labels = presetRows(items).map((item) => item.config.preset);
219
+ expect(labels.length).toBeGreaterThan(0);
220
+ expect(labels).toContain("fable-advisor");
221
+ });
222
+
223
+ test("a model no preset uses finds nothing", () => {
224
+ // The negative control for the test above: without it, a matcher that returned
225
+ // every row would pass it.
226
+ const items = buildModelsItems({
227
+ status: status(),
228
+ config: null,
229
+ query: "haiku",
230
+ });
231
+ expect(presetRows(items)).toEqual([]);
232
+ });
233
+
234
+ test("is case- and whitespace-insensitive", () => {
235
+ const items = buildModelsItems({
236
+ status: status(),
237
+ config: null,
238
+ query: " OPUS-Lead ",
239
+ });
240
+ expect(presetRows(items).map((item) => item.config.preset)).toEqual([
241
+ "opus-lead",
242
+ ]);
243
+ });
244
+
245
+ test("a query that matches nothing drops the header too", () => {
246
+ // A lone header reads as a result. Returning nothing is what lets the
247
+ // screen show the empty state, which is the only thing that says how to
248
+ // clear the filter.
249
+ expect(
250
+ buildModelsItems({
251
+ status: status(),
252
+ config: null,
253
+ query: "zzzz",
254
+ }),
255
+ ).toEqual([]);
256
+ });
257
+
258
+ test("a filtered-out custom config takes no row", () => {
259
+ const items = buildModelsItems({
260
+ status: status({ state: "on", preset: "custom" }),
261
+ config: customConfig(),
262
+ query: "economy",
263
+ });
264
+ expect(presetRows(items).map((item) => item.config.preset)).toEqual([
265
+ "sonnet-economy",
266
+ ]);
267
+ });
268
+ });
269
+
270
+ describe("firstSelectableIndex", () => {
271
+ test("skips the status header", () => {
272
+ const items = buildModelsItems({
273
+ status: status(),
274
+ config: null,
275
+ query: "",
276
+ });
277
+ expect(items[0]?.kind).toBe("status");
278
+ expect(firstSelectableIndex(items)).toBe(1);
279
+ });
280
+
281
+ test("an empty list answers 0 rather than -1", () => {
282
+ // -1 would index past the array and render an undefined row.
283
+ expect(firstSelectableIndex([])).toBe(0);
284
+ });
285
+
286
+ test("the index it returns is always a preset when one exists", () => {
287
+ for (const query of ["", "economy", "opus", "fable"]) {
288
+ const items = buildModelsItems({ status: status(), config: null, query });
289
+ if (items.length === 0) continue;
290
+ expect(items[firstSelectableIndex(items)]?.kind).toBe("preset");
291
+ }
292
+ });
293
+ });
294
+
295
+ describe("purity", () => {
296
+ test("the adapter reaches no filesystem and no clock", async () => {
297
+ // It runs on every render and every keystroke of the filter. Anything it
298
+ // touched would be touched at that rate.
299
+ const src = await Bun.file(
300
+ new URL("../ui/adapters/modelsAdapter.ts", import.meta.url),
301
+ ).text();
302
+ expect(src).not.toMatch(/from "node:fs/);
303
+ expect(src).not.toMatch(/\bfetch\s*\(/);
304
+ expect(src).not.toMatch(/Date\.now\(/);
305
+ });
306
+
307
+ test("building the list twice gives the same answer", () => {
308
+ const args = {
309
+ status: status({ state: "on", preset: "fable-advisor" }),
310
+ config: findPreset("fable-advisor") ?? null,
311
+ query: "",
312
+ };
313
+ expect(JSON.stringify(buildModelsItems(args))).toBe(
314
+ JSON.stringify(buildModelsItems(args)),
315
+ );
316
+ });
317
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `claudeup models` — exit codes and what it tells the user.
3
+ *
4
+ * The interesting one is the unknown preset: it must LIST the valid names and
5
+ * never near-match. `fable-lead` and `fable-advisor` differ by which model
6
+ * leads, so "did you mean" that silently picked the closer string would route
7
+ * every subagent differently from what was asked, and look like it worked.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import fs from "fs-extra";
14
+ import { runModelsCommand } from "../cli/models.js";
15
+ import { presetNames } from "../data/models-presets.js";
16
+ import { writeManifest } from "../services/manifest.js";
17
+
18
+ let logs: string[];
19
+ let errs: string[];
20
+ let project: string;
21
+ let configDir: string;
22
+ let previousConfigDir: string | undefined;
23
+
24
+ beforeEach(async () => {
25
+ logs = [];
26
+ errs = [];
27
+ console.log = mock((...a: unknown[]) => logs.push(a.join(" "))) as any;
28
+ console.error = mock((...a: unknown[]) => errs.push(a.join(" "))) as any;
29
+ project = await mkdtemp(join(tmpdir(), "modelscli-"));
30
+ configDir = await mkdtemp(join(tmpdir(), "modelscli-config-"));
31
+ previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
32
+ process.env.CLAUDE_CONFIG_DIR = configDir;
33
+ });
34
+
35
+ afterEach(async () => {
36
+ mock.restore();
37
+ // biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
38
+ if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
39
+ else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
40
+ await rm(project, { recursive: true, force: true });
41
+ await rm(configDir, { recursive: true, force: true });
42
+ });
43
+
44
+ /**
45
+ * Give the project a profile before applying a preset.
46
+ *
47
+ * Without one, `models use` reaches `ensureManifest`'s adoption branch, which asks
48
+ * "Create .claude/profiles.json?" — and that branch reads `process.stdin.isTTY`.
49
+ * A test that goes through it therefore passes headless (no TTY, so it refuses and
50
+ * returns) and HANGS in a real terminal, where stdin is a tty and the prompt waits
51
+ * for a human who is not there. Two tests did exactly that: green in CI, a 5 s
52
+ * timeout when the suite was run from a terminal.
53
+ *
54
+ * Adoption is `bootstrap`'s behaviour and is tested there. Every test here that
55
+ * applies a preset seeds the manifest first, so the outcome depends on the code
56
+ * under test rather than on how the runner was launched.
57
+ */
58
+ async function seedProfile(name = "team"): Promise<void> {
59
+ await writeManifest({ version: 2, profiles: { [name]: { name } } }, project);
60
+ const dir = join(project, ".claude", "_profiles", name);
61
+ await fs.outputJson(join(dir, "settings.json"), {});
62
+ await fs.symlink(
63
+ join("_profiles", name, "settings.json"),
64
+ join(project, ".claude", "settings.json"),
65
+ );
66
+ }
67
+
68
+ describe("models use", () => {
69
+ test("an unknown preset exits 1 and lists every valid name", async () => {
70
+ const code = await runModelsCommand(["use", "fable-leed"], project);
71
+
72
+ expect(code).toBe(1);
73
+ const out = errs.join("\n");
74
+ expect(out).toContain('Unknown preset "fable-leed"');
75
+ for (const name of presetNames()) expect(out).toContain(name);
76
+ // Never near-matched into an apply.
77
+ expect(await fs.pathExists(join(project, ".claude", "models.json"))).toBe(
78
+ false,
79
+ );
80
+ });
81
+
82
+ test("`use` with no preset exits 1 and lists the names", async () => {
83
+ const code = await runModelsCommand(["use"], project);
84
+ expect(code).toBe(1);
85
+ expect(errs.join("\n")).toContain(presetNames()[0] as string);
86
+ });
87
+
88
+ test("applies a valid preset and points at the file to commit", async () => {
89
+ await seedProfile();
90
+
91
+ const code = await runModelsCommand(["use", "sonnet-economy"], project);
92
+
93
+ expect(code).toBe(0);
94
+ const out = logs.join("\n");
95
+ expect(out).toContain('Applied preset "sonnet-economy"');
96
+ expect(out).toContain('model = "sonnet"');
97
+ expect(out).toContain("Commit .claude/profiles.json");
98
+ });
99
+ });
100
+
101
+ describe("models list", () => {
102
+ test("shows every preset with its main and grades", async () => {
103
+ const code = await runModelsCommand(["list"], project);
104
+ expect(code).toBe(0);
105
+ const out = logs.join("\n");
106
+ for (const name of presetNames()) expect(out).toContain(name);
107
+ expect(out).toContain("smart");
108
+ expect(out).toContain("(default)");
109
+ });
110
+
111
+ test("marks the active preset", async () => {
112
+ await seedProfile();
113
+ await runModelsCommand(["use", "opus-lead"], project);
114
+ logs = [];
115
+ await runModelsCommand(["list"], project);
116
+ expect(logs.join("\n")).toContain("● Opus main — opus-lead");
117
+ });
118
+ });
119
+
120
+ describe("models status", () => {
121
+ test("reports off for a project with no routing, and exits 0", async () => {
122
+ const code = await runModelsCommand(["status"], project);
123
+ expect(code).toBe(0);
124
+ expect(logs.join("\n")).toContain("off");
125
+ });
126
+
127
+ test("a bare `models` is status", async () => {
128
+ const code = await runModelsCommand([], project);
129
+ expect(code).toBe(0);
130
+ expect(logs.join("\n")).toContain("Model tiers:");
131
+ });
132
+
133
+ // An invalid config routes nothing, so it is a failure, not a report.
134
+ test("exits 1 on an invalid config and prints each error", async () => {
135
+ await fs.outputJson(join(project, ".claude", "models.json"), {
136
+ version: 1,
137
+ preset: "hand-edited",
138
+ main: { model: "gpt-5" },
139
+ });
140
+
141
+ const code = await runModelsCommand(["status"], project);
142
+
143
+ expect(code).toBe(1);
144
+ const out = logs.join("\n");
145
+ expect(out).toContain("invalid");
146
+ expect(out).toContain("main.model");
147
+ });
148
+ });
149
+
150
+ describe("models off", () => {
151
+ test("removes the config and says routing stopped", async () => {
152
+ await seedProfile();
153
+ await runModelsCommand(["use", "sonnet-economy"], project);
154
+ logs = [];
155
+
156
+ const code = await runModelsCommand(["off"], project);
157
+
158
+ expect(code).toBe(0);
159
+ expect(await fs.pathExists(join(project, ".claude", "models.json"))).toBe(
160
+ false,
161
+ );
162
+ expect(logs.join("\n")).toContain("Model tiers off");
163
+ });
164
+ });
165
+
166
+ describe("unknown subcommand", () => {
167
+ test("exits 1 and prints usage", async () => {
168
+ const code = await runModelsCommand(["frobnicate"], project);
169
+ expect(code).toBe(1);
170
+ expect(errs.join("\n")).toContain('Unknown models command "frobnicate"');
171
+ expect(logs.join("\n")).toContain("Usage: claudeup models");
172
+ });
173
+ });