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.
Files changed (58) 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 +36 -0
  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/cli/doctor.ts +90 -0
  22. package/src/cli/hook.ts +129 -0
  23. package/src/cli/models.ts +214 -0
  24. package/src/cli/router.ts +12 -0
  25. package/src/data/gitignore-defaults.ts +4 -0
  26. package/src/data/models-presets.ts +281 -0
  27. package/src/data/predefined-profiles.ts +9 -0
  28. package/src/data/settings-catalog.ts +11 -4
  29. package/src/main.tsx +51 -82
  30. package/src/services/hook-registration.ts +218 -0
  31. package/src/services/manifest.ts +84 -0
  32. package/src/services/models-core.ts +628 -0
  33. package/src/services/models-manager.ts +606 -0
  34. package/src/services/profile-materializer.ts +17 -0
  35. package/src/services/resolver.ts +11 -0
  36. package/src/services/settings-file.ts +69 -0
  37. package/src/services/styles-manager.ts +23 -45
  38. package/src/services/symlink-manager.ts +57 -11
  39. package/src/tui.tsx +112 -0
  40. package/src/types/bun.d.ts +21 -0
  41. package/src/types/index.ts +14 -0
  42. package/src/ui/App.tsx +15 -3
  43. package/src/ui/adapters/modelsAdapter.ts +170 -0
  44. package/src/ui/components/TabBar.tsx +9 -4
  45. package/src/ui/components/layout/FooterHints.tsx +20 -3
  46. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  47. package/src/ui/components/primitives/MetaText.tsx +27 -1
  48. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  49. package/src/ui/renderers/modelVisuals.tsx +853 -0
  50. package/src/ui/renderers/skillRenderers.tsx +13 -3
  51. package/src/ui/renderers/styleRenderers.tsx +7 -3
  52. package/src/ui/screens/ModelsScreen.tsx +478 -0
  53. package/src/ui/screens/StylesScreen.tsx +8 -13
  54. package/src/ui/screens/index.ts +1 -0
  55. package/src/ui/state/reducer.ts +94 -0
  56. package/src/ui/state/types.ts +65 -2
  57. package/src/ui/theme-mode.ts +116 -0
  58. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * hook-registration — putting `claudeup hook agent-model` into user settings.
