claudeup 6.3.2 → 6.5.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__/catalog-notice.test.ts +3 -3
- package/src/__tests__/cli-live.test.ts +9 -2
- package/src/__tests__/cli-update-view.test.ts +2 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gap-fill-versions.test.ts +24 -24
- 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__/marketplace-badge.test.ts +1 -1
- package/src/__tests__/marketplaces.test.ts +0 -1
- package/src/__tests__/model-visuals.test.tsx +793 -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__/moved-marketplace.test.ts +7 -8
- package/src/__tests__/plugin-contents.test.ts +1 -1
- package/src/__tests__/profile-adopt.test.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +48 -2
- package/src/__tests__/resolver.test.ts +43 -6
- 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 +4 -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 -1
- package/src/data/gitignore-reasons.ts +0 -4
- package/src/data/marketplaces.ts +1 -15
- package/src/data/models-presets.ts +270 -0
- package/src/data/predefined-profiles.ts +12 -21
- 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/plugin-manager.ts +2 -3
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +13 -2
- 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/adapters/pluginsAdapter.ts +1 -1
- 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/PluginsScreen.tsx +1 -1
- 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
|
+
});
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An orphaned plugin that merely changed marketplace is not deprecated.
|
|
3
3
|
*
|
|
4
|
-
* Splitting `magus` into `magus` + `magus-marketing` left `
|
|
5
|
-
*
|
|
6
|
-
* under a namespace that no longer lists them. They rendered as bare
|
|
4
|
+
* Splitting `magus` into `magus` + `magus-marketing` left `video-editing@magus`
|
|
5
|
+
* and `nanobanana@magus` installed under a namespace that no longer lists them. They rendered as bare
|
|
7
6
|
* "deprecated", whose only action is deletion — which drops a plugin that is
|
|
8
7
|
* still published, just under a different id.
|
|
9
8
|
*/
|
|
@@ -45,19 +44,19 @@ describe("moved-marketplace detection", () => {
|
|
|
45
44
|
test("finds a plugin that moved to a sibling marketplace", () => {
|
|
46
45
|
const marketplaces = new Map([
|
|
47
46
|
mp("magus", ["dev", "terminal"]),
|
|
48
|
-
mp("magus-marketing", ["
|
|
47
|
+
mp("magus-marketing", ["image-generate", "video-editing"]),
|
|
49
48
|
]);
|
|
50
49
|
|
|
51
|
-
expect(
|
|
52
|
-
"
|
|
53
|
-
);
|
|
50
|
+
expect(
|
|
51
|
+
findPluginInOtherMarketplace("video-editing", "magus", marketplaces),
|
|
52
|
+
).toBe("magus-marketing");
|
|
54
53
|
});
|
|
55
54
|
|
|
56
55
|
test("a genuinely retired plugin reports no destination", () => {
|
|
57
56
|
// `conductor` was removed at magus v8.0.0 and republished nowhere.
|
|
58
57
|
const marketplaces = new Map([
|
|
59
58
|
mp("magus", ["dev"]),
|
|
60
|
-
mp("magus-marketing", ["
|
|
59
|
+
mp("magus-marketing", ["video-editing"]),
|
|
61
60
|
]);
|
|
62
61
|
|
|
63
62
|
expect(
|
|
@@ -90,7 +90,7 @@ describe("fetchPluginContents", () => {
|
|
|
90
90
|
stubTree([
|
|
91
91
|
"plugins/dev/skills/frontend/design-system/SKILL.md",
|
|
92
92
|
"plugins/dev/skills/backend/db-branching/SKILL.md",
|
|
93
|
-
"plugins/
|
|
93
|
+
"plugins/designer/skills/ui-analyse/SKILL.md",
|
|
94
94
|
]);
|
|
95
95
|
|
|
96
96
|
const skills = await fetchPluginContents("fake/multi", "./plugins/dev");
|
|
@@ -24,7 +24,7 @@ describe("buildAdoptedProfile", () => {
|
|
|
24
24
|
|
|
25
25
|
test("skips disabled plugins", () => {
|
|
26
26
|
const entry = buildAdoptedProfile({
|
|
27
|
-
enabledPlugins: { "dev@magus": true, "
|
|
27
|
+
enabledPlugins: { "dev@magus": true, "designer@magus": false },
|
|
28
28
|
settings: {},
|
|
29
29
|
mcpServers: {},
|
|
30
30
|
now: NOW,
|
|
@@ -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: {},
|
|
@@ -47,12 +61,12 @@ describe("buildProfileSettings", () => {
|
|
|
47
61
|
test("explicitly disables union plugins the profile excludes", () => {
|
|
48
62
|
const s = buildProfileSettings(
|
|
49
63
|
closure({ plugins: { "designer@magus": "latest" } }),
|
|
50
|
-
["designer@magus", "terminal@magus", "
|
|
64
|
+
["designer@magus", "terminal@magus", "madbench@magus"],
|
|
51
65
|
);
|
|
52
66
|
expect(s.enabledPlugins).toEqual({
|
|
53
67
|
"designer@magus": true,
|
|
54
68
|
"terminal@magus": false,
|
|
55
|
-
"
|
|
69
|
+
"madbench@magus": false,
|
|
56
70
|
});
|
|
57
71
|
});
|
|
58
72
|
|
|
@@ -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,
|
|
@@ -90,12 +91,11 @@ describe("resolveExtends", () => {
|
|
|
90
91
|
extends: "growth-marketer",
|
|
91
92
|
};
|
|
92
93
|
const merged = resolveExtends(entry);
|
|
93
|
-
//
|
|
94
|
+
// image-generate/video-editing ship on the magus-marketing channel and
|
|
94
95
|
// pin it explicitly — they must NOT be rewritten to @magus.
|
|
95
|
-
expect(merged.plugins!["seo@magus-marketing"]).toBe("latest");
|
|
96
96
|
expect(merged.plugins!["image-generate@magus-marketing"]).toBe("latest");
|
|
97
97
|
expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
|
|
98
|
-
expect(merged.plugins!["
|
|
98
|
+
expect(merged.plugins!["image-generate@magus"]).toBeUndefined();
|
|
99
99
|
// Bare names still default to @magus.
|
|
100
100
|
expect(merged.plugins!["browser-use@magus"]).toBe("latest");
|
|
101
101
|
});
|
|
@@ -105,6 +105,41 @@ describe("resolveExtends", () => {
|
|
|
105
105
|
expect(resolveExtends(entry)).toBe(entry);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// `models` inherits like `settings` — later wins — but by WHOLE object: a
|
|
109
|
+
// ModelsConfig is validated as a unit (every grade present, one effort per
|
|
110
|
+
// model), so a key-by-key merge could produce a config neither side wrote
|
|
111
|
+
// and neither side validated.
|
|
112
|
+
test("the entry's own models win over an inherited one, whole-object", () => {
|
|
113
|
+
const base = {
|
|
114
|
+
version: 1,
|
|
115
|
+
preset: "inherited",
|
|
116
|
+
main: { model: "sonnet" },
|
|
117
|
+
grades: {
|
|
118
|
+
smart: { model: "opus" },
|
|
119
|
+
normal: { model: "sonnet" },
|
|
120
|
+
cheap: { model: "haiku" },
|
|
121
|
+
},
|
|
122
|
+
agents: {},
|
|
123
|
+
fallback: "normal",
|
|
124
|
+
} as ProfileManifestEntry["models"];
|
|
125
|
+
const own = { ...(base as object), preset: "mine" } as typeof base;
|
|
126
|
+
|
|
127
|
+
const withBase = PREDEFINED_PROFILES.find((p) => p.id === "must-have");
|
|
128
|
+
if (!withBase) throw new Error("must-have preset went missing");
|
|
129
|
+
withBase.models = base;
|
|
130
|
+
try {
|
|
131
|
+
expect(
|
|
132
|
+
resolveExtends({ name: "X", extends: "must-have" }).models?.preset,
|
|
133
|
+
).toBe("inherited");
|
|
134
|
+
expect(
|
|
135
|
+
resolveExtends({ name: "X", extends: "must-have", models: own }).models
|
|
136
|
+
?.preset,
|
|
137
|
+
).toBe("mine");
|
|
138
|
+
} finally {
|
|
139
|
+
withBase.models = undefined;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
108
143
|
test("unknown extends id is ignored (returns entry unchanged)", () => {
|
|
109
144
|
const entry: ProfileManifestEntry = { name: "X", extends: "nope" };
|
|
110
145
|
expect(resolveExtends(entry)).toBe(entry);
|
|
@@ -267,8 +302,8 @@ describe("marketplaces derived from plugin ids", () => {
|
|
|
267
302
|
name: "Mixed",
|
|
268
303
|
plugins: {
|
|
269
304
|
"dev@magus": "latest",
|
|
270
|
-
"
|
|
271
|
-
"
|
|
305
|
+
"image-generate@magus-marketing": "latest",
|
|
306
|
+
"feature-dev@claude-plugins-official": "latest",
|
|
272
307
|
},
|
|
273
308
|
},
|
|
274
309
|
});
|
|
@@ -276,7 +311,9 @@ describe("marketplaces derived from plugin ids", () => {
|
|
|
276
311
|
expect(c.marketplaces["magus-marketing"]?.repo).toBe(
|
|
277
312
|
"MadAppGang/magus-marketing",
|
|
278
313
|
);
|
|
279
|
-
expect(c.marketplaces["
|
|
314
|
+
expect(c.marketplaces["claude-plugins-official"]?.repo).toBe(
|
|
315
|
+
"anthropics/claude-plugins-official",
|
|
316
|
+
);
|
|
280
317
|
});
|
|
281
318
|
|
|
282
319
|
test("an explicit declaration is never overridden", async () => {
|
|
@@ -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
|
+
});
|