magus-cli 7.0.0 → 7.2.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__/blackbox-parity.test.ts +58 -17
- package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
- package/src/__tests__/cli-update-view.test.ts +100 -21
- package/src/__tests__/local-marketplace-scan.test.ts +74 -0
- package/src/__tests__/marketplace-refresh.test.ts +12 -7
- package/src/__tests__/plugin-row-text.test.ts +33 -24
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/theme-env.test.ts +29 -0
- package/src/__tests__/update-plan.test.ts +57 -34
- package/src/__tests__/version-unknown-state.test.ts +29 -14
- package/src/cli/ansi.ts +58 -11
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/router.ts +59 -2
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +237 -45
- package/src/cli/update.ts +59 -29
- package/src/main.tsx +11 -2
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/manifest.ts +21 -0
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/resolver.ts +33 -0
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-plan.ts +23 -66
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -3
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +142 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What magus requires about profiles before it will work on a project.
|
|
3
|
+
*
|
|
4
|
+
* Two behaviours are pinned here, and both were defects:
|
|
5
|
+
*
|
|
6
|
+
* 1. The TUI could show a Profiles tab listing two profiles while the project
|
|
7
|
+
* ran neither, and say nothing.
|
|
8
|
+
* 2. Both surfaces offered a way to carry on anyway — the TUI as a "Not now"
|
|
9
|
+
* row, the CLI as "using X for this run only". With no active profile there
|
|
10
|
+
* is no profile state to read and nowhere to write updates, so that let the
|
|
11
|
+
* user into a magus that could not do its job.
|
|
12
|
+
*
|
|
13
|
+
* And one thing is pinned by its ABSENCE: nothing compares the manifest to
|
|
14
|
+
* `enabledPlugins`. See the header of `services/profile-startup.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import fs from "fs-extra";
|
|
22
|
+
import { validateManifest } from "../services/manifest.js";
|
|
23
|
+
import { materializeAndActivate } from "../services/profile-activate.js";
|
|
24
|
+
import { adoptCurrentProject } from "../services/profile-adopt.js";
|
|
25
|
+
import {
|
|
26
|
+
ACTIVATE_PREFIX,
|
|
27
|
+
ADOPT_VALUE,
|
|
28
|
+
QUIT_VALUE,
|
|
29
|
+
buildProfileOptions,
|
|
30
|
+
defaultOptionIndex,
|
|
31
|
+
describeNeed,
|
|
32
|
+
detectProfileStartupNeed,
|
|
33
|
+
needsNothing,
|
|
34
|
+
parseProfileChoice,
|
|
35
|
+
} from "../services/profile-startup.js";
|
|
36
|
+
import { listProfiles } from "../services/profiles.js";
|
|
37
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
38
|
+
import {
|
|
39
|
+
buildProfileStartupModal,
|
|
40
|
+
describeFailure,
|
|
41
|
+
} from "../ui/hooks/useProfileStartupModal.js";
|
|
42
|
+
|
|
43
|
+
const LEAN: ProfileManifest = {
|
|
44
|
+
version: 2,
|
|
45
|
+
profiles: {
|
|
46
|
+
lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
|
|
47
|
+
full: {
|
|
48
|
+
name: "Full",
|
|
49
|
+
plugins: { "terminal@magus": "latest", "dev@magus": "latest" },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function enable(project: string, ids: string[]): Promise<void> {
|
|
55
|
+
await fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
56
|
+
enabledPlugins: Object.fromEntries(ids.map((id) => [id, true])),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("detectProfileStartupNeed", () => {
|
|
61
|
+
let project: string;
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
project = await mkdtemp(join(tmpdir(), "startup-"));
|
|
64
|
+
});
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await rm(project, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("no manifest — unmigrated, offers to adopt, nothing to activate", async () => {
|
|
70
|
+
await enable(project, ["dev@magus"]);
|
|
71
|
+
|
|
72
|
+
const need = await detectProfileStartupNeed(project);
|
|
73
|
+
|
|
74
|
+
expect(need.state).toBe("unmigrated");
|
|
75
|
+
expect(need.adopt).toBe("default");
|
|
76
|
+
expect(need.activate).toEqual([]);
|
|
77
|
+
expect(needsNothing(need)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("profiles but none active — inactive, offers both routes", async () => {
|
|
81
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
82
|
+
await enable(project, ["terminal@magus"]);
|
|
83
|
+
|
|
84
|
+
const need = await detectProfileStartupNeed(project);
|
|
85
|
+
|
|
86
|
+
expect(need.state).toBe("inactive");
|
|
87
|
+
expect(need.activate.map((p) => p.id).sort()).toEqual(["full", "lean"]);
|
|
88
|
+
// Adopting stays available so a manifest full of stale profiles is not a
|
|
89
|
+
// dead end where activating a wrong one is the only way forward.
|
|
90
|
+
expect(need.adopt).toBe("default");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a profile is active — asks nothing, whatever is enabled", async () => {
|
|
94
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
95
|
+
await enable(project, ["terminal@magus"]);
|
|
96
|
+
await materializeAndActivate(LEAN, "lean", project, () => {});
|
|
97
|
+
|
|
98
|
+
expect(
|
|
99
|
+
needsNothing(await detectProfileStartupNeed(project)).valueOf(),
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an active profile whose generated settings were edited still asks nothing", async () => {
|
|
104
|
+
// THE point of dropping the comparison. `settings.json` is a symlink into
|
|
105
|
+
// `_profiles/lean/`, which is generated FROM the manifest — so judging the
|
|
106
|
+
// manifest against it reads the manifest's own output. The old code offered
|
|
107
|
+
// to create a third profile describing the corrupted file; the right answer
|
|
108
|
+
// is to re-materialize, and the wrong answer is what made magus behave as
|
|
109
|
+
// though the profileless world were still live.
|
|
110
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
111
|
+
await enable(project, ["terminal@magus"]);
|
|
112
|
+
await materializeAndActivate(LEAN, "lean", project, () => {});
|
|
113
|
+
await fs.outputJson(
|
|
114
|
+
join(project, ".claude", "_profiles", "lean", "settings.json"),
|
|
115
|
+
{ enabledPlugins: { "terminal@magus": true, "brand-new@magus": true } },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const need = await detectProfileStartupNeed(project);
|
|
119
|
+
|
|
120
|
+
expect(need.state).toBe("ok");
|
|
121
|
+
expect(need.adopt).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("what is enabled never changes the answer", async () => {
|
|
125
|
+
// Nothing reads enabledPlugins, so two wildly different settings files on
|
|
126
|
+
// the same manifest must produce identical needs.
|
|
127
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
128
|
+
|
|
129
|
+
await enable(project, ["terminal@magus"]);
|
|
130
|
+
const matching = await detectProfileStartupNeed(project);
|
|
131
|
+
await enable(project, ["nothing-any-profile-declares@magus"]);
|
|
132
|
+
const mismatching = await detectProfileStartupNeed(project);
|
|
133
|
+
|
|
134
|
+
expect(mismatching.state).toBe(matching.state);
|
|
135
|
+
expect(mismatching.adopt).toBe(matching.adopt);
|
|
136
|
+
expect(mismatching.activate).toEqual(matching.activate);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("listProfiles marks the active profile, and only that one", async () => {
|
|
140
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
|
|
141
|
+
await enable(project, ["terminal@magus"]);
|
|
142
|
+
|
|
143
|
+
const before = await listProfiles(project);
|
|
144
|
+
expect(before.every((p) => p.active !== true)).toBe(true);
|
|
145
|
+
|
|
146
|
+
await materializeAndActivate(LEAN, "lean", project, () => {});
|
|
147
|
+
const after = await listProfiles(project);
|
|
148
|
+
|
|
149
|
+
expect(after.find((p) => p.id === "lean")?.active).toBe(true);
|
|
150
|
+
expect(after.find((p) => p.id === "full")?.active).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a declared default is applied without asking on a fresh clone", async () => {
|
|
154
|
+
// The whole point of the field: the repo already answered, and there is
|
|
155
|
+
// nothing of this developer's to replace, so nobody is asked.
|
|
156
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
157
|
+
...LEAN,
|
|
158
|
+
default: "full",
|
|
159
|
+
});
|
|
160
|
+
// No settings.json, no .mcp.json — nothing activation would displace.
|
|
161
|
+
|
|
162
|
+
const need = await detectProfileStartupNeed(project);
|
|
163
|
+
|
|
164
|
+
expect(need.state).toBe("inactive");
|
|
165
|
+
expect(need.declaredDefault).toBe("full");
|
|
166
|
+
expect(need.autoActivate).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a declared default is NOT applied when there is work to replace", async () => {
|
|
170
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
171
|
+
...LEAN,
|
|
172
|
+
default: "full",
|
|
173
|
+
});
|
|
174
|
+
await enable(project, ["dev@magus"]);
|
|
175
|
+
|
|
176
|
+
const need = await detectProfileStartupNeed(project);
|
|
177
|
+
|
|
178
|
+
// Still declared, so it is preselected — but never automatic. Replacing
|
|
179
|
+
// someone's working files does not happen without them saying so.
|
|
180
|
+
expect(need.declaredDefault).toBe("full");
|
|
181
|
+
expect(need.autoActivate).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a default naming a profile that does not exist falls back to asking", async () => {
|
|
185
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
186
|
+
...LEAN,
|
|
187
|
+
default: "renamed-away",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const need = await detectProfileStartupNeed(project);
|
|
191
|
+
|
|
192
|
+
// validateManifest reports the typo; detection must stay usable meanwhile
|
|
193
|
+
// rather than auto-activating a profile that is not there.
|
|
194
|
+
expect(need.declaredDefault).toBeNull();
|
|
195
|
+
expect(need.autoActivate).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("adopting records the default, and never overwrites one", async () => {
|
|
199
|
+
await enable(project, ["dev@magus"]);
|
|
200
|
+
|
|
201
|
+
const first = await adoptCurrentProject(
|
|
202
|
+
project,
|
|
203
|
+
undefined,
|
|
204
|
+
undefined,
|
|
205
|
+
"default",
|
|
206
|
+
);
|
|
207
|
+
expect(first.manifest.default).toBe("default");
|
|
208
|
+
|
|
209
|
+
// A second adoption into a manifest that already declares one must leave
|
|
210
|
+
// the team's committed choice alone — one developer saving local state is
|
|
211
|
+
// not a decision about what everyone else gets.
|
|
212
|
+
const second = await adoptCurrentProject(
|
|
213
|
+
project,
|
|
214
|
+
undefined,
|
|
215
|
+
{ ...LEAN, default: "lean" },
|
|
216
|
+
"default",
|
|
217
|
+
);
|
|
218
|
+
expect(second.manifest.default).toBe("lean");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("adopt never proposes an id that already exists", async () => {
|
|
222
|
+
await fs.outputJson(join(project, ".claude", "profiles.json"), {
|
|
223
|
+
version: 2,
|
|
224
|
+
profiles: {
|
|
225
|
+
default: { name: "Default", plugins: { "terminal@magus": "latest" } },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
await enable(project, ["something-else@magus"]);
|
|
229
|
+
|
|
230
|
+
expect((await detectProfileStartupNeed(project)).adopt).toBe("default-2");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("what the user is offered", () => {
|
|
235
|
+
const manifest = LEAN;
|
|
236
|
+
const unmigrated = {
|
|
237
|
+
state: "unmigrated" as const,
|
|
238
|
+
adopt: "default",
|
|
239
|
+
activate: [],
|
|
240
|
+
manifest: { version: 2 as const, profiles: {} },
|
|
241
|
+
};
|
|
242
|
+
const inactive = {
|
|
243
|
+
state: "inactive" as const,
|
|
244
|
+
adopt: "default",
|
|
245
|
+
activate: [
|
|
246
|
+
{ id: "lean", name: "Lean", pluginCount: 1 },
|
|
247
|
+
{ id: "full", name: "Full", pluginCount: 2 },
|
|
248
|
+
],
|
|
249
|
+
manifest,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
test("there is NO way to carry on without a profile", () => {
|
|
253
|
+
for (const need of [unmigrated, inactive]) {
|
|
254
|
+
const values = buildProfileOptions(need).map((o) => o.value);
|
|
255
|
+
// The old "Not now" / "for this run only" escape. Its absence is the
|
|
256
|
+
// behaviour, so it is asserted rather than assumed.
|
|
257
|
+
expect(values).not.toContain("dismiss");
|
|
258
|
+
// ...but leaving must always be reachable, and visibly so.
|
|
259
|
+
expect(values[values.length - 1]).toBe(QUIT_VALUE);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("no option claims to know what is currently installed", () => {
|
|
264
|
+
// The "matches now" badge compared declared plugins against enabled ones.
|
|
265
|
+
// It is gone with the comparison; a row may only describe its own profile.
|
|
266
|
+
for (const option of buildProfileOptions(inactive)) {
|
|
267
|
+
expect(option.badge).toBeUndefined();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("activate rows carry the profile's own plugin count", () => {
|
|
272
|
+
const options = buildProfileOptions(inactive);
|
|
273
|
+
expect(options[1]?.meta).toBe("1 plugin");
|
|
274
|
+
expect(options[2]?.meta).toBe("2 plugins");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("each state is titled as the thing that is actually wrong", () => {
|
|
278
|
+
expect(describeNeed(unmigrated).title).toBe("magus uses profiles");
|
|
279
|
+
expect(describeNeed(inactive).title).toBe("No profile selected");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("the unmigrated message reassures instead of warning", () => {
|
|
283
|
+
// Adopting an unmigrated project captures what is already present, so it
|
|
284
|
+
// must NOT carry the overwrite warning the inactive state needs.
|
|
285
|
+
const { message } = describeNeed(unmigrated);
|
|
286
|
+
|
|
287
|
+
expect(message).toContain("nothing about what you run changes");
|
|
288
|
+
expect(message).not.toContain("overwritten");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("the inactive message names what is lost, in concrete terms", () => {
|
|
292
|
+
// Two defects this replaced. It explained magus's internals ("the manifest
|
|
293
|
+
// is not in effect", "nowhere to write updates"), and then it went abstract
|
|
294
|
+
// ("the setup you are running belongs to no profile") — neither said that
|
|
295
|
+
// switching OVERWRITES the plugins and skills currently turned on, which is
|
|
296
|
+
// the only fact that makes the save option worth choosing.
|
|
297
|
+
const { message } = describeNeed(inactive);
|
|
298
|
+
|
|
299
|
+
expect(message).toContain("plugins and skills turned on here");
|
|
300
|
+
expect(message).toContain("not saved in any profile");
|
|
301
|
+
expect(message).toContain("overwritten");
|
|
302
|
+
|
|
303
|
+
// Neither the internal vocabulary nor the abstraction comes back. "backup"
|
|
304
|
+
// is banned here too: promising that nothing is deleted raises a question
|
|
305
|
+
// the reader did not have and implies data loss, when what changes is
|
|
306
|
+
// settings. Where the files went is reported AFTER the switch instead.
|
|
307
|
+
for (const banned of [
|
|
308
|
+
"manifest",
|
|
309
|
+
"profile state",
|
|
310
|
+
"declares profiles",
|
|
311
|
+
"setup",
|
|
312
|
+
"belongs to",
|
|
313
|
+
"backup",
|
|
314
|
+
"deleted",
|
|
315
|
+
]) {
|
|
316
|
+
expect(message).not.toContain(banned);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the declared profile's row is the one under the cursor", () => {
|
|
321
|
+
const withDefault = { ...inactive, declaredDefault: "full" };
|
|
322
|
+
// options: [save, Activate Lean, Activate Full, Quit]
|
|
323
|
+
expect(defaultOptionIndex(withDefault)).toBe(2);
|
|
324
|
+
|
|
325
|
+
// Nothing declared: start on the save row, the only option that keeps
|
|
326
|
+
// what is turned on.
|
|
327
|
+
expect(defaultOptionIndex(inactive)).toBe(0);
|
|
328
|
+
// Declared but absent from the offered list — never point past the end.
|
|
329
|
+
expect(defaultOptionIndex({ ...inactive, declaredDefault: "ghost" })).toBe(
|
|
330
|
+
0,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a default naming no profile is a manifest error", () => {
|
|
335
|
+
const errors = validateManifest({ ...LEAN, default: "ghost" });
|
|
336
|
+
expect(errors.map((e) => e.path)).toContain("default");
|
|
337
|
+
|
|
338
|
+
// A valid one is not.
|
|
339
|
+
expect(validateManifest({ ...LEAN, default: "lean" })).toEqual([]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("the save option says what it preserves", () => {
|
|
343
|
+
// "Create <id> from this project" described the mechanism. The reason to
|
|
344
|
+
// pick this row is that it is the only one that keeps what is turned on.
|
|
345
|
+
expect(buildProfileOptions(inactive)[0]?.label).toBe(
|
|
346
|
+
'Save what you have as "default"',
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a value maps to the action it names", () => {
|
|
351
|
+
expect(parseProfileChoice(ADOPT_VALUE, inactive)).toEqual({
|
|
352
|
+
kind: "adopt",
|
|
353
|
+
id: "default",
|
|
354
|
+
});
|
|
355
|
+
expect(parseProfileChoice(`${ACTIVATE_PREFIX}lean`, inactive)).toEqual({
|
|
356
|
+
kind: "activate",
|
|
357
|
+
id: "lean",
|
|
358
|
+
});
|
|
359
|
+
expect(parseProfileChoice(QUIT_VALUE, inactive)).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a value that names nothing real cannot act", () => {
|
|
363
|
+
// adopt with nothing to adopt, and activate naming a profile not offered.
|
|
364
|
+
expect(
|
|
365
|
+
parseProfileChoice(ADOPT_VALUE, { ...inactive, adopt: null }),
|
|
366
|
+
).toBeNull();
|
|
367
|
+
expect(parseProfileChoice(`${ACTIVATE_PREFIX}ghost`, inactive)).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("a failure that already moved something", () => {
|
|
372
|
+
const at = (name: string) => ({
|
|
373
|
+
link: `/p/.claude/${name}`,
|
|
374
|
+
savedTo: `/p/.claude/.magus-backups/S/${name}`,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a true no-op says so", () => {
|
|
378
|
+
const msg = describeFailure(new Error("boom"), [], "/p", null);
|
|
379
|
+
expect(msg).toContain("boom");
|
|
380
|
+
expect(msg).toContain("exactly as it was");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("a partial failure never claims nothing happened", () => {
|
|
384
|
+
// Activation moves up to four artifacts in sequence and settings.json goes
|
|
385
|
+
// FIRST, so a throw on the second leaves it already in the backup. The old
|
|
386
|
+
// message said "Nothing was activated" unconditionally — false about the
|
|
387
|
+
// user's own file, and `.magus-backups/` is gitignored, so git status
|
|
388
|
+
// showed the deletion with no sign of the copy.
|
|
389
|
+
const msg = describeFailure(
|
|
390
|
+
new Error("EACCES: skills"),
|
|
391
|
+
[at("settings.json")],
|
|
392
|
+
"/p",
|
|
393
|
+
"lean",
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(msg).not.toContain("Nothing was moved");
|
|
397
|
+
expect(msg).not.toContain("exactly as it was");
|
|
398
|
+
// It must name where the file went, project-relative.
|
|
399
|
+
expect(msg).toContain(".claude/settings.json");
|
|
400
|
+
expect(msg).toContain(".claude/.magus-backups/S/settings.json");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("it warns against the re-attempt that would capture an empty set", () => {
|
|
404
|
+
// Re-choosing save-what-you-have runs adoption again, and adoption reads
|
|
405
|
+
// settings.json — now in the backup. It would capture an empty plugin set
|
|
406
|
+
// into a second profile and call that the user's setup.
|
|
407
|
+
const msg = describeFailure(
|
|
408
|
+
new Error("boom"),
|
|
409
|
+
[at("settings.json")],
|
|
410
|
+
"/p",
|
|
411
|
+
"lean",
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(msg).toContain("Do NOT save-what-you-have");
|
|
415
|
+
expect(msg).toContain("Activating lean again is safe");
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("the TUI modal", () => {
|
|
420
|
+
const inactive = {
|
|
421
|
+
state: "inactive" as const,
|
|
422
|
+
adopt: "default",
|
|
423
|
+
activate: [{ id: "lean", name: "Lean", pluginCount: 1 }],
|
|
424
|
+
manifest: LEAN,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
test("escape and q do nothing — they cannot dismiss it", () => {
|
|
428
|
+
const calls: string[] = [];
|
|
429
|
+
const modal = buildProfileStartupModal({
|
|
430
|
+
need: inactive,
|
|
431
|
+
onChoose: (c) => calls.push(`${c.kind}:${c.id}`),
|
|
432
|
+
onQuit: () => calls.push("quit"),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (modal.type !== "select") throw new Error("expected a select modal");
|
|
436
|
+
// ModalContainer routes both Escape and `q` here. Neither may leave the
|
|
437
|
+
// user in the app this modal exists to say is unusable.
|
|
438
|
+
modal.onCancel();
|
|
439
|
+
|
|
440
|
+
expect(calls).toEqual([]);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("choosing routes to the action, quitting routes to quit", () => {
|
|
444
|
+
const calls: string[] = [];
|
|
445
|
+
const modal = buildProfileStartupModal({
|
|
446
|
+
need: inactive,
|
|
447
|
+
onChoose: (c) => calls.push(`${c.kind}:${c.id}`),
|
|
448
|
+
onQuit: () => calls.push("quit"),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (modal.type !== "select") throw new Error("expected a select modal");
|
|
452
|
+
modal.onSelect(ADOPT_VALUE);
|
|
453
|
+
modal.onSelect(`${ACTIVATE_PREFIX}lean`);
|
|
454
|
+
modal.onSelect(QUIT_VALUE);
|
|
455
|
+
|
|
456
|
+
expect(calls).toEqual(["adopt:default", "activate:lean", "quit"]);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -278,4 +278,33 @@ describe("main.tsx order", () => {
|
|
|
278
278
|
}
|
|
279
279
|
expect(code).toMatch(/env:\s*themeEnv\b/);
|
|
280
280
|
});
|
|
281
|
+
|
|
282
|
+
// T-env-3 and T-env-3b guard main.tsx, and main.tsx ALONE — which is exactly
|
|
283
|
+
// how this escaped. The CLI report's theme resolution was added in router.ts
|
|
284
|
+
// and called `snapshotThemeEnv()` there, a second snapshot taken from inside
|
|
285
|
+
// `route()`, long after `loadProjectDotenv()` merged the project's .env into
|
|
286
|
+
// process.env. A checked-out repo could set the palette for every subcommand
|
|
287
|
+
// while both tests above stayed green.
|
|
288
|
+
//
|
|
289
|
+
// The rule generalises: main.tsx owns the snapshot because it is the only
|
|
290
|
+
// code that runs before the dotenv load. Everything downstream is HANDED the
|
|
291
|
+
// value.
|
|
292
|
+
test("T-env-3c: no module downstream of the dotenv load takes its own snapshot", () => {
|
|
293
|
+
const downstream = ["cli/router.ts", "cli/update.ts", "cli/doctor.ts"];
|
|
294
|
+
for (const rel of downstream) {
|
|
295
|
+
const src = stripComments(
|
|
296
|
+
readFileSync(path.join(import.meta.dir, "..", rel), "utf-8"),
|
|
297
|
+
);
|
|
298
|
+
expect({ rel, calls: /snapshotThemeEnv\s*\(/.test(src) }).toEqual({
|
|
299
|
+
rel,
|
|
300
|
+
calls: false,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("T-env-3d: main.tsx hands its snapshot to route()", () => {
|
|
306
|
+
// Without this the router falls back to "no opinion" and every subcommand
|
|
307
|
+
// silently reverts to the dark default on a light terminal.
|
|
308
|
+
expect(code).toMatch(/route\([^)]*themeEnv[^)]*\)/);
|
|
309
|
+
});
|
|
281
310
|
});
|
|
@@ -403,11 +403,19 @@ describe("per-scope decisions — a stale scope is never masked", () => {
|
|
|
403
403
|
expect(item.reason).toBe("up-to-date");
|
|
404
404
|
});
|
|
405
405
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
406
|
+
// REVERSES an earlier decision, deliberately. This used to be `unknown` with
|
|
407
|
+
// a note reading "publishes no version", which put 278 of the 292 plugins in
|
|
408
|
+
// claude-plugins-official into a warning state that no action could ever
|
|
409
|
+
// clear: the marketplace does not publish versions, so re-running changes
|
|
410
|
+
// nothing and the row stays amber forever.
|
|
411
|
+
//
|
|
412
|
+
// The rule now is: if we cannot compare, assume what is installed is the
|
|
413
|
+
// latest. The machine code still says WHY (`no-published-version`), so the
|
|
414
|
+
// distinction is not lost — it just stops being reported as a problem. Do not
|
|
415
|
+
// "fix" this back without changing that rule first.
|
|
416
|
+
test("a catalog that declares no version leaves the install current", () => {
|
|
417
|
+
// dev@magus is IN the catalog with a null version — the declares-no-version
|
|
418
|
+
// case, not the withdrawn one.
|
|
411
419
|
const [item] = planPluginUpdates(
|
|
412
420
|
{ "dev@magus": "latest" },
|
|
413
421
|
catalog(
|
|
@@ -418,9 +426,12 @@ describe("per-scope decisions — a stale scope is never masked", () => {
|
|
|
418
426
|
}),
|
|
419
427
|
),
|
|
420
428
|
);
|
|
421
|
-
expect(item.action).toBe("
|
|
422
|
-
expect(item.note).toContain("publishes no version");
|
|
429
|
+
expect(item.action).toBe("current");
|
|
423
430
|
expect(item.reason).toBe("no-published-version");
|
|
431
|
+
// No note: there is nothing for the reader to do about it.
|
|
432
|
+
expect(item.note).toBeUndefined();
|
|
433
|
+
// And it is not confused with the withdrawn case, which is the next test.
|
|
434
|
+
expect(item.reason).not.toBe("not-offered");
|
|
424
435
|
});
|
|
425
436
|
|
|
426
437
|
test("an installed plugin ABSENT from every catalog is 'not offered'", () => {
|
|
@@ -919,14 +930,13 @@ describe("planHasWork / summarizePlan", () => {
|
|
|
919
930
|
test("unknown splits into plugins and tools, and the total is their sum", () => {
|
|
920
931
|
const p = plan({
|
|
921
932
|
plugins: planPluginUpdates(
|
|
933
|
+
// `a@magus` is an ORPHAN — no marketplace offers it — which is the
|
|
934
|
+
// one remaining way a plugin is genuinely `unknown`. A catalog that
|
|
935
|
+
// merely declares no version is `current` now, so using that here
|
|
936
|
+
// would leave `unknownPlugins` at 0 and the sum would hold
|
|
937
|
+
// trivially, testing nothing.
|
|
922
938
|
{ "a@magus": "latest" },
|
|
923
|
-
catalog(
|
|
924
|
-
info({
|
|
925
|
-
id: "a@magus",
|
|
926
|
-
version: null,
|
|
927
|
-
projectScope: { enabled: true, version: "abc123" },
|
|
928
|
-
}),
|
|
929
|
-
),
|
|
939
|
+
catalog(orphan("a@magus", "1.0.0")),
|
|
930
940
|
),
|
|
931
941
|
bins: planBinUpdates(
|
|
932
942
|
[
|
|
@@ -991,19 +1001,24 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
|
|
|
991
1001
|
expect(item.scopes).toEqual([]);
|
|
992
1002
|
});
|
|
993
1003
|
|
|
994
|
-
|
|
1004
|
+
// `moved` is GONE, and `movedTo` with it. It was inferred from a bare name
|
|
1005
|
+
// match: a plugin published to two marketplaces — claudish is — reported
|
|
1006
|
+
// "moved → magus" when nothing had moved and both copies were installed.
|
|
1007
|
+
// The inference cannot tell a move from a dual publication, so it is not made
|
|
1008
|
+
// at all. An orphan is `not-offered`, which is a fact rather than a guess.
|
|
1009
|
+
test("an orphan is 'not offered', and never guesses where it went", () => {
|
|
995
1010
|
const [item] = planPluginUpdates(
|
|
996
1011
|
{ "seo@magus": "latest" },
|
|
997
1012
|
catalog(
|
|
998
|
-
orphan("seo@magus", "1.8.0"
|
|
1013
|
+
orphan("seo@magus", "1.8.0"),
|
|
999
1014
|
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1000
1015
|
),
|
|
1001
1016
|
);
|
|
1002
1017
|
expect(item).toMatchObject({
|
|
1003
1018
|
action: "unknown",
|
|
1004
|
-
reason: "
|
|
1005
|
-
note: "moved to magus-marketing",
|
|
1019
|
+
reason: "not-offered",
|
|
1006
1020
|
});
|
|
1021
|
+
expect(item.note).not.toMatch(/moved/i);
|
|
1007
1022
|
});
|
|
1008
1023
|
|
|
1009
1024
|
test("an orphan whose marketplace we DID read is 'not offered'", () => {
|
|
@@ -1060,9 +1075,17 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
|
|
|
1060
1075
|
});
|
|
1061
1076
|
});
|
|
1062
1077
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1078
|
+
// REVERSES F3, deliberately. `unorderable` is gone as a state: when two
|
|
1079
|
+
// versions cannot be ordered we assume what is installed IS the latest, rather
|
|
1080
|
+
// than raising a warning nothing can clear.
|
|
1081
|
+
//
|
|
1082
|
+
// F3's premise was that "cannot compare" might hide a plugin that is behind.
|
|
1083
|
+
// True, but the cost was paid on every run by rows that were fine — an install
|
|
1084
|
+
// recorded as a commit sha can never be ordered against a semver pin, so the
|
|
1085
|
+
// warning was permanent and unactionable. Do not restore `unorderable` without
|
|
1086
|
+
// changing that rule first.
|
|
1087
|
+
describe("classifyPlugin — an unorderable version is assumed current", () => {
|
|
1088
|
+
test("a pinned plugin whose install cannot be ordered is current", () => {
|
|
1066
1089
|
const [item] = planPluginUpdates(
|
|
1067
1090
|
{ "playground@official": "7.3.0" },
|
|
1068
1091
|
catalog(
|
|
@@ -1073,16 +1096,13 @@ describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)
|
|
|
1073
1096
|
}),
|
|
1074
1097
|
),
|
|
1075
1098
|
);
|
|
1076
|
-
expect(item).
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
scopes: [],
|
|
1081
|
-
note: "installed version cannot be compared with the published 7.3.0",
|
|
1082
|
-
});
|
|
1099
|
+
expect(item.action).toBe("current");
|
|
1100
|
+
expect(item.reason).not.toBe("unorderable");
|
|
1101
|
+
// Nothing is planned against it, which is the part F3 actually protected.
|
|
1102
|
+
expect(item.scopes).toEqual([]);
|
|
1083
1103
|
});
|
|
1084
1104
|
|
|
1085
|
-
test("
|
|
1105
|
+
test("no published version, under an exact pin, is also current", () => {
|
|
1086
1106
|
const [item] = planPluginUpdates(
|
|
1087
1107
|
{ "playground@official": "7.3.0" },
|
|
1088
1108
|
catalog(
|
|
@@ -1093,11 +1113,14 @@ describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)
|
|
|
1093
1113
|
}),
|
|
1094
1114
|
),
|
|
1095
1115
|
);
|
|
1096
|
-
expect(item).
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1116
|
+
expect(item.action).toBe("current");
|
|
1117
|
+
expect(item.note).toBeUndefined();
|
|
1118
|
+
// The exact-pin path reports `up-to-date` here where the "latest" path
|
|
1119
|
+
// reports `no-published-version` — same verdict, different machine code.
|
|
1120
|
+
// Asserted as observed rather than as preferred: nothing renders the two
|
|
1121
|
+
// differently today, and forcing them to agree is a change to the
|
|
1122
|
+
// classifier, not to this test.
|
|
1123
|
+
expect(item.reason).toBe("up-to-date");
|
|
1101
1124
|
});
|
|
1102
1125
|
|
|
1103
1126
|
test("a scope that IS behind still wins: it is planned, not hidden", () => {
|