3
+ *
4
+ * The pure functions are tested against plain objects; the writer is tested
5
+ * against a temp `CLAUDE_CONFIG_DIR`, never the operator's real one.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtemp, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import fs from "fs-extra";
12
+ import {
13
+ AGENT_MODEL_HOOK_COMMAND,
14
+ ensureHookRegistered,
15
+ isAgentModelHookRegistered,
16
+ isHookRegistered,
17
+ registerAgentModelHook,
18
+ removeHookRegistration,
19
+ } from "../services/hook-registration.js";
20
+
21
+ const ENTRY = {
22
+ type: "command",
23
+ command: AGENT_MODEL_HOOK_COMMAND,
24
+ timeout: 5,
25
+ };
26
+
27
+ describe("ensureHookRegistered", () => {
28
+ test("writes the measured shape: a PreToolUse block matching Agent", () => {
29
+ const { settings, changed } = ensureHookRegistered({});
30
+ expect(changed).toBe(true);
31
+ expect(settings).toEqual({
32
+ hooks: { PreToolUse: [{ matcher: "Agent", hooks: [ENTRY] }] },
33
+ });
34
+ });
35
+
36
+ // Identity is the command string, so re-running claudeup never accumulates
37
+ // duplicate registrations.
38
+ test("is idempotent", () => {
39
+ const first = ensureHookRegistered({});
40
+ const second = ensureHookRegistered(first.settings);
41
+ expect(second.changed).toBe(false);
42
+ expect(second.settings).toBe(first.settings);
43
+ });
44
+
45
+ // A second block with the same matcher works, but makes the user's settings
46
+ // progressively less readable and looks like a bug when they open it.
47
+ test("appends into an existing Agent block instead of adding a second one", () => {
48
+ const existing = {
49
+ hooks: {
50
+ PreToolUse: [
51
+ {
52
+ matcher: "Agent",
53
+ hooks: [{ type: "command", command: "my-own-audit-hook" }],
54
+ },
55
+ ],
56
+ },
57
+ };
58
+ const { settings } = ensureHookRegistered(existing);
59
+ const blocks = (settings.hooks as any).PreToolUse;
60
+ expect(blocks).toHaveLength(1);
61
+ expect(blocks[0].hooks).toEqual([
62
+ { type: "command", command: "my-own-audit-hook" },
63
+ ENTRY,
64
+ ]);
65
+ });
66
+
67
+ test("leaves other tools' hooks and other settings untouched", () => {
68
+ const existing = {
69
+ model: "opus",
70
+ hooks: {
71
+ PreToolUse: [
72
+ { matcher: "Bash", hooks: [{ type: "command", command: "guard" }] },
73
+ ],
74
+ Stop: [{ hooks: [{ type: "command", command: "chime" }] }],
75
+ },
76
+ };
77
+ const { settings } = ensureHookRegistered(existing);
78
+ expect(settings.model).toBe("opus");
79
+ expect((settings.hooks as any).Stop).toEqual(existing.hooks.Stop);
80
+ expect((settings.hooks as any).PreToolUse[0].matcher).toBe("Bash");
81
+ expect((settings.hooks as any).PreToolUse[1]).toEqual({
82
+ matcher: "Agent",
83
+ hooks: [ENTRY],
84
+ });
85
+ });
86
+
87
+ test("does not mutate the object it was given", () => {
88
+ const original = { hooks: { PreToolUse: [] } };
89
+ ensureHookRegistered(original);
90
+ expect(original).toEqual({ hooks: { PreToolUse: [] } });
91
+ });
92
+
93
+ // Never an absolute path: `claudeup upgrade` replaces the binary, and on npm
94
+ // the path carries a version directory. A recorded absolute path is dead
95
+ // after the next upgrade, and a hook that cannot start routes nothing with
96
+ // no error anyone sees.
97
+ test("registers the bare command, not a path", () => {
98
+ const { settings } = ensureHookRegistered({});
99
+ const command = (settings.hooks as any).PreToolUse[0].hooks[0].command;
100
+ expect(command).toBe("claudeup hook agent-model");
101
+ expect(command.includes("/")).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe("isHookRegistered", () => {
106
+ test("false for every shape that is not a hooks array", () => {
107
+ expect(isHookRegistered({})).toBe(false);
108
+ expect(isHookRegistered({ hooks: "nope" })).toBe(false);
109
+ expect(isHookRegistered({ hooks: { PreToolUse: "nope" } })).toBe(false);
110
+ expect(isHookRegistered({ hooks: { PreToolUse: [{}] } })).toBe(false);
111
+ });
112
+
113
+ test("finds the command wherever it sits", () => {
114
+ expect(
115
+ isHookRegistered({
116
+ hooks: {
117
+ PreToolUse: [
118
+ { matcher: "Bash", hooks: [{ command: "x" }] },
119
+ { matcher: "Agent", hooks: [{ command: "y" }, ENTRY] },
120
+ ],
121
+ },
122
+ }),
123
+ ).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe("removeHookRegistration", () => {
128
+ test("round-trips: register then remove leaves no trace", () => {
129
+ const { settings } = ensureHookRegistered({ model: "opus" });
130
+ const removed = removeHookRegistration(settings);
131
+ expect(removed.changed).toBe(true);
132
+ expect(removed.settings).toEqual({ model: "opus" });
133
+ });
134
+
135
+ test("keeps other commands in a shared Agent block", () => {
136
+ const { settings } = ensureHookRegistered({
137
+ hooks: {
138
+ PreToolUse: [{ matcher: "Agent", hooks: [{ command: "mine" }] }],
139
+ },
140
+ });
141
+ const removed = removeHookRegistration(settings);
142
+ expect((removed.settings.hooks as any).PreToolUse).toEqual([
143
+ { matcher: "Agent", hooks: [{ command: "mine" }] },
144
+ ]);
145
+ });
146
+
147
+ test("no-op when it was never registered", () => {
148
+ const before = { hooks: { Stop: [] } };
149
+ const removed = removeHookRegistration(before);
150
+ expect(removed.changed).toBe(false);
151
+ expect(removed.settings).toBe(before);
152
+ });
153
+ });
154
+
155
+ describe("registerAgentModelHook", () => {
156
+ let configDir: string;
157
+ let previous: string | undefined;
158
+
159
+ beforeEach(async () => {
160
+ configDir = await mkdtemp(join(tmpdir(), "hookreg-"));
161
+ previous = process.env.CLAUDE_CONFIG_DIR;
162
+ process.env.CLAUDE_CONFIG_DIR = configDir;
163
+ });
164
+ afterEach(async () => {
165
+ // biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
166
+ if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR;
167
+ else process.env.CLAUDE_CONFIG_DIR = previous;
168
+ await rm(configDir, { recursive: true, force: true });
169
+ });
170
+
171
+ const settingsPath = () => join(configDir, "settings.json");
172
+
173
+ test("honours CLAUDE_CONFIG_DIR and reports registered, then already", async () => {
174
+ expect(await registerAgentModelHook()).toBe("registered");
175
+ expect(await fs.pathExists(settingsPath())).toBe(true);
176
+ expect(await isAgentModelHookRegistered()).toBe(true);
177
+ expect(await registerAgentModelHook()).toBe("already");
178
+ });
179
+
180
+ test("an explicit configDir overrides the environment", async () => {
181
+ const other = await mkdtemp(join(tmpdir(), "hookreg-other-"));
182
+ try {
183
+ await registerAgentModelHook({ configDir: other });
184
+ expect(await fs.pathExists(join(other, "settings.json"))).toBe(true);
185
+ expect(await fs.pathExists(settingsPath())).toBe(false);
186
+ } finally {
187
+ await rm(other, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test("preserves every other key in the user's settings", async () => {
192
+ await fs.outputJson(settingsPath(), {
193
+ model: "sonnet",
194
+ enabledPlugins: { "dev@magus": true },
195
+ hooks: { SessionStart: [{ hooks: [{ command: "hello" }] }] },
196
+ });
197
+
198
+ await registerAgentModelHook();
199
+
200
+ const after = await fs.readJson(settingsPath());
201
+ expect(after.model).toBe("sonnet");
202
+ expect(after.enabledPlugins).toEqual({ "dev@magus": true });
203
+ expect(after.hooks.SessionStart).toEqual([
204
+ { hooks: [{ command: "hello" }] },
205
+ ]);
206
+ expect(after.hooks.PreToolUse[0].matcher).toBe("Agent");
207
+ });
208
+
209
+ // The reason this does NOT use readGlobalSettings/writeGlobalSettings: those
210
+ // swallow a parse error and return {}, so a settings file a human is
211
+ // mid-edit in would come back holding one hook and nothing else.
212
+ test("throws on unparseable user settings and leaves the file alone", async () => {
213
+ await fs.outputFile(settingsPath(), '{ "model": "opus", ');
214
+
215
+ await expect(registerAgentModelHook()).rejects.toThrow(/not valid JSON/);
216
+
217
+ expect(await fs.readFile(settingsPath(), "utf8")).toBe(
218
+ '{ "model": "opus", ',
219
+ );
220
+ // And the read-side reports "not registered" rather than throwing, so
221
+ // doctor/status can still say something useful about a broken file.
222
+ expect(await isAgentModelHookRegistered()).toBe(false);
223
+ });
224
+ });
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import fs from "fs-extra";
6
6
  import {
7
7
  emptyManifest,
8
+ mergeManifestSettings,
8
9
  migrateToV2,
9
10
  readManifest,
10
11
  validateManifest,
@@ -106,6 +107,139 @@ describe("validateManifest", () => {
106
107
  true,
107
108
  );
108
109
  });
110
+
111
+ // Model routing is the one field whose bad value does not degrade: MEASURED
112
+ // (benches/agent-model-routing `bogus-model`, 5/5), a model the Agent tool
113
+ // does not accept refuses the spawn outright, so every subagent dispatch
114
+ // fails for everyone who pulled the manifest. This is the gate.
115
+ test("a hand-edited models block is validated, path-prefixed by profile", () => {
116
+ const errors = validateManifest({
117
+ version: 2,
118
+ profiles: {
119
+ p: {
120
+ name: "P",
121
+ models: {
122
+ version: 1,
123
+ preset: "hand",
124
+ main: { model: "opus" },
125
+ grades: {
126
+ smart: { model: "claude-opus-4-6" },
127
+ normal: { model: "opus" },
128
+ cheap: { model: "sonnet" },
129
+ },
130
+ agents: {},
131
+ fallback: "normal",
132
+ },
133
+ },
134
+ },
135
+ } as unknown as ProfileManifest);
136
+
137
+ expect(
138
+ errors.some((e) => e.path === "profiles.p.models.grades.smart.model"),
139
+ ).toBe(true);
140
+ });
141
+
142
+ test("a valid models block reports nothing", () => {
143
+ const errors = validateManifest({
144
+ version: 2,
145
+ profiles: {
146
+ p: {
147
+ name: "P",
148
+ models: {
149
+ version: 1,
150
+ preset: "fable-advisor",
151
+ main: { model: "opus", effort: "medium" },
152
+ grades: {
153
+ smart: { model: "fable", effort: "xhigh" },
154
+ normal: { model: "opus", effort: "medium" },
155
+ cheap: { model: "sonnet", effort: "low" },
156
+ },
157
+ agents: { "dev:architect": "smart" },
158
+ fallback: "normal",
159
+ },
160
+ },
161
+ },
162
+ } as unknown as ProfileManifest);
163
+
164
+ expect(errors).toEqual([]);
165
+ });
166
+ });
167
+
168
+ describe("mergeManifestSettings", () => {
169
+ let dir: string;
170
+ beforeEach(async () => {
171
+ dir = await mkdtemp(join(tmpdir(), "manifest-merge-"));
172
+ });
173
+ afterEach(async () => {
174
+ await rm(dir, { recursive: true, force: true });
175
+ });
176
+
177
+ test("merges into the entry's settings, keeping what was there", async () => {
178
+ await writeManifest(
179
+ {
180
+ version: 2,
181
+ profiles: { p: { name: "P", settings: { outputStyle: "direct" } } },
182
+ },
183
+ dir,
184
+ );
185
+
186
+ expect(await mergeManifestSettings(dir, "p", { model: "opus" })).toBe(true);
187
+
188
+ const m = await readManifest(dir);
189
+ expect(m.profiles.p!.settings).toEqual({
190
+ outputStyle: "direct",
191
+ model: "opus",
192
+ });
193
+ expect(m.profiles.p!.updatedAt).toBeDefined();
194
+ });
195
+
196
+ test("unset removes keys outright, not as undefined", async () => {
197
+ await writeManifest(
198
+ {
199
+ version: 2,
200
+ profiles: { p: { name: "P", settings: { model: "opus", keep: 1 } } },
201
+ },
202
+ dir,
203
+ );
204
+
205
+ await mergeManifestSettings(dir, "p", {}, { unset: ["model"] });
206
+
207
+ const raw = await fs.readJson(join(dir, ".claude", "profiles.json"));
208
+ expect("model" in raw.profiles.p.settings).toBe(false);
209
+ expect(raw.profiles.p.settings.keep).toBe(1);
210
+ });
211
+
212
+ test("false, not an error, when there is nothing to record against", async () => {
213
+ expect(await mergeManifestSettings(dir, "p", { model: "opus" })).toBe(
214
+ false,
215
+ );
216
+ expect(await mergeManifestSettings(dir, null, { model: "opus" })).toBe(
217
+ false,
218
+ );
219
+
220
+ await writeManifest(
221
+ { version: 2, profiles: { other: { name: "O" } } },
222
+ dir,
223
+ );
224
+ expect(await mergeManifestSettings(dir, "p", { model: "opus" })).toBe(
225
+ false,
226
+ );
227
+ });
228
+
229
+ // readManifest degrades a corrupt file to an empty manifest. Reusing it here
230
+ // would record nothing and report success, leaving the caller's live setting
231
+ // to be reverted by the next install with no way to learn why.
232
+ test("throws on an unreadable manifest instead of silently doing nothing", async () => {
233
+ await fs.outputFile(join(dir, ".claude", "profiles.json"), "{ not json");
234
+
235
+ await expect(
236
+ mergeManifestSettings(dir, "p", { model: "opus" }),
237
+ ).rejects.toThrow(/not valid JSON/);
238
+
239
+ expect(
240
+ await fs.readFile(join(dir, ".claude", "profiles.json"), "utf8"),
241
+ ).toBe("{ not json");
242
+ });
109
243
  });
110
244
 
111
245
  describe("readManifest / writeManifest", () => {