claudeup 6.5.1 → 6.7.1

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 (85) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/active-closure.test.ts +184 -0
  3. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  4. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  5. package/src/__tests__/blackbox-parity.test.ts +684 -0
  6. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  7. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  8. package/src/__tests__/cli-apply-seams.test.ts +2 -0
  9. package/src/__tests__/cli-tool-commands.test.ts +1 -1
  10. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  11. package/src/__tests__/cli-update-view.test.ts +142 -2
  12. package/src/__tests__/content-drift.test.ts +60 -0
  13. package/src/__tests__/conventions-manager.test.ts +45 -45
  14. package/src/__tests__/enabled-not-installed.test.ts +2 -3
  15. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  16. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  17. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  18. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  19. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  20. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  21. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  22. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  23. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  24. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  25. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  26. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  27. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  28. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  29. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  30. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  31. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  32. package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
  33. package/src/__tests__/models-presets-marketplace.test.ts +141 -0
  34. package/src/__tests__/plugin-cli-argv.test.ts +1 -1
  35. package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
  36. package/src/__tests__/plugin-row-text.test.ts +336 -0
  37. package/src/__tests__/resolver.test.ts +3 -3
  38. package/src/__tests__/scope-action.test.ts +69 -19
  39. package/src/__tests__/shell-script-callers.test.ts +8 -8
  40. package/src/__tests__/tool-probe.test.ts +1400 -0
  41. package/src/__tests__/update-apply.test.ts +283 -1
  42. package/src/__tests__/update-plan.test.ts +773 -31
  43. package/src/__tests__/version-unknown-state.test.ts +388 -0
  44. package/src/cli/router.ts +0 -5
  45. package/src/cli/update-view.ts +92 -12
  46. package/src/cli/update.ts +114 -38
  47. package/src/data/models-presets.ts +10 -11
  48. package/src/data/predefined-profiles.ts +7 -7
  49. package/src/services/active-closure.ts +117 -0
  50. package/src/services/catalog-cache-store.ts +6 -2
  51. package/src/services/claude-settings.ts +2 -174
  52. package/src/services/community-styles.ts +1 -1
  53. package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
  54. package/src/services/gitignore-fixer.ts +1 -1
  55. package/src/services/marketplace-refresh.ts +30 -0
  56. package/src/services/plugin-manager.ts +200 -283
  57. package/src/services/resolver.ts +7 -2
  58. package/src/services/tool-probe.ts +1637 -0
  59. package/src/services/toolchain.ts +97 -0
  60. package/src/services/update-engine.ts +220 -32
  61. package/src/services/update-plan.ts +640 -160
  62. package/src/services/version-snapshot.ts +1 -1
  63. package/src/types/bun.d.ts +6 -0
  64. package/src/ui/App.tsx +2 -27
  65. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  66. package/src/ui/adapters/pluginsAdapter.ts +57 -0
  67. package/src/ui/components/modals/ModalContainer.tsx +0 -26
  68. package/src/ui/hooks/index.ts +0 -1
  69. package/src/ui/hooks/useGitignoreModal.ts +2 -3
  70. package/src/ui/renderers/cliToolRenderers.tsx +108 -67
  71. package/src/ui/renderers/pluginRenderers.tsx +157 -98
  72. package/src/ui/renderers/pluginRowText.ts +70 -0
  73. package/src/ui/renderers/pluginStatusText.ts +56 -0
  74. package/src/ui/screens/CliToolsScreen.tsx +154 -280
  75. package/src/ui/screens/PluginsScreen.tsx +156 -140
  76. package/src/ui/state/types.ts +0 -7
  77. package/src/__tests__/dual-write-prevention.test.ts +0 -380
  78. package/src/__tests__/gap-fill-versions.test.ts +0 -389
  79. package/src/__tests__/plugin-version-check.test.ts +0 -762
  80. package/src/cli/claude.ts +0 -21
  81. package/src/prerunner/index.ts +0 -500
  82. package/src/services/plugin-version-check.ts +0 -360
  83. package/src/services/update-cache.ts +0 -78
  84. package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
  85. package/src/ui/hooks/useMismatchModal.ts +0 -89
