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,259 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import { DEFAULT_PRESET, findPreset } from "../data/models-presets.js";
|
|
5
|
+
import type { ModelsStatus } from "../services/models-core.js";
|
|
6
|
+
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
7
|
+
import type { AppAction, AppState, ModelsSnapshot } from "../ui/state/types.js";
|
|
8
|
+
import { asyncValue } from "../ui/state/types.js";
|
|
9
|
+
|
|
10
|
+
function run(actions: AppAction[], from: AppState = initialState): AppState {
|
|
11
|
+
return actions.reduce(appReducer, from);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function status(over: Partial<ModelsStatus> = {}): ModelsStatus {
|
|
15
|
+
return { state: "off", preset: null, drift: [], warnings: [], ...over };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function snapshot(over: Partial<ModelsSnapshot> = {}): ModelsSnapshot {
|
|
19
|
+
return {
|
|
20
|
+
status: status(),
|
|
21
|
+
config: null,
|
|
22
|
+
path: "/x/.claude/models.json",
|
|
23
|
+
...over,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Navigating away and back is what unmounts the screen. */
|
|
28
|
+
const roundTrip: AppAction[] = [
|
|
29
|
+
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
30
|
+
{ type: "NAVIGATE", route: { screen: "models" } },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
describe("models screen initial state", () => {
|
|
34
|
+
test("starts idle, unfiltered, and with no message", () => {
|
|
35
|
+
expect(initialState.models.data.status).toBe("idle");
|
|
36
|
+
expect(initialState.models.searchQuery).toBe("");
|
|
37
|
+
expect(initialState.models.status).toBeNull();
|
|
38
|
+
expect(initialState.models.isApplying).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the models route is reachable and NAVIGATE keeps the screen's state", () => {
|
|
42
|
+
const state = run([
|
|
43
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
44
|
+
...roundTrip,
|
|
45
|
+
]);
|
|
46
|
+
expect(state.currentRoute.screen).toBe("models");
|
|
47
|
+
expect(state.models.data.status).toBe("success");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("models search state", () => {
|
|
52
|
+
test("appending builds up the query one character at a time", () => {
|
|
53
|
+
// The regression this guards: computing `searchQuery + char` in the screen
|
|
54
|
+
// reads a value captured at render time, so keystrokes arriving faster
|
|
55
|
+
// than React re-renders all start from the same stale string.
|
|
56
|
+
const state = run([
|
|
57
|
+
{ type: "MODELS_SEARCH_APPEND", char: "o" },
|
|
58
|
+
{ type: "MODELS_SEARCH_APPEND", char: "p" },
|
|
59
|
+
{ type: "MODELS_SEARCH_APPEND", char: "u" },
|
|
60
|
+
{ type: "MODELS_SEARCH_APPEND", char: "s" },
|
|
61
|
+
]);
|
|
62
|
+
expect(state.models.searchQuery).toBe("opus");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("appending resets the cursor so it cannot sit past the filtered list", () => {
|
|
66
|
+
const state = run([
|
|
67
|
+
{ type: "MODELS_SELECT", index: 4 },
|
|
68
|
+
{ type: "MODELS_SEARCH_APPEND", char: "h" },
|
|
69
|
+
]);
|
|
70
|
+
expect(state.models.selectedIndex).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("backspace removes one character at a time", () => {
|
|
74
|
+
const typed = run([
|
|
75
|
+
{ type: "MODELS_SEARCH_APPEND", char: "a" },
|
|
76
|
+
{ type: "MODELS_SEARCH_APPEND", char: "b" },
|
|
77
|
+
{ type: "MODELS_SEARCH_APPEND", char: "c" },
|
|
78
|
+
]);
|
|
79
|
+
expect(
|
|
80
|
+
run([{ type: "MODELS_SEARCH_BACKSPACE" }], typed).models.searchQuery,
|
|
81
|
+
).toBe("ab");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("backspace on an empty query is harmless", () => {
|
|
85
|
+
expect(run([{ type: "MODELS_SEARCH_BACKSPACE" }]).models.searchQuery).toBe(
|
|
86
|
+
"",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("clearing the filter is one action, and it leaves the data alone", () => {
|
|
91
|
+
const filtered = run([
|
|
92
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
93
|
+
{ type: "MODELS_SEARCH_APPEND", char: "z" },
|
|
94
|
+
]);
|
|
95
|
+
const cleared = run([{ type: "MODELS_SET_SEARCH", query: "" }], filtered);
|
|
96
|
+
expect(cleared.models.searchQuery).toBe("");
|
|
97
|
+
expect(cleared.models.data.status).toBe("success");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("models data state", () => {
|
|
102
|
+
test("a reload keeps the last snapshot on screen", () => {
|
|
103
|
+
// Reading the routing scans session transcripts to resolve the live model
|
|
104
|
+
// id behind each alias, so a reload is not instant. Without `previous` the
|
|
105
|
+
// list blanks to "Reading…" every time — including on every tab switch,
|
|
106
|
+
// because the router unmounts the screen and the effect refetches.
|
|
107
|
+
const loaded = run([
|
|
108
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot({ path: "/a" }) },
|
|
109
|
+
{ type: "MODELS_DATA_LOADING" },
|
|
110
|
+
]);
|
|
111
|
+
expect(loaded.models.data.status).toBe("loading");
|
|
112
|
+
expect(asyncValue(loaded.models.data)?.path).toBe("/a");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a FIRST load has nothing to carry, and says so", () => {
|
|
116
|
+
const loading = run([{ type: "MODELS_DATA_LOADING" }]);
|
|
117
|
+
expect(asyncValue(loading.models.data)).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("success stores the whole snapshot, config included", () => {
|
|
121
|
+
const config = findPreset(DEFAULT_PRESET) ?? null;
|
|
122
|
+
const state = run([
|
|
123
|
+
{
|
|
124
|
+
type: "MODELS_DATA_SUCCESS",
|
|
125
|
+
snapshot: snapshot({
|
|
126
|
+
status: status({ state: "on", preset: DEFAULT_PRESET }),
|
|
127
|
+
config,
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
const value = asyncValue(state.models.data);
|
|
132
|
+
expect(value?.status.state).toBe("on");
|
|
133
|
+
expect(value?.config?.preset).toBe(DEFAULT_PRESET);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an error replaces the data rather than hiding behind stale rows", () => {
|
|
137
|
+
const state = run([
|
|
138
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
139
|
+
{ type: "MODELS_DATA_ERROR", error: new Error("unreadable") },
|
|
140
|
+
]);
|
|
141
|
+
expect(state.models.data.status).toBe("error");
|
|
142
|
+
expect(asyncValue(state.models.data)).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("models status line", () => {
|
|
147
|
+
test("survives the screen unmounting, because it lives in app state", () => {
|
|
148
|
+
// `Router` swaps the component type on a tab change, so a message held in
|
|
149
|
+
// the screen's own useState is destroyed by 0 → 1 → 0 — and the message is
|
|
150
|
+
// the only record of what the last apply did.
|
|
151
|
+
const messaged = run([
|
|
152
|
+
{
|
|
153
|
+
type: "MODELS_STATUS_SET",
|
|
154
|
+
status: { text: "Routed opus-lead", tone: "success" },
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
expect(run(roundTrip, messaged).models.status?.text).toBe(
|
|
158
|
+
"Routed opus-lead",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("is replaced by the next message, not appended to", () => {
|
|
163
|
+
const state = run([
|
|
164
|
+
{ type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
|
|
165
|
+
{ type: "MODELS_STATUS_SET", status: { text: "two", tone: "error" } },
|
|
166
|
+
]);
|
|
167
|
+
expect(state.models.status).toEqual({ text: "two", tone: "error" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("clearing removes it", () => {
|
|
171
|
+
const state = run([
|
|
172
|
+
{ type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
|
|
173
|
+
{ type: "MODELS_STATUS_CLEAR" },
|
|
174
|
+
]);
|
|
175
|
+
expect(state.models.status).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("apply-in-flight state", () => {
|
|
180
|
+
test("survives the screen unmounting", () => {
|
|
181
|
+
// An apply rewrites the profile, re-materializes it and registers a hook.
|
|
182
|
+
// Held locally, the flag died on a tab switch and a returning user could
|
|
183
|
+
// launch a second write on top of the first.
|
|
184
|
+
const applying = run([{ type: "MODELS_APPLY_START" }]);
|
|
185
|
+
expect(applying.models.isApplying).toBe(true);
|
|
186
|
+
expect(run(roundTrip, applying).models.isApplying).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("clears when the write finishes", () => {
|
|
190
|
+
expect(
|
|
191
|
+
run([{ type: "MODELS_APPLY_START" }, { type: "MODELS_APPLY_END" }]).models
|
|
192
|
+
.isApplying,
|
|
193
|
+
).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("selection state", () => {
|
|
198
|
+
test("select records the index verbatim", () => {
|
|
199
|
+
expect(
|
|
200
|
+
run([{ type: "MODELS_SELECT", index: 3 }]).models.selectedIndex,
|
|
201
|
+
).toBe(3);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("models actions touch no other screen's state", () => {
|
|
205
|
+
// One shared reducer, so a mistyped spread is a real hazard: `...state`
|
|
206
|
+
// rather than `...state.models` would wipe every other screen.
|
|
207
|
+
const before = initialState;
|
|
208
|
+
const after = run([
|
|
209
|
+
{ type: "MODELS_SELECT", index: 2 },
|
|
210
|
+
{ type: "MODELS_APPLY_START" },
|
|
211
|
+
{ type: "MODELS_SEARCH_APPEND", char: "x" },
|
|
212
|
+
]);
|
|
213
|
+
expect(after.styles).toBe(before.styles);
|
|
214
|
+
expect(after.plugins).toBe(before.plugins);
|
|
215
|
+
expect(after.skills).toBe(before.skills);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ─── Keybindings ──────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
const SCREEN = join(import.meta.dir, "..", "ui", "screens", "ModelsScreen.tsx");
|
|
222
|
+
|
|
223
|
+
describe("ModelsScreen keybindings", () => {
|
|
224
|
+
test("a and Enter both apply, and c clears", async () => {
|
|
225
|
+
const src = await fs.readFile(SCREEN, "utf8");
|
|
226
|
+
expect(src).toContain('event.name === "a"');
|
|
227
|
+
expect(src).toContain('event.name === "c"');
|
|
228
|
+
expect(src).toContain('event.name === "r"');
|
|
229
|
+
expect(src).toContain('event.name === "/"');
|
|
230
|
+
// Enter is the same action as `a`. There is nothing to "open" here, so
|
|
231
|
+
// binding the most obvious key to nothing would be the wrong lesson.
|
|
232
|
+
const enter = src.indexOf('event.name === "return"');
|
|
233
|
+
expect(enter).toBeGreaterThan(-1);
|
|
234
|
+
expect(src.slice(enter, enter + 400)).toContain("handleApply()");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("digits are treated as navigation, so a message survives a tab round trip", async () => {
|
|
238
|
+
// Every digit now navigates, 0 included. Clearing the status line on the
|
|
239
|
+
// way OUT of the tab loses the message the state was changed to preserve.
|
|
240
|
+
const src = await fs.readFile(SCREEN, "utf8");
|
|
241
|
+
expect(src).toContain("/^[0-9]$/.test(event.name");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("no screen still treats 0 as a non-navigation key", async () => {
|
|
245
|
+
// The Styles screen shipped `/^[1-9]$/` before Models existed on key 0.
|
|
246
|
+
const styles = await fs.readFile(
|
|
247
|
+
join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
|
|
248
|
+
"utf8",
|
|
249
|
+
);
|
|
250
|
+
expect(styles).not.toContain("/^[1-9]$/");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("screen state is held in the reducer, never in useState", async () => {
|
|
254
|
+
// The rule the two "survives unmounting" tests above enforce at runtime,
|
|
255
|
+
// pinned at the source so a new piece of state cannot quietly opt out.
|
|
256
|
+
const src = await fs.readFile(SCREEN, "utf8");
|
|
257
|
+
expect(src).not.toContain("useState");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import fs from "fs-extra";
|
|
6
|
+
import type { ModelsConfig } from "../services/models-core.js";
|
|
6
7
|
import {
|
|
7
8
|
buildProfileMcp,
|
|
8
9
|
buildProfileSettings,
|
|
@@ -11,6 +12,19 @@ import {
|
|
|
11
12
|
import { profileDir } from "../services/symlink-manager.js";
|
|
12
13
|
import type { ResolvedClosure } from "../types/index.js";
|
|
13
14
|
|
|
15
|
+
const ROUTING: ModelsConfig = {
|
|
16
|
+
version: 1,
|
|
17
|
+
preset: "test",
|
|
18
|
+
main: { model: "opus", effort: "medium" },
|
|
19
|
+
grades: {
|
|
20
|
+
smart: { model: "fable", effort: "xhigh" },
|
|
21
|
+
normal: { model: "opus", effort: "medium" },
|
|
22
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
23
|
+
},
|
|
24
|
+
agents: { "dev:architect": "smart" },
|
|
25
|
+
fallback: "normal",
|
|
26
|
+
};
|
|
27
|
+
|
|
14
28
|
function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
|
|
15
29
|
return {
|
|
16
30
|
marketplaces: {},
|
|
@@ -162,6 +176,38 @@ describe("materializeProfile", () => {
|
|
|
162
176
|
expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
|
|
163
177
|
});
|
|
164
178
|
|
|
179
|
+
test("writes models.json when the closure carries routing", async () => {
|
|
180
|
+
const dir = await materializeProfile(
|
|
181
|
+
"p",
|
|
182
|
+
closure({ models: ROUTING }),
|
|
183
|
+
project,
|
|
184
|
+
);
|
|
185
|
+
expect(await fs.readJson(join(dir, "models.json"))).toEqual(ROUTING);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// The one artifact materialization DELETES. Every other file here is
|
|
189
|
+
// unconditional, so overwriting is enough; routing is optional, and a
|
|
190
|
+
// profile that dropped it would otherwise keep routing from a file nothing
|
|
191
|
+
// writes any more — `claudeup models off` could not turn routing off,
|
|
192
|
+
// because the hook walks up to the nearest models.json and finds this one.
|
|
193
|
+
test("deletes a stale models.json when the closure has no routing", async () => {
|
|
194
|
+
const dir = await materializeProfile(
|
|
195
|
+
"p",
|
|
196
|
+
closure({ models: ROUTING }),
|
|
197
|
+
project,
|
|
198
|
+
);
|
|
199
|
+
expect(await fs.pathExists(join(dir, "models.json"))).toBe(true);
|
|
200
|
+
|
|
201
|
+
await materializeProfile("p", closure(), project);
|
|
202
|
+
|
|
203
|
+
expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a profile that never had routing gets no models.json", async () => {
|
|
207
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
208
|
+
expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
165
211
|
test("two profiles from one union each disable the other's plugins", async () => {
|
|
166
212
|
const union = ["designer@magus", "terminal@magus"];
|
|
167
213
|
const fe = await materializeProfile(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
2
3
|
import {
|
|
3
4
|
dedupeBins,
|
|
4
5
|
resolveAllProfiles,
|
|
@@ -105,6 +106,41 @@ describe("resolveExtends", () => {
|
|
|
105
106
|
expect(resolveExtends(entry)).toBe(entry);
|
|
106
107
|
});
|
|
107
108
|
|
|
109
|
+
// `models` inherits like `settings` — later wins — but by WHOLE object: a
|
|
110
|
+
// ModelsConfig is validated as a unit (every grade present, one effort per
|
|
111
|
+
// model), so a key-by-key merge could produce a config neither side wrote
|
|
112
|
+
// and neither side validated.
|
|
113
|
+
test("the entry's own models win over an inherited one, whole-object", () => {
|
|
114
|
+
const base = {
|
|
115
|
+
version: 1,
|
|
116
|
+
preset: "inherited",
|
|
117
|
+
main: { model: "sonnet" },
|
|
118
|
+
grades: {
|
|
119
|
+
smart: { model: "opus" },
|
|
120
|
+
normal: { model: "sonnet" },
|
|
121
|
+
cheap: { model: "haiku" },
|
|
122
|
+
},
|
|
123
|
+
agents: {},
|
|
124
|
+
fallback: "normal",
|
|
125
|
+
} as ProfileManifestEntry["models"];
|
|
126
|
+
const own = { ...(base as object), preset: "mine" } as typeof base;
|
|
127
|
+
|
|
128
|
+
const withBase = PREDEFINED_PROFILES.find((p) => p.id === "must-have");
|
|
129
|
+
if (!withBase) throw new Error("must-have preset went missing");
|
|
130
|
+
withBase.models = base;
|
|
131
|
+
try {
|
|
132
|
+
expect(
|
|
133
|
+
resolveExtends({ name: "X", extends: "must-have" }).models?.preset,
|
|
134
|
+
).toBe("inherited");
|
|
135
|
+
expect(
|
|
136
|
+
resolveExtends({ name: "X", extends: "must-have", models: own }).models
|
|
137
|
+
?.preset,
|
|
138
|
+
).toBe("mine");
|
|
139
|
+
} finally {
|
|
140
|
+
withBase.models = undefined;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
108
144
|
test("unknown extends id is ignored (returns entry unchanged)", () => {
|
|
109
145
|
const entry: ProfileManifestEntry = { name: "X", extends: "nope" };
|
|
110
146
|
expect(resolveExtends(entry)).toBe(entry);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `services/settings-file.ts` — the one place claudeup edits a settings.json.
|
|
3
|
+
*
|
|
4
|
+
* Every test here pins a failure mode that has cost, or would cost, a user
|
|
5
|
+
* their configuration:
|
|
6
|
+
*
|
|
7
|
+
* - an unparseable file must be REFUSED, and left byte-for-byte alone;
|
|
8
|
+
* - a symlinked settings.json must be written THROUGH the link, because that
|
|
9
|
+
* is the shape an active profile leaves behind (`.claude/settings.json` →
|
|
10
|
+
* `.claude/_profiles/<name>/settings.json`). Remove-then-create would
|
|
11
|
+
* replace the link with a plain file and detach the project from its
|
|
12
|
+
* profile, which nothing downstream would notice.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
symlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
readSettingsFile,
|
|
28
|
+
updateSettingsFile,
|
|
29
|
+
} from "../services/settings-file.js";
|
|
30
|
+
|
|
31
|
+
const dirs: string[] = [];
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const dir of dirs.splice(0))
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function tempDir(): string {
|
|
39
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "claudeup-settings-file-"));
|
|
40
|
+
dirs.push(dir);
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("readSettingsFile", () => {
|
|
45
|
+
test("S-1: a missing file reads as an empty object", async () => {
|
|
46
|
+
const missing = path.join(tempDir(), "nested", "settings.json");
|
|
47
|
+
expect(await readSettingsFile(missing)).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("S-1b: an empty file reads as an empty object", async () => {
|
|
51
|
+
const file = path.join(tempDir(), "settings.json");
|
|
52
|
+
writeFileSync(file, " \n");
|
|
53
|
+
expect(await readSettingsFile(file)).toEqual({});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("S-2: an unparseable file throws, naming the path and the refusal", async () => {
|
|
57
|
+
const file = path.join(tempDir(), "settings.json");
|
|
58
|
+
writeFileSync(file, "{ not json");
|
|
59
|
+
|
|
60
|
+
await expect(readSettingsFile(file)).rejects.toThrow(/not valid JSON/);
|
|
61
|
+
await expect(readSettingsFile(file)).rejects.toThrow(
|
|
62
|
+
/refusing to overwrite/,
|
|
63
|
+
);
|
|
64
|
+
await expect(readSettingsFile(file)).rejects.toThrow(file);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("updateSettingsFile", () => {
|
|
69
|
+
test("S-3: a missing file starts from {} and is created", async () => {
|
|
70
|
+
const file = path.join(tempDir(), "nested", "settings.json");
|
|
71
|
+
|
|
72
|
+
await updateSettingsFile(file, (settings) => {
|
|
73
|
+
settings.outputStyle = "composed";
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({
|
|
77
|
+
outputStyle: "composed",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("S-4: a valid file round-trips and unrelated keys survive", async () => {
|
|
82
|
+
const file = path.join(tempDir(), "settings.json");
|
|
83
|
+
writeFileSync(
|
|
84
|
+
file,
|
|
85
|
+
`${JSON.stringify(
|
|
86
|
+
{
|
|
87
|
+
enabledPlugins: { "dev@magus": true },
|
|
88
|
+
someOtherKey: 42,
|
|
89
|
+
outputStyle: "old",
|
|
90
|
+
},
|
|
91
|
+
null,
|
|
92
|
+
2,
|
|
93
|
+
)}\n`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await updateSettingsFile(file, (settings) => {
|
|
97
|
+
settings.outputStyle = "new";
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({
|
|
101
|
+
enabledPlugins: { "dev@magus": true },
|
|
102
|
+
someOtherKey: 42,
|
|
103
|
+
outputStyle: "new",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("S-4b: the mutator may return a replacement object instead of mutating", async () => {
|
|
108
|
+
const file = path.join(tempDir(), "settings.json");
|
|
109
|
+
writeFileSync(file, `${JSON.stringify({ a: 1 }, null, 2)}\n`);
|
|
110
|
+
|
|
111
|
+
await updateSettingsFile(file, (settings) => ({ ...settings, b: 2 }));
|
|
112
|
+
|
|
113
|
+
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ a: 1, b: 2 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("S-4c: deleting a key removes it rather than serializing undefined", async () => {
|
|
117
|
+
const file = path.join(tempDir(), "settings.json");
|
|
118
|
+
writeFileSync(file, `${JSON.stringify({ a: 1, outputStyle: "x" })}\n`);
|
|
119
|
+
|
|
120
|
+
await updateSettingsFile(file, (settings) => {
|
|
121
|
+
// biome-ignore lint/performance/noDelete: removal is the intent
|
|
122
|
+
delete settings.outputStyle;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const raw = readFileSync(file, "utf8");
|
|
126
|
+
expect(raw).not.toContain("outputStyle");
|
|
127
|
+
expect(JSON.parse(raw)).toEqual({ a: 1 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("S-4d: the write is 2-space JSON with a trailing newline", async () => {
|
|
131
|
+
const file = path.join(tempDir(), "settings.json");
|
|
132
|
+
|
|
133
|
+
await updateSettingsFile(file, () => ({ a: { b: 1 } }));
|
|
134
|
+
|
|
135
|
+
expect(readFileSync(file, "utf8")).toBe(
|
|
136
|
+
'{\n "a": {\n "b": 1\n }\n}\n',
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("S-5: an unparseable file throws and the bytes on disk are UNCHANGED", async () => {
|
|
141
|
+
const file = path.join(tempDir(), "settings.json");
|
|
142
|
+
const original = '{ "half": tru\n// a human mid-edit\n';
|
|
143
|
+
writeFileSync(file, original);
|
|
144
|
+
let mutatorRan = false;
|
|
145
|
+
|
|
146
|
+
await expect(
|
|
147
|
+
updateSettingsFile(file, (settings) => {
|
|
148
|
+
mutatorRan = true;
|
|
149
|
+
settings.outputStyle = "composed";
|
|
150
|
+
}),
|
|
151
|
+
).rejects.toThrow(/not valid JSON, refusing to overwrite it/);
|
|
152
|
+
|
|
153
|
+
expect(mutatorRan).toBe(false);
|
|
154
|
+
expect(readFileSync(file, "utf8")).toBe(original);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("S-6: a symlinked settings.json is written THROUGH the link — the profile case", async () => {
|
|
158
|
+
const dir = tempDir();
|
|
159
|
+
const target = path.join(dir, "profile-settings.json");
|
|
160
|
+
const link = path.join(dir, "settings.json");
|
|
161
|
+
writeFileSync(
|
|
162
|
+
target,
|
|
163
|
+
`${JSON.stringify({ enabledPlugins: { "dev@magus": true } }, null, 2)}\n`,
|
|
164
|
+
);
|
|
165
|
+
symlinkSync(target, link);
|
|
166
|
+
|
|
167
|
+
await updateSettingsFile(link, (settings) => {
|
|
168
|
+
settings.outputStyle = "composed";
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The profile's real file received the write …
|
|
172
|
+
expect(JSON.parse(readFileSync(target, "utf8"))).toEqual({
|
|
173
|
+
enabledPlugins: { "dev@magus": true },
|
|
174
|
+
outputStyle: "composed",
|
|
175
|
+
});
|
|
176
|
+
// … and the link is still a link, not a plain file that replaced it.
|
|
177
|
+
expect(lstatSync(link).isSymbolicLink()).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
async function materialize(
|
|
14
14
|
project: string,
|
|
15
15
|
name: string,
|
|
16
|
-
files: {
|
|
16
|
+
files: {
|
|
17
|
+
settings?: object;
|
|
18
|
+
mcp?: object;
|
|
19
|
+
skills?: boolean;
|
|
20
|
+
models?: object;
|
|
21
|
+
},
|
|
17
22
|
) {
|
|
18
23
|
const dir = profileDir(name, project);
|
|
19
24
|
await fs.ensureDir(dir);
|
|
@@ -21,6 +26,7 @@ async function materialize(
|
|
|
21
26
|
await fs.writeJson(join(dir, "settings.json"), files.settings);
|
|
22
27
|
if (files.mcp) await fs.writeJson(join(dir, "mcp.json"), files.mcp);
|
|
23
28
|
if (files.skills) await fs.ensureDir(join(dir, "skills"));
|
|
29
|
+
if (files.models) await fs.writeJson(join(dir, "models.json"), files.models);
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
describe("symlink-manager", () => {
|
|
@@ -108,6 +114,64 @@ describe("symlink-manager", () => {
|
|
|
108
114
|
);
|
|
109
115
|
});
|
|
110
116
|
|
|
117
|
+
test("links models.json when the profile has routing", async () => {
|
|
118
|
+
await materialize(project, "frontend", {
|
|
119
|
+
settings: { a: 1 },
|
|
120
|
+
models: { version: 1, preset: "fable-advisor" },
|
|
121
|
+
});
|
|
122
|
+
await activateProfile("frontend", project);
|
|
123
|
+
|
|
124
|
+
const link = join(project, ".claude", "models.json");
|
|
125
|
+
expect((await lstat(link)).isSymbolicLink()).toBe(true);
|
|
126
|
+
expect((await fs.readJson(link)).preset).toBe("fable-advisor");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// A dangling .claude/models.json is a repo-wide hazard: `.claude/` is
|
|
130
|
+
// committed, so it would ship to every teammate and point at a directory
|
|
131
|
+
// that only exists on the machine that made it.
|
|
132
|
+
test("a profile without routing gets no models link at all", async () => {
|
|
133
|
+
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
134
|
+
await activateProfile("frontend", project);
|
|
135
|
+
|
|
136
|
+
const link = join(project, ".claude", "models.json");
|
|
137
|
+
expect(await fs.pathExists(link)).toBe(false);
|
|
138
|
+
await expect(lstat(link)).rejects.toThrow();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("switching to a profile without routing removes the previous link", async () => {
|
|
142
|
+
await materialize(project, "routed", {
|
|
143
|
+
settings: { which: "routed" },
|
|
144
|
+
models: { version: 1, preset: "fable-advisor" },
|
|
145
|
+
});
|
|
146
|
+
await materialize(project, "plain", { settings: { which: "plain" } });
|
|
147
|
+
|
|
148
|
+
await activateProfile("routed", project);
|
|
149
|
+
expect(
|
|
150
|
+
(await lstat(join(project, ".claude", "models.json"))).isSymbolicLink(),
|
|
151
|
+
).toBe(true);
|
|
152
|
+
|
|
153
|
+
await activateProfile("plain", project);
|
|
154
|
+
await expect(
|
|
155
|
+
lstat(join(project, ".claude", "models.json")),
|
|
156
|
+
).rejects.toThrow();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Removal is scoped to links INTO _profiles/. A real models.json a project
|
|
160
|
+
// wrote by hand is not claudeup's to delete.
|
|
161
|
+
test("a real models.json file is never clobbered by activation", async () => {
|
|
162
|
+
await materialize(project, "plain", { settings: { a: 1 } });
|
|
163
|
+
await fs.outputJson(join(project, ".claude", "models.json"), {
|
|
164
|
+
version: 1,
|
|
165
|
+
preset: "hand-written",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await activateProfile("plain", project);
|
|
169
|
+
|
|
170
|
+
expect(
|
|
171
|
+
(await fs.readJson(join(project, ".claude", "models.json"))).preset,
|
|
172
|
+
).toBe("hand-written");
|
|
173
|
+
});
|
|
174
|
+
|
|
111
175
|
test("activeProfile returns null when settings.json is a real file", async () => {
|
|
112
176
|
await fs.ensureDir(join(project, ".claude"));
|
|
113
177
|
await fs.writeJson(join(project, ".claude", "settings.json"), { a: 1 });
|