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.
- package/package.json +4 -4
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -0
- package/src/__tests__/cli-tool-commands.test.ts +1 -1
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +142 -2
- package/src/__tests__/content-drift.test.ts +60 -0
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +2 -3
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
- package/src/__tests__/models-presets-marketplace.test.ts +141 -0
- package/src/__tests__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/__tests__/scope-action.test.ts +69 -19
- package/src/__tests__/shell-script-callers.test.ts +8 -8
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/update-apply.test.ts +283 -1
- package/src/__tests__/update-plan.test.ts +773 -31
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/cli/router.ts +0 -5
- package/src/cli/update-view.ts +92 -12
- package/src/cli/update.ts +114 -38
- package/src/data/models-presets.ts +10 -11
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +2 -174
- package/src/services/community-styles.ts +1 -1
- package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
- package/src/services/gitignore-fixer.ts +1 -1
- package/src/services/marketplace-refresh.ts +30 -0
- package/src/services/plugin-manager.ts +200 -283
- package/src/services/resolver.ts +7 -2
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/update-engine.ts +220 -32
- package/src/services/update-plan.ts +640 -160
- package/src/services/version-snapshot.ts +1 -1
- package/src/types/bun.d.ts +6 -0
- package/src/ui/App.tsx +2 -27
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/pluginsAdapter.ts +57 -0
- package/src/ui/components/modals/ModalContainer.tsx +0 -26
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +2 -3
- package/src/ui/renderers/cliToolRenderers.tsx +108 -67
- package/src/ui/renderers/pluginRenderers.tsx +157 -98
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/screens/CliToolsScreen.tsx +154 -280
- package/src/ui/screens/PluginsScreen.tsx +156 -140
- package/src/ui/state/types.ts +0 -7
- package/src/__tests__/dual-write-prevention.test.ts +0 -380
- package/src/__tests__/gap-fill-versions.test.ts +0 -389
- package/src/__tests__/plugin-version-check.test.ts +0 -762
- package/src/cli/claude.ts +0 -21
- package/src/prerunner/index.ts +0 -500
- package/src/services/plugin-version-check.ts +0 -360
- package/src/services/update-cache.ts +0 -78
- package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
- 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
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
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
|
-
{
|
|
222
|
+
<RowSegments segments={segments} selected={true} />{" "}
|
|
222
223
|
</text>
|
|
223
224
|
);
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
// Fuzzy highlight
|
|
227
|
-
const
|
|
228
|
+
const nameSegments = item.matches
|
|
228
229
|
? highlightMatches(plugin.name, item.matches)
|
|
229
230
|
: null;
|
|
230
|
-
const displayName =
|
|
231
|
-
?
|
|
231
|
+
const displayName = nameSegments
|
|
232
|
+
? nameSegments.map((seg) => seg.text).join("")
|
|
232
233
|
: plugin.name;
|
|
233
234
|
|
|
234
235
|
if (plugin.isOrphaned) {
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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 &&
|
|
264
|
+
hasAnyScope && installedSomewhere
|
|
265
|
+
? theme.colors.text
|
|
266
|
+
: theme.colors.muted
|
|
260
267
|
}
|
|
261
268
|
>
|
|
262
269
|
{displayName}
|
|
263
270
|
</span>
|
|
264
|
-
<
|
|
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
|
-
//
|
|
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} —
|
|
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
|
-
|
|
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
|
|
320
|
-
|
|
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 &&
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
578
|
+
{needsWork ? (
|
|
525
579
|
<ActionHints
|
|
526
580
|
hints={[
|
|
527
581
|
{
|
|
528
582
|
key: "U",
|
|
529
|
-
label:
|
|
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
|
+
}
|