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
@@ -1,11 +1,16 @@
1
1
  import type React from "react";
2
2
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
3
3
  import {
4
+ compareVersions,
4
5
  isEnabledButNotInstalled,
5
6
  isInstalledInScope,
6
7
  } from "../../services/plugin-manager.js";
7
8
  import type { ScopeStatus } from "../../services/plugin-manager.js";
8
9
  import type { PluginComponentKind } from "../../services/skills-manager.js";
10
+ import type {
11
+ PluginUpdateAction,
12
+ PluginUpdateItem,
13
+ } from "../../services/update-plan.js";
9
14
  import { isKnownVersion } from "../../services/version-snapshot.js";
10
15
  import type { PluginRelease } from "../../types/index.js";
11
16
  import { highlightMatches } from "../../utils/fuzzy-search.js";
@@ -32,6 +37,8 @@ import type {
32
37
  import { componentBadge as badgeColors } from "../theme-mode.js";
33
38
  import { type UiColor, theme } from "../theme.js";
34
39
  import { renderMarkdown } from "./markdownRenderer.js";
40
+ import { type PluginRowSegment, pluginRowText } from "./pluginRowText.js";
41
+ import { type StatusTone, pluginStatusText } from "./pluginStatusText.js";
35
42
 
36
43
  // ─── Category renderers ───────────────────────────────────────────────────────
37
44
 
@@ -137,6 +144,47 @@ function categoryDetail(
137
144
 
138
145
  // ─── Plugin renderers ─────────────────────────────────────────────────────────
139
146
 
147
+ /**
148
+ * The row's text after the name, drawn exactly as `pluginRowText` produced it.
149
+ *
150
+ * On a selected row the tone's hue is surrendered to the selection (see
151
+ * MetaText); the words are the same either way.
152
+ */
153
+ function RowSegments({
154
+ segments,
155
+ selected,
156
+ }: {
157
+ segments: PluginRowSegment[];
158
+ selected: boolean;
159
+ }): React.ReactNode {
160
+ return (
161
+ <>
162
+ {segments.map((seg, i) => {
163
+ const key = `${i}:${seg.text}`;
164
+ if (seg.tone === "text") return <span key={key}>{seg.text}</span>;
165
+ if (seg.tone === "info") {
166
+ return (
167
+ <span
168
+ key={key}
169
+ fg={selected ? theme.selection.fg : theme.colors.info}
170
+ >
171
+ {seg.text}
172
+ </span>
173
+ );
174
+ }
175
+ return (
176
+ <MetaText
177
+ key={key}
178
+ text={seg.text}
179
+ tone={seg.tone}
180
+ selected={selected}
181
+ />
182
+ );
183
+ })}
184
+ </>
185
+ );
186
+ }
187
+
140
188
  function pluginRow(
141
189
  item: PluginPluginItem,
142
190
  isSelected: boolean,
@@ -147,57 +195,10 @@ function pluginRow(
147
195
  const hasLocal = !!plugin.localScope?.enabled;
148
196
  const hasAnyScope = hasUser || hasProject || hasLocal;
149
197
 
150
- // Enabled somewhere but nothing actually installed a broken state that used
151
- // to render exactly like a healthy install.
152
- const notInstalled = isEnabledButNotInstalled(plugin);
153
-
154
- // A known version is one we can actually show. "0.0.0" means installed with an
155
- // unknown version (every official Anthropic plugin records it) — never render it.
156
- const shownVersion = isKnownVersion(plugin.installedVersion)
157
- ? plugin.installedVersion
158
- : undefined;
159
-
160
- let versionStr = "";
161
- if (plugin.isOrphaned) {
162
- // "deprecated" alone reads as "delete me" — but a plugin that merely moved
163
- // marketplace is still published, and deleting it loses it for no reason.
164
- versionStr = plugin.movedTo ? ` moved → ${plugin.movedTo}` : " deprecated";
165
- } else if (notInstalled) {
166
- versionStr = " not installed";
167
- } else if (hasAnyScope) {
168
- // "what just changed" and "what is still available" are independent facts,
169
- // and both can be true at once: the prerunner can move a plugin 2.9.0→3.0.1
170
- // while the marketplace already offers 3.3.1. These used to share one
171
- // if/else chain, so the "updated" badge silently swallowed the pending
172
- // update — and the update only reappeared on the next refresh, which read
173
- // as the state spontaneously changing. Render them as separate clauses.
174
- if (plugin.recentlyUpdatedFrom && shownVersion) {
175
- // Show what actually happened, not just where it landed — the update may
176
- // have come from Claude Code or the prerunner without ever telling us.
177
- versionStr = ` v${plugin.recentlyUpdatedFrom} → v${shownVersion} updated`;
178
- } else if (plugin.recentlyInstalled) {
179
- // Newly seen. Version is optional here: a plugin can be genuinely
180
- // installed with no version we can name.
181
- versionStr = shownVersion ? ` v${shownVersion} new` : " new";
182
- } else if (shownVersion) {
183
- versionStr = ` v${shownVersion}`;
184
- }
185
-
186
- // Pending update, regardless of which badge above applied.
187
- if (plugin.hasUpdate && plugin.version && versionStr) {
188
- versionStr += ` → v${plugin.version} available`;
189
- } else if (plugin.contentStale && versionStr) {
190
- // Same version, different files. The version compare says "up to date"
191
- // and is wrong — only a reinstall delivers the current content.
192
- versionStr += " stale — reinstall";
193
- } else if (plugin.updateCheckFailed && versionStr) {
194
- // No update to show — but we never confirmed there wasn't one. Neither
195
- // upstream source could be read, so the compare ran against the pinned
196
- // clone, which by policy never advances. A bare version here reads as
197
- // "current", which is the exact claim we cannot make.
198
- versionStr += " ? unverified";
199
- }
200
- }
198
+ // Everything after the name version, history badge, status comes from
199
+ // ONE pure function over the classifier's answer, and is drawn verbatim in
200
+ // every branch below. No branch here decides plugin state.
201
+ const { segments } = pluginRowText(item);
201
202
 
202
203
  // Same glyphs as a marketplace row, in a cell that is always two columns
203
204
  // wide. Both halves matter: a different arrow at each level reads as a
@@ -218,23 +219,23 @@ function pluginRow(
218
219
  />{" "}
219
220
  {arrow}
220
221
  {plugin.name}
221
- {versionStr}{" "}
222
+ <RowSegments segments={segments} selected={true} />{" "}
222
223
  </text>
223
224
  );
224
225
  }
225
226
 
226
227
  // Fuzzy highlight
227
- const segments = item.matches
228
+ const nameSegments = item.matches
228
229
  ? highlightMatches(plugin.name, item.matches)
229
230
  : null;
230
- const displayName = segments
231
- ? segments.map((seg) => seg.text).join("")
231
+ const displayName = nameSegments
232
+ ? nameSegments.map((seg) => seg.text).join("")
232
233
  : plugin.name;
233
234
 
234
235
  if (plugin.isOrphaned) {
235
- const ver = isKnownVersion(plugin.installedVersion)
236
- ? ` v${plugin.installedVersion}`
237
- : "";
236
+ // The red squares are the presence marker for "published by no marketplace
237
+ // we read". WHAT is known about it — moved, withdrawn, or its marketplace
238
+ // was never read — is the status suffix, the same words the CLI prints.
238
239
  return (
239
240
  <text fg={theme.colors.text}>
240
241
  <span fg={theme.colors.danger}> ■■■ </span>
@@ -242,12 +243,16 @@ function pluginRow(
242
243
  name lands in the same column as every other plugin's. */}
243
244
  <span>{" "}</span>
244
245
  <span fg={theme.colors.muted}>{displayName}</span>
245
- {ver ? <span fg={theme.colors.warning}>{ver}</span> : null}
246
- <span fg={theme.colors.danger}> deprecated</span>
246
+ <RowSegments segments={segments} selected={false} />
247
247
  </text>
248
248
  );
249
249
  }
250
250
 
251
+ const installedSomewhere =
252
+ isInstalledInScope(plugin.userScope) ||
253
+ isInstalledInScope(plugin.projectScope) ||
254
+ isInstalledInScope(plugin.localScope);
255
+
251
256
  return (
252
257
  <text fg={theme.colors.text}>
253
258
  <span> </span>
@@ -256,41 +261,77 @@ function pluginRow(
256
261
  <span fg={theme.colors.dim}>{arrow}</span>
257
262
  <span
258
263
  fg={
259
- hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted
264
+ hasAnyScope && installedSomewhere
265
+ ? theme.colors.text
266
+ : theme.colors.muted
260
267
  }
261
268
  >
262
269
  {displayName}
263
270
  </span>
264
- <MetaText
265
- text={versionStr}
266
- tone={
267
- plugin.recentlyUpdatedFrom || plugin.recentlyInstalled
268
- ? "success"
269
- : // "not installed" is a broken state — enabled somewhere with
270
- // nothing actually installed — while a pending update is
271
- // routine. They shared `warning` and so read as the same
272
- // severity; red vs amber is the distinction that matters.
273
- notInstalled
274
- ? "danger"
275
- : plugin.hasUpdate
276
- ? "warning"
277
- : plugin.updateCheckFailed
278
- ? // Dimmer than a real update: nothing is known to be
279
- // wrong. But not `muted` either, which is the colour
280
- // of a verified-current plugin.
281
- "warning"
282
- : "muted"
283
- }
284
- />
271
+ <RowSegments segments={segments} selected={false} />
285
272
  </text>
286
273
  );
287
274
  }
288
275
 
276
+ const ACTION_LABEL: Record<PluginUpdateAction, string> = {
277
+ install: "NOT INSTALLED",
278
+ update: "UPDATE AVAILABLE",
279
+ repair: "NEEDS REINSTALL",
280
+ current: "UP TO DATE",
281
+ unknown: "UNKNOWN",
282
+ };
283
+
284
+ /** Resolved at render time, so a theme switch is honoured. */
285
+ function toneColor(tone: StatusTone): UiColor {
286
+ switch (tone) {
287
+ case "danger":
288
+ return theme.colors.danger;
289
+ case "warning":
290
+ return theme.colors.warning;
291
+ case "success":
292
+ return theme.colors.success;
293
+ case "info":
294
+ return theme.colors.info;
295
+ case "muted":
296
+ return theme.colors.muted;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * The detail panel's `Status:` line: the classifier's action, and its note
302
+ * VERBATIM — the same words `claudeup update` prints for this plugin.
303
+ */
304
+ function StatusLine({
305
+ status,
306
+ }: {
307
+ status: PluginUpdateItem | null;
308
+ }): React.ReactNode {
309
+ if (!status) return null;
310
+ const tone =
311
+ status.action === "current" ? "success" : pluginStatusText(status).tone;
312
+ const detail = status.note
313
+ ? status.note
314
+ : status.action === "update" && status.target
315
+ ? `→ v${status.target}`
316
+ : "";
317
+ return (
318
+ <KeyValueLine
319
+ label="Status"
320
+ value={
321
+ <span>
322
+ <span fg={toneColor(tone)}>{ACTION_LABEL[status.action]}</span>
323
+ {detail ? <span fg={theme.colors.text}> {detail}</span> : null}
324
+ </span>
325
+ }
326
+ />
327
+ );
328
+ }
329
+
289
330
  function pluginDetail(
290
331
  item: PluginPluginItem,
291
332
  fetched?: PluginContentsEntry,
292
333
  ): React.ReactNode {
293
- const { plugin } = item;
334
+ const { plugin, status } = item;
294
335
  // "Installed" has to mean the same thing here as in the list row. Reading the
295
336
  // `enabledPlugins` flag alone made this panel print "● Installed" for the
296
337
  // exact plugin the row beside it was labelling "not installed".
@@ -299,25 +340,32 @@ function pluginDetail(
299
340
  isInstalledInScope(plugin.projectScope) ||
300
341
  isInstalledInScope(plugin.localScope);
301
342
  const brokenInstall = isEnabledButNotInstalled(plugin);
343
+ // The ⬆ marker and the U hint claim that pressing U does something. That is
344
+ // exactly the classifier's update/repair, and nothing else.
345
+ const needsWork = status?.action === "update" || status?.action === "repair";
302
346
 
303
- // Orphaned/deprecated plugin
347
+ // Published by no marketplace we read. The status line says which of the
348
+ // three things that means — moved, withdrawn, or its marketplace unread.
304
349
  if (plugin.isOrphaned) {
305
350
  return (
306
351
  <box flexDirection="column">
307
352
  <box justifyContent="center">
308
353
  <text bg={theme.colors.warning} fg={theme.hints.fg}>
309
- <strong> {plugin.name} — DEPRECATED </strong>
354
+ <strong> {plugin.name} — NOT LISTED </strong>
310
355
  </text>
311
356
  </box>
357
+ <box marginTop={1}>
358
+ <StatusLine status={status} />
359
+ </box>
312
360
  <box marginTop={1}>
313
361
  <text fg={theme.colors.warning}>
314
- This plugin is no longer in the marketplace.
362
+ No marketplace claudeup read lists this plugin.
315
363
  </text>
316
364
  </box>
317
365
  <box marginTop={1}>
318
366
  <text fg={theme.colors.muted}>
319
- It was removed from the marketplace but still referenced in your
320
- settings. Press d to uninstall and clean up.
367
+ It is still installed or referenced in your settings. Press d to
368
+ uninstall and clean up, or to migrate it if it moved.
321
369
  </text>
322
370
  </box>
323
371
  <ActionHints
@@ -365,8 +413,16 @@ function pluginDetail(
365
413
  }
366
414
 
367
415
  const showVersion = isKnownVersion(plugin.version ?? undefined);
416
+ // The shared comparator, not `!==`: "1.2" and "1.2.0" are the same version,
417
+ // and the classifier's `Status:` line right above says so. Raw inequality
418
+ // made this line contradict it. `null` (no shared ordering) is a genuine
419
+ // difference and shows.
368
420
  const showInstalledVersion =
369
- isInstalled && isKnownVersion(plugin.installedVersion);
421
+ isInstalled &&
422
+ isKnownVersion(plugin.installedVersion) &&
423
+ plugin.installedVersion !== undefined &&
424
+ plugin.version !== null &&
425
+ compareVersions(plugin.version, plugin.installedVersion) !== 0;
370
426
 
371
427
  return (
372
428
  <box flexDirection="column">
@@ -376,7 +432,7 @@ function pluginDetail(
376
432
  <strong>
377
433
  {" "}
378
434
  {plugin.name}
379
- {isInstalled && plugin.hasUpdate ? " ⬆" : ""}{" "}
435
+ {needsWork ? " ⬆" : ""}{" "}
380
436
  </strong>
381
437
  </text>
382
438
  </box>
@@ -414,14 +470,14 @@ function pluginDetail(
414
470
  </box>
415
471
 
416
472
  {/* Metadata */}
473
+ <StatusLine status={status} />
417
474
  {showVersion ? (
418
475
  <KeyValueLine
419
476
  label="Version"
420
477
  value={
421
478
  <span>
422
479
  <span fg={theme.colors.link}>v{plugin.version}</span>
423
- {showInstalledVersion &&
424
- plugin.installedVersion !== plugin.version ? (
480
+ {showInstalledVersion ? (
425
481
  <span> (v{plugin.installedVersion} installed)</span>
426
482
  ) : null}
427
483
  </span>
@@ -435,7 +491,7 @@ function pluginDetail(
435
491
  * when in fact GitHub was never reached and the number came off a clone
436
492
  * that had not moved in days.
437
493
  */}
438
- {plugin.updateCheckFailed ? (
494
+ {status?.reason === "catalog-unverified" ? (
439
495
  <>
440
496
  <KeyValueLine
441
497
  label="Latest"
@@ -445,9 +501,7 @@ function pluginDetail(
445
501
  />
446
502
  <box>
447
503
  <text fg={theme.colors.muted}>
448
- {plugin.updateCheckFailure
449
- ? ` ${plugin.updateCheckFailure.detail}. `
450
- : " "}
504
+ {" "}
451
505
  {plugin.catalogSource === "local-clone"
452
506
  ? "Version shown is from the pinned marketplace clone, so a newer release may exist. Press r to retry."
453
507
  : "Press r to retry."}
@@ -521,12 +575,17 @@ function pluginDetail(
521
575
  />
522
576
 
523
577
  {/* Update action */}
524
- {isInstalled && plugin.hasUpdate ? (
578
+ {needsWork ? (
525
579
  <ActionHints
526
580
  hints={[
527
581
  {
528
582
  key: "U",
529
- label: `Update to v${plugin.version}`,
583
+ label:
584
+ status?.action === "repair"
585
+ ? "Reinstall (files changed)"
586
+ : status?.target
587
+ ? `Update to v${status.target}`
588
+ : "Update",
530
589
  tone: "primary",
531
590
  },
532
591
  ]}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Everything a Plugins row shows after the plugin's name, as plain segments.
3
+ *
4
+ * `pluginRow` renders these segments verbatim — for the selected row, the
5
+ * unselected row, and an orphan alike — and holds no conditional on plugin
6
+ * state of its own. That is the point. The row used to carry three rules of
7
+ * its own, and each one was a way to disagree with `claudeup update`:
8
+ *
9
+ * - every status clause was gated on the installed version being displayable,
10
+ * so a plugin recorded as "0.0.0" showed no status and read as healthy;
11
+ * - its reason words ("? not comparable") were not the CLI's reason;
12
+ * - an orphan's text depended on the cursor — the selected row said
13
+ * "moved → X", the unselected one always said "deprecated".
14
+ *
15
+ * Pure and `.ts`, so a test can assert on exactly what the row prints.
16
+ */
17
+
18
+ import { isKnownVersion } from "../../services/version-snapshot.js";
19
+ import type { PluginPluginItem } from "../adapters/pluginsAdapter.js";
20
+ import { type StatusTone, pluginStatusText } from "./pluginStatusText.js";
21
+
22
+ export interface PluginRowSegment {
23
+ text: string;
24
+ tone: StatusTone | "text";
25
+ }
26
+
27
+ export interface PluginRowText {
28
+ /** After the name: the version, a history badge, then the status suffix. */
29
+ segments: PluginRowSegment[];
30
+ /** The segments joined — exactly what the row renders after the name. */
31
+ text: string;
32
+ }
33
+
34
+ export function pluginRowText(item: PluginPluginItem): PluginRowText {
35
+ const { plugin, status } = item;
36
+ const segments: PluginRowSegment[] = [];
37
+
38
+ // A version is shown only when it carries information. "0.0.0" means
39
+ // installed with an unknown version (Anthropic's official plugins record
40
+ // exactly that) — it is never printed, and nothing below depends on it.
41
+ const installed = status?.installed ?? plugin.installedVersion;
42
+ const shown = isKnownVersion(installed ?? undefined) ? installed : undefined;
43
+
44
+ // "What just changed" and "what state it is in now" are independent facts
45
+ // and both can be true at once: an external update can move a plugin
46
+ // 2.9.0 → 3.0.1 while the marketplace already offers 3.3.1. So the badge
47
+ // replaces only the version, and the status suffix is always appended.
48
+ if (plugin.recentlyUpdatedFrom && shown) {
49
+ // Show what actually happened, not just where it landed — the update may
50
+ // have come from Claude Code or a direct CLI call without telling us.
51
+ segments.push({
52
+ text: ` v${plugin.recentlyUpdatedFrom} → v${shown} updated`,
53
+ tone: "success",
54
+ });
55
+ } else if (plugin.recentlyInstalled) {
56
+ // Newly seen. A plugin can be genuinely installed with no version we can
57
+ // name, so the version is optional here.
58
+ segments.push({
59
+ text: shown ? ` v${shown} new` : " new",
60
+ tone: "success",
61
+ });
62
+ } else if (shown) {
63
+ segments.push({ text: ` v${shown}`, tone: "muted" });
64
+ }
65
+
66
+ const suffix = pluginStatusText(status);
67
+ if (suffix.suffix) segments.push({ text: suffix.suffix, tone: suffix.tone });
68
+
69
+ return { segments, text: segments.map((s) => s.text).join("") };
70
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A plugin's status, as the words and tone the Plugins screen shows.
3
+ *
4
+ * Pure, and a function of the classifier's answer ALONE. The row used to build
5
+ * this text from `PluginInfo` flags with its own rules, and every clause was
6
+ * gated on the installed version being displayable — so a plugin recorded as
7
+ * "0.0.0" rendered with no status at all and read as healthy, while
8
+ * `claudeup update` said `unknown`. Nothing here looks at the version.
9
+ *
10
+ * The note is shown VERBATIM: it is the exact string `claudeup update` prints,
11
+ * which is what makes the two front ends say the same thing.
12
+ */
13
+
14
+ import type { PluginUpdateItem } from "../../services/update-plan.js";
15
+
16
+ export type StatusTone = "muted" | "warning" | "danger" | "success" | "info";
17
+
18
+ export interface PluginStatusText {
19
+ /** Text after the version, with its leading space; "" for nothing to say. */
20
+ suffix: string;
21
+ tone: StatusTone;
22
+ }
23
+
24
+ export function pluginStatusText(
25
+ status: PluginUpdateItem | null,
26
+ ): PluginStatusText {
27
+ if (!status) return { suffix: "", tone: "muted" };
28
+ const withNote = status.note ? ` (${status.note})` : "";
29
+
30
+ switch (status.action) {
31
+ case "unknown":
32
+ // Amber, not red: "cannot tell" is not a failure.
33
+ return {
34
+ suffix: ` ? ${status.note ?? "cannot tell whether it is current"}`,
35
+ tone: "warning",
36
+ };
37
+ case "update":
38
+ return {
39
+ suffix: `${status.target ? ` → v${status.target} available` : " update available"}${withNote}`,
40
+ tone: "warning",
41
+ };
42
+ case "repair":
43
+ return { suffix: ` stale — reinstall${withNote}`, tone: "warning" };
44
+ case "install":
45
+ // `absent` only ever comes from a profile pin (see `pinForRow`), so it
46
+ // may say so. An undeclared row that is merely enabled must not.
47
+ return status.reason === "absent"
48
+ ? { suffix: " not installed — profile declares it", tone: "warning" }
49
+ : // Enabled with nothing on disk is a broken state; a missing
50
+ // declared plugin is routine work. Red against amber is the
51
+ // distinction that matters.
52
+ { suffix: ` not installed${withNote}`, tone: "danger" };
53
+ case "current":
54
+ return { suffix: withNote, tone: "muted" };
55
+ }
56
+ }