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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"magus-cli-darwin-arm64": "7.
|
|
68
|
-
"magus-cli-darwin-x64": "7.
|
|
69
|
-
"magus-cli-linux-x64": "7.
|
|
67
|
+
"magus-cli-darwin-arm64": "7.2.0",
|
|
68
|
+
"magus-cli-darwin-x64": "7.2.0",
|
|
69
|
+
"magus-cli-linux-x64": "7.2.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -132,7 +132,6 @@ function makeCatalog(): PluginInfo[] {
|
|
|
132
132
|
// _synthetic orphans
|
|
133
133
|
row("seo@magus", null, "1.0.0", {
|
|
134
134
|
isOrphaned: true,
|
|
135
|
-
movedTo: "magus-marketing",
|
|
136
135
|
}),
|
|
137
136
|
row("legacy-tool@magus", null, "0.5.0", { isOrphaned: true }),
|
|
138
137
|
row("seo@magus-marketing", "1.1.0", null),
|
|
@@ -442,7 +441,12 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
|
|
|
442
441
|
}
|
|
443
442
|
});
|
|
444
443
|
|
|
445
|
-
|
|
444
|
+
// These 5 are CURRENT now, not unknown: their marketplace publishes no
|
|
445
|
+
// version, so there is nothing newer to be behind. R7 is unchanged and is
|
|
446
|
+
// still what this asserts — the two front ends must give the same answer,
|
|
447
|
+
// whatever that answer is. The reason code survives so the WHY is still
|
|
448
|
+
// recorded; the note does not, because nothing here is actionable.
|
|
449
|
+
test("the 5 official plugins get the same verdict on both paths", async () => {
|
|
446
450
|
const p = await runBothPaths();
|
|
447
451
|
|
|
448
452
|
for (const id of Object.keys(REAL_OFFICIAL)) {
|
|
@@ -450,14 +454,15 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
|
|
|
450
454
|
const tui = p.tuiRows.get(id)?.status;
|
|
451
455
|
expect({ id, action: cli?.action, reason: cli?.reason }).toEqual({
|
|
452
456
|
id,
|
|
453
|
-
action: "
|
|
457
|
+
action: "current",
|
|
454
458
|
reason: "no-published-version",
|
|
455
459
|
});
|
|
456
|
-
|
|
460
|
+
// Parity, the point of the file: same note (none), same rendered row.
|
|
457
461
|
expect(tui?.note).toBe(cli?.note);
|
|
462
|
+
expect(cli?.note).toBeUndefined();
|
|
458
463
|
const row = p.tuiRows.get(id);
|
|
459
464
|
if (!row) throw new Error(`no TUI row for ${id}`);
|
|
460
|
-
expect(pluginRowText(row).text).toContain(NO_PUBLISHED_VERSION_NOTE);
|
|
465
|
+
expect(pluginRowText(row).text).not.toContain(NO_PUBLISHED_VERSION_NOTE);
|
|
461
466
|
}
|
|
462
467
|
});
|
|
463
468
|
|
|
@@ -467,7 +472,12 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
|
|
|
467
472
|
string,
|
|
468
473
|
{ action: string; reason?: string; target?: string | null }
|
|
469
474
|
> = {
|
|
470
|
-
|
|
475
|
+
// No published version to be behind → current. The reason still records
|
|
476
|
+
// that the verdict came from an absent catalog version, not a match.
|
|
477
|
+
[`zero-version@${OFFICIAL}`]: {
|
|
478
|
+
action: "current",
|
|
479
|
+
reason: "no-published-version",
|
|
480
|
+
},
|
|
471
481
|
[`typescript-lsp@${OFFICIAL}`]: {
|
|
472
482
|
action: "current",
|
|
473
483
|
reason: "up-to-date",
|
|
@@ -482,7 +492,10 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
|
|
|
482
492
|
reason: "catalog-unverified",
|
|
483
493
|
target: null,
|
|
484
494
|
},
|
|
485
|
-
|
|
495
|
+
// Was `moved`, which guessed a destination from a bare name match and
|
|
496
|
+
// could not tell a move from a dual publication. An orphan is simply
|
|
497
|
+
// not offered.
|
|
498
|
+
"seo@magus": { action: "unknown", reason: "not-offered", target: null },
|
|
486
499
|
"legacy-tool@magus": {
|
|
487
500
|
action: "unknown",
|
|
488
501
|
reason: "not-offered",
|
|
@@ -493,11 +506,10 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
|
|
|
493
506
|
reason: "behind",
|
|
494
507
|
target: "2.1.0",
|
|
495
508
|
},
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
},
|
|
509
|
+
// `unorderable` is gone: when two versions cannot be ordered we assume
|
|
510
|
+
// the install is the latest rather than raising a warning no action
|
|
511
|
+
// can clear.
|
|
512
|
+
"pinned-unorderable@magus": { action: "current" },
|
|
501
513
|
};
|
|
502
514
|
|
|
503
515
|
for (const [id, want] of Object.entries(expected)) {
|
|
@@ -563,8 +575,16 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
|
|
|
563
575
|
expect(countPluginStates(tuiItems)).toEqual(cliCounts);
|
|
564
576
|
expect(cliCounts.install).toBe(1); // bunjs
|
|
565
577
|
expect(cliCounts.repair).toBe(1); // madbench
|
|
566
|
-
//
|
|
567
|
-
|
|
578
|
+
// Was 10. Seven of those rows are `current` now, and each for the same
|
|
579
|
+
// reason: nothing could be compared, so nothing is known to be behind.
|
|
580
|
+
// − 5 official (marketplace publishes no version)
|
|
581
|
+
// − zero-version (installed "0.0.0", no published version)
|
|
582
|
+
// − pinned-unorderable (sha install against a semver pin)
|
|
583
|
+
// What is left is unknown because something is genuinely wrong or unread:
|
|
584
|
+
// claudish catalog-unverified — the check itself failed
|
|
585
|
+
// seo@magus not-offered — an orphan
|
|
586
|
+
// legacy-tool not-offered — an orphan
|
|
587
|
+
expect(cliCounts.unknown).toBe(3);
|
|
568
588
|
expect(cliCounts.update).toBeGreaterThanOrEqual(2); // designer, pinned-behind
|
|
569
589
|
});
|
|
570
590
|
|
|
@@ -671,14 +691,35 @@ describe("fixture discriminates the pre-change logic", () => {
|
|
|
671
691
|
}
|
|
672
692
|
});
|
|
673
693
|
|
|
674
|
-
|
|
694
|
+
// The fixture must still tell the two designs apart, or every test above it
|
|
695
|
+
// could pass against the old flag-derived logic and prove nothing.
|
|
696
|
+
//
|
|
697
|
+
// It no longer discriminates on the UNVERSIONED rows: both call them
|
|
698
|
+
// healthy/current. That is a real narrowing and it is stated rather than
|
|
699
|
+
// hidden — the surviving difference there is that the classifier records WHY
|
|
700
|
+
// (`no-published-version`) while the flags carry no reason at all.
|
|
701
|
+
test("flag-derived state cannot reach the states the classifier does", async () => {
|
|
675
702
|
const p = await runBothPaths();
|
|
676
|
-
const ids = [...Object.keys(REAL_OFFICIAL), `zero-version@${OFFICIAL}`];
|
|
677
703
|
|
|
678
|
-
|
|
704
|
+
// Same verdict, different information: the flags have no reason field.
|
|
705
|
+
for (const id of [
|
|
706
|
+
...Object.keys(REAL_OFFICIAL),
|
|
707
|
+
`zero-version@${OFFICIAL}`,
|
|
708
|
+
]) {
|
|
709
|
+
const info = p.catalog.find((c) => c.id === id) as PluginInfo;
|
|
710
|
+
expect(legacyFlagState(info)).toBe("healthy");
|
|
711
|
+
expect(p.tuiRows.get(id)?.status?.action).toBe("current");
|
|
712
|
+
expect(p.tuiRows.get(id)?.status?.reason).toBe("no-published-version");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Where they still disagree outright: an orphan carries neither
|
|
716
|
+
// `hasUpdate` nor `updateCheckFailed`, so the flags call it healthy while
|
|
717
|
+
// the classifier says no marketplace offers it at all.
|
|
718
|
+
for (const id of ["seo@magus", "legacy-tool@magus"]) {
|
|
679
719
|
const info = p.catalog.find((c) => c.id === id) as PluginInfo;
|
|
680
720
|
expect(legacyFlagState(info)).toBe("healthy");
|
|
681
721
|
expect(p.tuiRows.get(id)?.status?.action).toBe("unknown");
|
|
722
|
+
expect(p.tuiRows.get(id)?.status?.reason).toBe("not-offered");
|
|
682
723
|
}
|
|
683
724
|
});
|
|
684
725
|
});
|
|
@@ -170,7 +170,12 @@ describe("classifyPlugin — one state plus a human reason", () => {
|
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
// Report #1 asked for the opposite of this, and that decision was REVERSED:
|
|
174
|
+
// a catalog that publishes no version leaves the install `current`, because
|
|
175
|
+
// there is no newer version to be behind. The reason code still records WHY
|
|
176
|
+
// the verdict was reached, so the state is not lost — it just stops being
|
|
177
|
+
// reported as a problem the reader must act on.
|
|
178
|
+
test("unversioned catalog with a commit-sha install is current, with the reason kept", () => {
|
|
174
179
|
const idx = indexCatalog([
|
|
175
180
|
info("code-review@claude-plugins-official", null, "3ea32df27be7"),
|
|
176
181
|
]);
|
|
@@ -181,16 +186,12 @@ describe("classifyPlugin — one state plus a human reason", () => {
|
|
|
181
186
|
idx,
|
|
182
187
|
);
|
|
183
188
|
|
|
184
|
-
expect({
|
|
185
|
-
action:
|
|
186
|
-
reason: got.reason,
|
|
187
|
-
target: got.target,
|
|
188
|
-
}).toEqual({
|
|
189
|
-
action: "unknown",
|
|
189
|
+
expect({ action: got.action, reason: got.reason }).toEqual({
|
|
190
|
+
action: "current",
|
|
190
191
|
reason: "no-published-version",
|
|
191
|
-
target: null,
|
|
192
192
|
});
|
|
193
|
-
|
|
193
|
+
// No note: nothing here is actionable, so there is nothing to tell anyone.
|
|
194
|
+
expect(got.note).toBeUndefined();
|
|
194
195
|
});
|
|
195
196
|
|
|
196
197
|
test("a failed catalog check on a latest pin is unknown / catalog-unverified, never current", () => {
|
|
@@ -214,13 +215,11 @@ describe("classifyPlugin — one state plus a human reason", () => {
|
|
|
214
215
|
info("e@nowhere", null, "0.3.0", { isOrphaned: true }),
|
|
215
216
|
]);
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
"e@nowhere",
|
|
223
|
-
]) {
|
|
218
|
+
// `a@magus` is deliberately NOT in this list: a catalog that publishes no
|
|
219
|
+
// version is `current` now, not unknown. It is the negative control
|
|
220
|
+
// below — without it this loop would pass even if every state collapsed
|
|
221
|
+
// into one.
|
|
222
|
+
for (const id of ["b@magus", "c@magus", "d@magus", "e@nowhere"]) {
|
|
224
223
|
const got = classifyPlugin(id, "latest", idx);
|
|
225
224
|
expect({ id, action: got.action }).toEqual({ id, action: "unknown" });
|
|
226
225
|
expect({ id, target: got.target, hasNote: Boolean(got.note) }).toEqual({
|
|
@@ -229,6 +228,12 @@ describe("classifyPlugin — one state plus a human reason", () => {
|
|
|
229
228
|
hasNote: true,
|
|
230
229
|
});
|
|
231
230
|
}
|
|
231
|
+
|
|
232
|
+
// The control: same catalog, same call, the opposite verdict — and
|
|
233
|
+
// crucially no note, because nothing about it is actionable.
|
|
234
|
+
const a = classifyPlugin("a@magus", "latest", idx);
|
|
235
|
+
expect(a.action).toBe("current");
|
|
236
|
+
expect(a.note).toBeUndefined();
|
|
232
237
|
});
|
|
233
238
|
});
|
|
234
239
|
|
|
@@ -486,14 +491,31 @@ describe("pluginRowText", () => {
|
|
|
486
491
|
};
|
|
487
492
|
}
|
|
488
493
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
494
|
+
// The gate this guards: every status clause used to be conditional on the
|
|
495
|
+
// installed version being displayable, so a plugin recorded as "0.0.0"
|
|
496
|
+
// printed nothing after its name and read as healthy.
|
|
497
|
+
//
|
|
498
|
+
// The fixture is an ORPHAN at 0.0.0, not a plain 0.0.0 install. A plain one
|
|
499
|
+
// is `current` with no note now, which would leave nothing for this test to
|
|
500
|
+
// find and let the gate come back unnoticed.
|
|
501
|
+
test("a note renders on a 0.0.0 install — no version gate", () => {
|
|
502
|
+
const plugin = info("zero-version@magus", null, "0.0.0", {
|
|
503
|
+
isOrphaned: true,
|
|
504
|
+
});
|
|
505
|
+
const sibling = info("dev@magus", "1.0.0", "1.0.0");
|
|
506
|
+
const status = classifyPlugin(
|
|
507
|
+
plugin.id,
|
|
508
|
+
"latest",
|
|
509
|
+
indexCatalog([plugin, sibling]),
|
|
510
|
+
);
|
|
492
511
|
|
|
493
512
|
const out = pluginRowText(rowItem(plugin, status));
|
|
494
513
|
|
|
495
514
|
expect(status.action).toBe("unknown");
|
|
515
|
+
expect(status.note).toBeTruthy();
|
|
496
516
|
expect(out.text).toContain(status.note ?? "<missing note>");
|
|
517
|
+
// The undisplayable version itself is still never printed.
|
|
518
|
+
expect(out.text).not.toContain("0.0.0");
|
|
497
519
|
});
|
|
498
520
|
|
|
499
521
|
test("text is exactly the segments joined, and contains the status suffix", () => {
|
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
stepRecord,
|
|
26
26
|
summaryLine,
|
|
27
27
|
uncheckedGateNotice,
|
|
28
|
-
unknownPluginsNotice,
|
|
29
28
|
unknownToolsNotice,
|
|
30
29
|
} from "../cli/update-view.js";
|
|
31
30
|
import type { UpdatePlan } from "../services/update-plan.js";
|
|
@@ -167,7 +166,10 @@ describe("alignment", () => {
|
|
|
167
166
|
const rows = planReport(p).filter((l) => l.includes("@"));
|
|
168
167
|
const cols = rows.map((r) => r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/));
|
|
169
168
|
expect(new Set(cols).size).toBe(1);
|
|
170
|
-
|
|
169
|
+
// Not `rows[0]`: grouping means row order follows state, not plan order,
|
|
170
|
+
// and this fixture's long id is `current` — the LAST group. The claim is
|
|
171
|
+
// that the id is printed in full somewhere, not where it lands.
|
|
172
|
+
expect(rows.some((r) => r.includes(long))).toBe(true);
|
|
171
173
|
});
|
|
172
174
|
});
|
|
173
175
|
|
|
@@ -195,13 +197,30 @@ describe("NO_COLOR", () => {
|
|
|
195
197
|
test("every action stays distinguishable without colour", () => {
|
|
196
198
|
setColorEnabled(false);
|
|
197
199
|
const out = planReport(plan()).join("\n");
|
|
198
|
-
// Colour never carries meaning alone:
|
|
199
|
-
//
|
|
200
|
-
|
|
200
|
+
// Colour never carries meaning alone: the report has to survive a pipe
|
|
201
|
+
// and colour blindness. Plugins say their state as a GROUP HEADING and a
|
|
202
|
+
// glyph; the tool and skill sections still badge each row with its own
|
|
203
|
+
// action word.
|
|
204
|
+
for (const heading of ["Update", "Fix required", "Current"])
|
|
205
|
+
expect(out).toContain(heading);
|
|
206
|
+
for (const label of ["CURRENT", "UPGRADE", "INSTALL"])
|
|
201
207
|
expect(out).toContain(label);
|
|
202
208
|
for (const glyph of ["=", "↑", "+", "?"]) expect(out).toContain(glyph);
|
|
203
209
|
});
|
|
204
210
|
|
|
211
|
+
// Two states that an apply treats very differently, and which a reader must
|
|
212
|
+
// never have to tell apart by hue. `unknown` is grouped under Current and
|
|
213
|
+
// wears `?`; an update wears `↑` under its own heading.
|
|
214
|
+
test("an unverified row is not confusable with an up-to-date one", () => {
|
|
215
|
+
setColorEnabled(false);
|
|
216
|
+
const rows = planReport(plan());
|
|
217
|
+
const unknown = rows.find((l) => l.includes("designer@magus"))!;
|
|
218
|
+
const current = rows.find((l) => l.includes("agentdev@magus"))!;
|
|
219
|
+
expect(unknown).toContain("?");
|
|
220
|
+
expect(current).toContain("=");
|
|
221
|
+
expect(unknown).toContain("catalog unreachable");
|
|
222
|
+
});
|
|
223
|
+
|
|
205
224
|
test("a note rides along on the row it explains", () => {
|
|
206
225
|
setColorEnabled(false);
|
|
207
226
|
const row = planReport(plan()).find((l) => l.includes("designer@magus"))!;
|
|
@@ -240,7 +259,10 @@ describe("bars", () => {
|
|
|
240
259
|
expect(frame[2]).not.toMatch(/[█░]/);
|
|
241
260
|
});
|
|
242
261
|
|
|
243
|
-
|
|
262
|
+
// The record carries NO bar: a share-of-the-slowest meter restated the
|
|
263
|
+
// millisecond column next to it. The durations themselves still have to be
|
|
264
|
+
// right, and still have to rank the steps.
|
|
265
|
+
test("the record reports each step's duration and draws no bar", () => {
|
|
244
266
|
setColorEnabled(false);
|
|
245
267
|
const record = stepRecord(
|
|
246
268
|
[
|
|
@@ -261,18 +283,19 @@ describe("bars", () => {
|
|
|
261
283
|
],
|
|
262
284
|
1000,
|
|
263
285
|
);
|
|
264
|
-
const
|
|
265
|
-
expect(
|
|
266
|
-
expect(
|
|
286
|
+
for (const row of record) expect(row).not.toMatch(/[█░]/);
|
|
287
|
+
expect(record[0]).toContain("100ms");
|
|
288
|
+
expect(record[1]).toContain("1.0s");
|
|
267
289
|
});
|
|
268
290
|
|
|
269
|
-
test("a step that never ran does not
|
|
291
|
+
test("a step that never ran reports no elapsed time, and does not throw", () => {
|
|
270
292
|
setColorEnabled(false);
|
|
271
293
|
const record = stepRecord(
|
|
272
294
|
[{ label: "x", state: "pending", detail: "" }],
|
|
273
295
|
0,
|
|
274
296
|
);
|
|
275
|
-
expect(record[0]).toContain("
|
|
297
|
+
expect(record[0]).toContain("x");
|
|
298
|
+
expect(record[0]).not.toMatch(/[█░]/);
|
|
276
299
|
});
|
|
277
300
|
|
|
278
301
|
test("the apply meter is full exactly when the work is done", () => {
|
|
@@ -317,13 +340,69 @@ describe("sections", () => {
|
|
|
317
340
|
expect(out).not.toContain("Skills");
|
|
318
341
|
});
|
|
319
342
|
|
|
320
|
-
|
|
343
|
+
// The Plugins section is grouped by state, so its counts live on the group
|
|
344
|
+
// headings, not on one legend line. The invariant that matters is the one
|
|
345
|
+
// that broke once already: the counts printed must add up to the rows
|
|
346
|
+
// printed. A fixed heading order used to render an unnamed group as nothing
|
|
347
|
+
// while the total above still counted it — "Plugins 4" over three rows.
|
|
348
|
+
test("every plugin row sits under a heading, and the headings add up", () => {
|
|
321
349
|
setColorEnabled(false);
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
expect(
|
|
325
|
-
|
|
326
|
-
|
|
350
|
+
const out = planReport(plan());
|
|
351
|
+
const total = out.find((l) => /^\s+Plugins\s+\d+$/.test(l))!;
|
|
352
|
+
expect(total).toContain("4");
|
|
353
|
+
|
|
354
|
+
// Matches ANY "<words> <count>" heading, not a fixed alternation of the
|
|
355
|
+
// group names. A named list silently stops counting a group that is added
|
|
356
|
+
// later, which is the same blind spot as the fixed order above — the sum
|
|
357
|
+
// then holds against a subset and the test passes while rows go unheaded.
|
|
358
|
+
const headings = out.flatMap((l) => {
|
|
359
|
+
const m = l.match(/^\s+([A-Z][A-Za-z ]+?)\s\s+(\d+)$/);
|
|
360
|
+
return m && m[1] !== "Plugins"
|
|
361
|
+
? [{ group: m[1], count: Number(m[2]) }]
|
|
362
|
+
: [];
|
|
363
|
+
});
|
|
364
|
+
// This fixture has one of each except Deprecated, whose row would need a
|
|
365
|
+
// `not-offered` reason. `designer@magus` is unknown with a note, so it is
|
|
366
|
+
// Unverified — NOT Current, which would claim a failed check succeeded.
|
|
367
|
+
expect(headings.map((h) => h.group)).toEqual([
|
|
368
|
+
"Update",
|
|
369
|
+
"Fix required",
|
|
370
|
+
"Unverified",
|
|
371
|
+
"Current",
|
|
372
|
+
]);
|
|
373
|
+
// Every counted row is a row on screen: 1 + 1 + 1 + 1 === the 4 above.
|
|
374
|
+
const rows = out.filter((l) => l.includes("@"));
|
|
375
|
+
expect(headings.reduce((n, h) => n + h.count, 0)).toBe(rows.length);
|
|
376
|
+
expect(rows.length).toBe(4);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// The regression the reviewer caught before release: a pin naming a plugin no
|
|
380
|
+
// marketplace offers, with nothing installed, was filtered out of the table
|
|
381
|
+
// on the grounds that a notice printed it instead. That notice never existed.
|
|
382
|
+
// The row appeared nowhere while the closing lines still counted it.
|
|
383
|
+
test("a pin nothing offers still gets a row", () => {
|
|
384
|
+
setColorEnabled(false);
|
|
385
|
+
const p = plan();
|
|
386
|
+
p.plugins = [
|
|
387
|
+
{
|
|
388
|
+
pluginId: "agentdev@magus",
|
|
389
|
+
pinned: "latest",
|
|
390
|
+
installed: null,
|
|
391
|
+
available: null,
|
|
392
|
+
target: null,
|
|
393
|
+
action: "unknown",
|
|
394
|
+
reason: "not-offered",
|
|
395
|
+
scopes: [],
|
|
396
|
+
},
|
|
397
|
+
...p.plugins,
|
|
398
|
+
] as UpdatePlan["plugins"];
|
|
399
|
+
|
|
400
|
+
const out = planReport(p);
|
|
401
|
+
expect(out.join("\n")).toContain("agentdev@magus");
|
|
402
|
+
// And it is counted where it is shown, not somewhere else.
|
|
403
|
+
const total = out.find((l) => /^\s+Plugins\s+\d+$/.test(l))!;
|
|
404
|
+
expect(total).toContain("5");
|
|
405
|
+
expect(out.filter((l) => l.includes("@")).length).toBe(5);
|
|
327
406
|
});
|
|
328
407
|
|
|
329
408
|
test("a CLI upgrade shows the command that will run", () => {
|
|
@@ -398,7 +477,6 @@ describe("unverified notices", () => {
|
|
|
398
477
|
// once the catalog is reachable", which is false advice for a plugin whose
|
|
399
478
|
// marketplace publishes no version — the case a real dry run showed five of.
|
|
400
479
|
const notices = () => [
|
|
401
|
-
unknownPluginsNotice(5),
|
|
402
480
|
nothingToApplyNotice(5),
|
|
403
481
|
unknownToolsNotice(1, ["crush"]),
|
|
404
482
|
uncheckedGateNotice(6),
|
|
@@ -413,11 +491,12 @@ describe("unverified notices", () => {
|
|
|
413
491
|
}
|
|
414
492
|
});
|
|
415
493
|
|
|
494
|
+
// There is no per-plugin notice under the plan any more: every unverified
|
|
495
|
+
// plugin is a row in the table, under a heading naming its state, so the
|
|
496
|
+
// line restated the table and miscounted it. `nothingToApplyNotice` is a
|
|
497
|
+
// different case — it fires when there IS no table worth reading.
|
|
416
498
|
test("an unverified plugin is never reported as up to date", () => {
|
|
417
499
|
setColorEnabled(false);
|
|
418
|
-
const under = unknownPluginsNotice(5);
|
|
419
|
-
expect(under).toContain("5 plugin(s) could not be checked");
|
|
420
|
-
expect(under).toContain("They are NOT reported as up to date.");
|
|
421
500
|
const nothing = nothingToApplyNotice(5);
|
|
422
501
|
expect(nothing.trimStart()).toStartWith("⚠ Nothing to apply.");
|
|
423
502
|
expect(nothing).toContain("5 plugin(s) could not be checked");
|
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "fs-extra";
|
|
5
5
|
import {
|
|
6
|
+
registeredMarketplaceNames,
|
|
6
7
|
scanMcpServers,
|
|
7
8
|
scanSkillDirs,
|
|
8
9
|
} from "../services/local-marketplace.js";
|
|
@@ -172,3 +173,76 @@ describe("clone walkers", () => {
|
|
|
172
173
|
expect(await walkMarkdown(path.join(root, "nope"))).toEqual([]);
|
|
173
174
|
});
|
|
174
175
|
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* REGRESSION: a directory under `marketplaces/` was treated as a marketplace.
|
|
179
|
+
*
|
|
180
|
+
* `scanLocalMarketplaces` enumerated the directory and accepted anything with a
|
|
181
|
+
* parseable manifest — never asking `known_marketplaces.json` whether it was
|
|
182
|
+
* registered. Claude Code refreshes a marketplace by delete-then-clone and
|
|
183
|
+
* leaves `<name>.bak` behind, so the reporter's machine carried a 13M
|
|
184
|
+
* `magus.bak` holding an older magus manifest. It cost them twice:
|
|
185
|
+
*
|
|
186
|
+
* - `getAllMarketplaces()` dedupes by repo, first-come, and `magus.bak`
|
|
187
|
+
* reported the same repo — so the REAL magus was dropped from the plugin
|
|
188
|
+
* list entirely. That is the "nothing installed and magus is not first"
|
|
189
|
+
* screenshot.
|
|
190
|
+
* - `findPluginInOtherMarketplace` then answered from it: `kanban@magus`
|
|
191
|
+
* rendered `moved → magus.bak`, pointing the user at a backup folder.
|
|
192
|
+
*
|
|
193
|
+
* `claude plugin marketplace list` never showed it — six entries, never seven —
|
|
194
|
+
* because Claude Code reads the registry. This is magus agreeing with it.
|
|
195
|
+
*/
|
|
196
|
+
describe("registeredMarketplaceNames", () => {
|
|
197
|
+
it("accepts a registry key", () => {
|
|
198
|
+
const names = registeredMarketplaceNames({
|
|
199
|
+
magus: { installLocation: "/h/.claude/plugins/marketplaces/magus" },
|
|
200
|
+
});
|
|
201
|
+
expect(names?.has("magus")).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("rejects a directory with no registry entry — the magus.bak case", () => {
|
|
205
|
+
const names = registeredMarketplaceNames({
|
|
206
|
+
magus: { installLocation: "/h/.claude/plugins/marketplaces/magus" },
|
|
207
|
+
});
|
|
208
|
+
expect(names?.has("magus.bak")).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("also accepts the basename of installLocation when it differs from the key", () => {
|
|
212
|
+
// Nothing enforces key === directory name. Dropping a real marketplace is
|
|
213
|
+
// far worse than keeping a stray directory, so both spellings count.
|
|
214
|
+
const names = registeredMarketplaceNames({
|
|
215
|
+
magus: {
|
|
216
|
+
installLocation: "/h/.claude/plugins/marketplaces/magus-pinned",
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
expect(names?.has("magus")).toBe(true);
|
|
220
|
+
expect(names?.has("magus-pinned")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns null for input it cannot use, so nothing is filtered", () => {
|
|
224
|
+
// THE failure mode to avoid. Null means "no opinion" and the caller keeps
|
|
225
|
+
// every directory. Returning an empty set here instead would delete every
|
|
226
|
+
// marketplace on a corrupt registry, and every installed plugin would
|
|
227
|
+
// report as withdrawn — the exact "unknown rendered as a determinate
|
|
228
|
+
// negative" this whole change removes, rebuilt one level up.
|
|
229
|
+
expect(registeredMarketplaceNames(null)).toBeNull();
|
|
230
|
+
expect(registeredMarketplaceNames(undefined)).toBeNull();
|
|
231
|
+
expect(registeredMarketplaceNames("not an object")).toBeNull();
|
|
232
|
+
expect(registeredMarketplaceNames(["magus"])).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("treats an empty registry as an answer, not as missing data", () => {
|
|
236
|
+
// A file that exists and parses to {} says nothing is registered. An
|
|
237
|
+
// ABSENT file is what returns null, upstream in readKnownMarketplacesOrNull.
|
|
238
|
+
const names = registeredMarketplaceNames({});
|
|
239
|
+
expect(names).not.toBeNull();
|
|
240
|
+
expect(names?.size).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("survives an entry with no installLocation", () => {
|
|
244
|
+
const names = registeredMarketplaceNames({ magus: {} });
|
|
245
|
+
expect(names?.has("magus")).toBe(true);
|
|
246
|
+
expect(names?.size).toBe(1);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -214,19 +214,24 @@ describe("refreshRegisteredMarketplaces — selection & skips", () => {
|
|
|
214
214
|
expect(r.skipped).toEqual(["local"]);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
// `autoUpdateDisabled` is GONE, and so is the warning built on it. The
|
|
218
|
+
// premise was that opting out freezes the catalog, so the marketplace's
|
|
219
|
+
// plugins read as up to date forever. MEASURED against a real clone, that is
|
|
220
|
+
// false: `readCatalogFromRemote` does its own `git fetch` plus
|
|
221
|
+
// `git show origin/<branch>`, so a clone pinned at dev 7.4.0 with autoUpdate
|
|
222
|
+
// off still reported 7.4.1 available. The flag does not hide updates, and the
|
|
223
|
+
// warning told users to fix something that was not broken.
|
|
224
|
+
//
|
|
225
|
+
// It is an ordinary skip now: nothing was pulled, and that is all we claim.
|
|
226
|
+
it("an autoUpdate === false marketplace is an ordinary skip", async () => {
|
|
222
227
|
installClone("magus");
|
|
223
228
|
publishV2();
|
|
224
229
|
configured = { magus: gh() };
|
|
225
230
|
autoUpdate = { magus: false };
|
|
226
231
|
const r = await refreshRegisteredMarketplaces();
|
|
227
232
|
expect(r.refreshed).toEqual([]);
|
|
228
|
-
expect(r.
|
|
229
|
-
expect(r
|
|
233
|
+
expect(r.skipped).toEqual(["magus"]);
|
|
234
|
+
expect(r).not.toHaveProperty("autoUpdateDisabled");
|
|
230
235
|
});
|
|
231
236
|
|
|
232
237
|
it("honors the skip set (already handled this run)", async () => {
|