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.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +58 -17
  3. package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
  4. package/src/__tests__/cli-update-view.test.ts +100 -21
  5. package/src/__tests__/local-marketplace-scan.test.ts +74 -0
  6. package/src/__tests__/marketplace-refresh.test.ts +12 -7
  7. package/src/__tests__/plugin-row-text.test.ts +33 -24
  8. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  9. package/src/__tests__/profile-migration.test.ts +242 -0
  10. package/src/__tests__/profile-startup.test.ts +458 -0
  11. package/src/__tests__/theme-env.test.ts +29 -0
  12. package/src/__tests__/update-plan.test.ts +57 -34
  13. package/src/__tests__/version-unknown-state.test.ts +29 -14
  14. package/src/cli/ansi.ts +58 -11
  15. package/src/cli/bootstrap.ts +169 -3
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/router.ts +59 -2
  20. package/src/cli/tui-prompt.tsx +143 -0
  21. package/src/cli/update-view.ts +237 -45
  22. package/src/cli/update.ts +59 -29
  23. package/src/main.tsx +11 -2
  24. package/src/services/claude-settings.ts +15 -40
  25. package/src/services/local-marketplace.ts +76 -6
  26. package/src/services/manifest.ts +21 -0
  27. package/src/services/marketplace-refresh.ts +7 -15
  28. package/src/services/plugin-manager.ts +0 -31
  29. package/src/services/profile-activate.ts +147 -0
  30. package/src/services/profile-adopt.ts +50 -4
  31. package/src/services/profile-startup.ts +311 -0
  32. package/src/services/profiles.ts +49 -11
  33. package/src/services/resolver.ts +33 -0
  34. package/src/services/symlink-manager.ts +184 -30
  35. package/src/services/update-plan.ts +23 -66
  36. package/src/types/index.ts +44 -23
  37. package/src/ui/App.tsx +60 -3
  38. package/src/ui/components/layout/FooterHints.tsx +21 -9
  39. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  40. package/src/ui/components/modals/SelectModal.tsx +142 -21
  41. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  42. package/src/ui/renderers/profileRenderers.tsx +27 -4
  43. package/src/ui/screens/PluginsScreen.tsx +9 -50
  44. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  45. package/src/ui/state/types.ts +18 -0
  46. package/src/ui/theme-mode.ts +142 -0
  47. package/src/utils/command-utils.ts +19 -14
  48. 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
- test("an installed plugin whose catalog declares no version is 'unknown'", () => {
407
- // This fixture puts dev@magus IN the catalog with a null version, so it is
408
- // the declares-no-version case, not the withdrawn one — the assertion used
409
- // to say "not offered", which is what conflated them. 278 of the 292
410
- // plugins in claude-plugins-official are in exactly this state.
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("unknown");
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
- test("an orphan another marketplace publishes is 'moved', naming it", () => {
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", { movedTo: "magus-marketing" }),
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: "moved",
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
- describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)", () => {
1064
- test("REGRESSION: a pinned plugin at an unorderable version used to be current", () => {
1065
- // The "latest" path already refused this; the exact-pin path did not.
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).toMatchObject({
1077
- action: "unknown",
1078
- reason: "unorderable",
1079
- target: null,
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("with no published version the note names the pin instead", () => {
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).toMatchObject({
1097
- action: "unknown",
1098
- reason: "unorderable",
1099
- note: "installed version cannot be compared with the pin 7.3.0",
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", () => {