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,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", () => {
|