@@ -0,0 +1,523 @@
1
+ /**
2
+ * BLACK-BOX: R1 — one plugin classifier, and the TUI text contract (§8.1, §8.7).
3
+ * Written from requirements.md and architecture §8 only.
4
+ */
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { PluginInfo } from "../services/plugin-manager.js";
7
+ import {
8
+ type PluginUpdateItem,
9
+ type UpdatePlan,
10
+ classifyPlugin,
11
+ classifyPluginRows,
12
+ countPluginStates,
13
+ indexCatalog,
14
+ pinForRow,
15
+ planHasWork,
16
+ planIsBehind,
17
+ planPluginUpdates,
18
+ scopeIsBehind,
19
+ } from "../services/update-plan.js";
20
+ import type { PluginPluginItem } from "../ui/adapters/pluginsAdapter.js";
21
+ import { pluginRowText } from "../ui/renderers/pluginRowText.js";
22
+ import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
23
+
24
+ function info(
25
+ id: string,
26
+ catalogVersion: string | null,
27
+ installed: string | null,
28
+ extra: Partial<PluginInfo> = {},
29
+ ): PluginInfo {
30
+ const at = id.indexOf("@");
31
+ return {
32
+ id,
33
+ name: at >= 0 ? id.slice(0, at) : id,
34
+ version: catalogVersion,
35
+ description: "",
36
+ marketplace: at >= 0 ? id.slice(at + 1) : "",
37
+ marketplaceDisplay: at >= 0 ? id.slice(at + 1) : "",
38
+ enabled: installed !== null,
39
+ ...(installed !== null
40
+ ? {
41
+ installedVersion: installed,
42
+ projectScope: { enabled: true, version: installed },
43
+ }
44
+ : {}),
45
+ ...extra,
46
+ };
47
+ }
48
+
49
+ function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
50
+ return {
51
+ pluginId: "x@magus",
52
+ pinned: "latest",
53
+ installed: "1.0.0",
54
+ available: "1.0.0",
55
+ target: null,
56
+ action: "current",
57
+ reason: "up-to-date",
58
+ scopes: [],
59
+ beforeVersions: {},
60
+ ...over,
61
+ };
62
+ }
63
+
64
+ describe("indexCatalog", () => {
65
+ const idx = indexCatalog([
66
+ info("dev@magus", "1.0.0", "1.0.0"),
67
+ info("old@magus", null, "0.1.0", { isOrphaned: true }),
68
+ info("solo@lonely", null, "0.1.0", { isOrphaned: true }),
69
+ ]);
70
+
71
+ test("isListed is true only for a catalog row that is not an orphan", () => {
72
+ expect(idx.isListed("dev@magus")).toBe(true);
73
+ expect(idx.isListed("old@magus")).toBe(false);
74
+ expect(idx.isListed("missing@magus")).toBe(false);
75
+ });
76
+
77
+ test("marketplaceIsKnown is true when another listed id shares the marketplace suffix", () => {
78
+ expect(idx.marketplaceIsKnown("missing@magus")).toBe(true);
79
+ expect(idx.marketplaceIsKnown("solo@lonely")).toBe(false);
80
+ expect(idx.marketplaceIsKnown("no-at-sign")).toBe(false);
81
+ });
82
+
83
+ test("accepts a Map as well as an array", () => {
84
+ const fromMap = indexCatalog(
85
+ new Map([["dev@magus", info("dev@magus", "1.0.0", null)]]),
86
+ );
87
+ expect(fromMap.get("dev@magus")?.version).toBe("1.0.0");
88
+ expect(fromMap.isListed("dev@magus")).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("classifyPlugin — one state plus a human reason", () => {
93
+ test("declared, absent from the catalog, marketplace read: unknown / not-offered, no target", () => {
94
+ const idx = indexCatalog([info("dev@magus", "1.0.0", "1.0.0")]);
95
+
96
+ const got = classifyPlugin("gone@magus", "latest", idx);
97
+
98
+ expect({
99
+ action: got.action,
100
+ reason: got.reason,
101
+ target: got.target,
102
+ }).toEqual({
103
+ action: "unknown",
104
+ reason: "not-offered",
105
+ target: null,
106
+ });
107
+ expect(got.note).toBeTruthy();
108
+ });
109
+
110
+ test("installed orphan whose marketplace contributes no listed id: unknown / marketplace-unread", () => {
111
+ const idx = indexCatalog([
112
+ info("dev@magus", "1.0.0", "1.0.0"),
113
+ info("ghost@unread-mp", null, "0.3.0", { isOrphaned: true }),
114
+ ]);
115
+
116
+ const got = classifyPlugin("ghost@unread-mp", "latest", idx);
117
+
118
+ expect({ action: got.action, reason: got.reason }).toEqual({
119
+ action: "unknown",
120
+ reason: "marketplace-unread",
121
+ });
122
+ });
123
+
124
+ test("declared latest, installed nowhere: install / absent, aiming at the catalog version", () => {
125
+ const idx = indexCatalog([info("bunjs@magus", "0.4.4", null)]);
126
+
127
+ const got = classifyPlugin("bunjs@magus", "latest", idx);
128
+
129
+ expect({
130
+ action: got.action,
131
+ reason: got.reason,
132
+ target: got.target,
133
+ }).toEqual({
134
+ action: "install",
135
+ reason: "absent",
136
+ target: "0.4.4",
137
+ });
138
+ });
139
+
140
+ test("installed behind the catalog: update / behind with the scope and its before-version", () => {
141
+ const idx = indexCatalog([info("designer@magus", "2.0.0", "1.5.0")]);
142
+
143
+ const got = classifyPlugin("designer@magus", "latest", idx);
144
+
145
+ expect({
146
+ action: got.action,
147
+ reason: got.reason,
148
+ target: got.target,
149
+ installed: got.installed,
150
+ available: got.available,
151
+ }).toEqual({
152
+ action: "update",
153
+ reason: "behind",
154
+ target: "2.0.0",
155
+ installed: "1.5.0",
156
+ available: "2.0.0",
157
+ });
158
+ expect(got.scopes).toContain("project");
159
+ expect(got.beforeVersions.project).toBe("1.5.0");
160
+ });
161
+
162
+ test("installed equal to the catalog: current / up-to-date", () => {
163
+ const idx = indexCatalog([info("dev@magus", "7.4.0", "7.4.0")]);
164
+
165
+ const got = classifyPlugin("dev@magus", "latest", idx);
166
+
167
+ expect({ action: got.action, reason: got.reason }).toEqual({
168
+ action: "current",
169
+ reason: "up-to-date",
170
+ });
171
+ });
172
+
173
+ test("unversioned catalog with a commit-sha install is unknown, never current (report #1)", () => {
174
+ const idx = indexCatalog([
175
+ info("code-review@claude-plugins-official", null, "3ea32df27be7"),
176
+ ]);
177
+
178
+ const got = classifyPlugin(
179
+ "code-review@claude-plugins-official",
180
+ "latest",
181
+ idx,
182
+ );
183
+
184
+ expect({
185
+ action: got.action,
186
+ reason: got.reason,
187
+ target: got.target,
188
+ }).toEqual({
189
+ action: "unknown",
190
+ reason: "no-published-version",
191
+ target: null,
192
+ });
193
+ expect(got.note).toBeTruthy();
194
+ });
195
+
196
+ test("a failed catalog check on a latest pin is unknown / catalog-unverified, never current", () => {
197
+ const idx = indexCatalog([
198
+ info("claudish@magus", "2.0.0", "2.0.0", { updateCheckFailed: true }),
199
+ ]);
200
+
201
+ const got = classifyPlugin("claudish@magus", "latest", idx);
202
+
203
+ expect({ action: got.action, reason: got.reason }).toEqual({
204
+ action: "unknown",
205
+ reason: "catalog-unverified",
206
+ });
207
+ });
208
+
209
+ test("every unknown item carries a note and no target", () => {
210
+ const idx = indexCatalog([
211
+ info("a@magus", null, "abc123def456"),
212
+ info("b@magus", null, "1.0.0", { isOrphaned: true }),
213
+ info("c@magus", "1.0.0", "1.0.0", { updateCheckFailed: true }),
214
+ info("e@nowhere", null, "0.3.0", { isOrphaned: true }),
215
+ ]);
216
+
217
+ for (const id of [
218
+ "a@magus",
219
+ "b@magus",
220
+ "c@magus",
221
+ "d@magus",
222
+ "e@nowhere",
223
+ ]) {
224
+ const got = classifyPlugin(id, "latest", idx);
225
+ expect({ id, action: got.action }).toEqual({ id, action: "unknown" });
226
+ expect({ id, target: got.target, hasNote: Boolean(got.note) }).toEqual({
227
+ id,
228
+ target: null,
229
+ hasNote: true,
230
+ });
231
+ }
232
+ });
233
+ });
234
+
235
+ describe("planPluginUpdates is classifyPlugin over every pin, sorted", () => {
236
+ test("output is sorted by pluginId and equals classifyPlugin item for item", () => {
237
+ const catalog = [
238
+ info("zeta@magus", "2.0.0", "1.0.0"),
239
+ info("alpha@magus", "1.0.0", "1.0.0"),
240
+ info("mid@magus", null, "abc123def456"),
241
+ ];
242
+ const pins = {
243
+ "zeta@magus": "latest",
244
+ "alpha@magus": "latest",
245
+ "mid@magus": "latest",
246
+ };
247
+
248
+ const plan = planPluginUpdates(
249
+ pins,
250
+ new Map(catalog.map((c) => [c.id, c])),
251
+ );
252
+
253
+ expect(plan.map((p) => p.pluginId)).toEqual([
254
+ "alpha@magus",
255
+ "mid@magus",
256
+ "zeta@magus",
257
+ ]);
258
+ const idx = indexCatalog(catalog);
259
+ for (const p of plan) {
260
+ expect(p).toEqual(
261
+ classifyPlugin(p.pluginId, pins[p.pluginId as keyof typeof pins], idx),
262
+ );
263
+ }
264
+ });
265
+ });
266
+
267
+ describe("pinForRow / classifyPluginRows", () => {
268
+ const declared = info("dev@magus", "1.0.0", "1.0.0");
269
+ const installedOnly = info("extra@magus", "1.0.0", "1.0.0");
270
+ const enabledOnly = info("en@magus", "1.0.0", null, {
271
+ enabled: true,
272
+ userScope: { enabled: true },
273
+ });
274
+ const browseOnly = info("browse@magus", "1.0.0", null);
275
+ const pins = { "dev@magus": "1.0.0" };
276
+
277
+ test("closure pin wins; installed or enabled rows are judged against latest; browse-only has no pin", () => {
278
+ expect(pinForRow(declared, pins)).toBe("1.0.0");
279
+ expect(pinForRow(installedOnly, pins)).toBe("latest");
280
+ expect(pinForRow(enabledOnly, pins)).toBe("latest");
281
+ expect(pinForRow(browseOnly, pins)).toBeNull();
282
+ });
283
+
284
+ test("classifyPluginRows has one status per pinned row and none for browse-only rows", () => {
285
+ const rows = classifyPluginRows(
286
+ [declared, installedOnly, enabledOnly, browseOnly],
287
+ pins,
288
+ );
289
+
290
+ expect([...rows.keys()].sort()).toEqual([
291
+ "dev@magus",
292
+ "en@magus",
293
+ "extra@magus",
294
+ ]);
295
+ expect(rows.get("en@magus")?.action).toBe("install");
296
+ expect(rows.get("en@magus")?.reason).toBe("enabled-not-installed");
297
+ });
298
+ });
299
+
300
+ describe("countPluginStates", () => {
301
+ test("tallies each state exactly", () => {
302
+ const counts = countPluginStates([
303
+ item({ action: "install", reason: "absent" }),
304
+ item({ action: "update", reason: "behind" }),
305
+ item({ action: "update", reason: "behind" }),
306
+ item({ action: "repair", reason: "content-stale" }),
307
+ item({ action: "current" }),
308
+ item({ action: "unknown", reason: "no-published-version" }),
309
+ ]);
310
+
311
+ expect(counts).toEqual({
312
+ install: 1,
313
+ update: 2,
314
+ repair: 1,
315
+ current: 1,
316
+ unknown: 1,
317
+ });
318
+ });
319
+
320
+ test("an empty input counts zero everywhere", () => {
321
+ expect(countPluginStates([])).toEqual({
322
+ install: 0,
323
+ update: 0,
324
+ repair: 0,
325
+ current: 0,
326
+ unknown: 0,
327
+ });
328
+ });
329
+ });
330
+
331
+ describe("planIsBehind — plugin actions at the --check gate", () => {
332
+ function planOf(plugins: PluginUpdateItem[]): UpdatePlan {
333
+ return {
334
+ profileId: "default",
335
+ plugins,
336
+ bins: [],
337
+ skills: [],
338
+ binSpecs: new Map(),
339
+ };
340
+ }
341
+
342
+ test("install, update and repair fail the gate; current and unknown do not", () => {
343
+ // The unknown row is the point: "could not be checked" is not evidence of
344
+ // drift, and for a marketplace that publishes no version it never will be,
345
+ // so the same rule as an unknown tool applies. It is still not APPLIED,
346
+ // which is planHasWork's business, not this gate's.
347
+ const table: Array<[PluginUpdateItem["action"], boolean]> = [
348
+ ["install", true],
349
+ ["update", true],
350
+ ["repair", true],
351
+ ["current", false],
352
+ ["unknown", false],
353
+ ];
354
+ for (const [action, behind] of table) {
355
+ const plan = planOf([item({ action })]);
356
+ expect({ action, behind: planIsBehind(plan) }).toEqual({
357
+ action,
358
+ behind,
359
+ });
360
+ }
361
+ });
362
+
363
+ test("one unknown among current rows does not fail the gate; one update does", () => {
364
+ const unknowns = planOf([
365
+ item({ action: "current" }),
366
+ item({ action: "unknown", reason: "no-published-version" }),
367
+ ]);
368
+ expect(planIsBehind(unknowns)).toBe(false);
369
+ expect(planHasWork(unknowns)).toBe(true);
370
+
371
+ const withUpdate = planOf([
372
+ item({ action: "unknown", reason: "no-published-version" }),
373
+ item({ action: "update", reason: "behind" }),
374
+ ]);
375
+ expect(planIsBehind(withUpdate)).toBe(true);
376
+ });
377
+ });
378
+
379
+ describe("scopeIsBehind", () => {
380
+ test("true only for an update whose scopes include that scope", () => {
381
+ const upd = item({
382
+ action: "update",
383
+ reason: "behind",
384
+ scopes: ["project"],
385
+ });
386
+
387
+ expect(scopeIsBehind(upd, "project")).toBe(true);
388
+ expect(scopeIsBehind(upd, "user")).toBe(false);
389
+ expect(
390
+ scopeIsBehind(
391
+ item({ action: "unknown", scopes: ["project"] }),
392
+ "project",
393
+ ),
394
+ ).toBe(false);
395
+ expect(scopeIsBehind(null, "project")).toBe(false);
396
+ expect(scopeIsBehind(undefined, "user")).toBe(false);
397
+ });
398
+ });
399
+
400
+ describe("pluginStatusText — the TUI suffix contract (§8.7)", () => {
401
+ test("null status: empty, muted", () => {
402
+ expect(pluginStatusText(null)).toEqual({ suffix: "", tone: "muted" });
403
+ });
404
+
405
+ test("unknown: ' ? <note>' with the note verbatim", () => {
406
+ const note =
407
+ "its marketplace publishes no version for this plugin, so there is nothing to compare";
408
+ expect(
409
+ pluginStatusText(
410
+ item({ action: "unknown", reason: "no-published-version", note }),
411
+ ).suffix,
412
+ ).toBe(` ? ${note}`);
413
+ });
414
+
415
+ test("update: ' → v<target> available', plus ' (<note>)' when a note is set", () => {
416
+ expect(
417
+ pluginStatusText(
418
+ item({ action: "update", reason: "behind", target: "2.0.0" }),
419
+ ).suffix,
420
+ ).toBe(" → v2.0.0 available");
421
+ expect(
422
+ pluginStatusText(
423
+ item({
424
+ action: "update",
425
+ reason: "pin-undeliverable",
426
+ target: "1.0.0",
427
+ note: "pin n",
428
+ }),
429
+ ).suffix,
430
+ ).toBe(" → v1.0.0 available (pin n)");
431
+ });
432
+
433
+ test("repair: ' stale — reinstall (<note>)'", () => {
434
+ expect(
435
+ pluginStatusText(
436
+ item({
437
+ action: "repair",
438
+ reason: "content-stale",
439
+ note: "files moved",
440
+ }),
441
+ ).suffix,
442
+ ).toBe(" stale — reinstall (files moved)");
443
+ });
444
+
445
+ test("install: absent claims the profile declares it; enabled-not-installed shows its note instead", () => {
446
+ expect(
447
+ pluginStatusText(
448
+ item({ action: "install", reason: "absent", installed: null }),
449
+ ).suffix,
450
+ ).toBe(" not installed — profile declares it");
451
+ const enabled = pluginStatusText(
452
+ item({
453
+ action: "install",
454
+ reason: "enabled-not-installed",
455
+ installed: null,
456
+ note: "enabled in user settings",
457
+ }),
458
+ ).suffix;
459
+ expect(enabled).toBe(" not installed (enabled in user settings)");
460
+ expect(enabled).not.toContain("profile declares");
461
+ });
462
+
463
+ test("current: empty, or ' (<note>)' for pin-undeliverable", () => {
464
+ expect(pluginStatusText(item({ action: "current" })).suffix).toBe("");
465
+ expect(
466
+ pluginStatusText(
467
+ item({ action: "current", reason: "pin-undeliverable", note: "pin n" }),
468
+ ).suffix,
469
+ ).toBe(" (pin n)");
470
+ });
471
+ });
472
+
473
+ describe("pluginRowText", () => {
474
+ function rowItem(
475
+ plugin: PluginInfo,
476
+ status: PluginUpdateItem | null,
477
+ ): PluginPluginItem {
478
+ return {
479
+ id: `plugin:${plugin.id}`,
480
+ kind: "plugin",
481
+ label: plugin.name,
482
+ plugin,
483
+ isExpandable: false,
484
+ isExpanded: false,
485
+ status,
486
+ };
487
+ }
488
+
489
+ test("an unknown status on a 0.0.0 install still renders the note (no version gate)", () => {
490
+ const plugin = info("zero-version@claude-plugins-official", null, "0.0.0");
491
+ const status = classifyPlugin(plugin.id, "latest", indexCatalog([plugin]));
492
+
493
+ const out = pluginRowText(rowItem(plugin, status));
494
+
495
+ expect(status.action).toBe("unknown");
496
+ expect(out.text).toContain(status.note ?? "<missing note>");
497
+ });
498
+
499
+ test("text is exactly the segments joined, and contains the status suffix", () => {
500
+ const plugin = info("designer@magus", "2.0.0", "1.5.0");
501
+ const status = classifyPlugin(plugin.id, "latest", indexCatalog([plugin]));
502
+
503
+ const out = pluginRowText(rowItem(plugin, status));
504
+
505
+ expect(out.text).toBe(out.segments.map((s) => s.text).join(""));
506
+ expect(out.text).toContain(pluginStatusText(status).suffix);
507
+ });
508
+
509
+ test("an orphan row renders its note", () => {
510
+ const plugin = info("legacy@magus", null, "0.5.0", { isOrphaned: true });
511
+ const sibling = info("dev@magus", "1.0.0", "1.0.0");
512
+ const status = classifyPlugin(
513
+ plugin.id,
514
+ "latest",
515
+ indexCatalog([plugin, sibling]),
516
+ );
517
+
518
+ const out = pluginRowText(rowItem(plugin, status));
519
+
520
+ expect(status.action).toBe("unknown");
521
+ expect(out.text).toContain(status.note ?? "<missing note>");
522
+ });
523
+ });