claudeup 4.26.0 → 4.27.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__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +79 -29
- package/src/services/plugin-manager.ts +5 -0
- package/src/services/version-check.ts +11 -5
- package/src/services/version-snapshot.ts +63 -20
- package/src/ui/App.tsx +12 -1
- package/src/ui/renderers/pluginRenderers.tsx +20 -12
- package/src/ui/screens/PluginsScreen.tsx +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.27.1",
|
|
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
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.27.1",
|
|
68
|
+
"claudeup-darwin-x64": "4.27.1",
|
|
69
|
+
"claudeup-linux-x64": "4.27.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the update-check refresh fix. checkForUpdates caches within a session
|
|
3
|
+
* (so we don't hammer the registry), but `force` bypasses that cache so a
|
|
4
|
+
* release landing mid-session is actually seen — the 4.25-vs-4.26 banner
|
|
5
|
+
* staleness. (The fetch itself also sets `cache: "no-store"` so a CDN-stale
|
|
6
|
+
* response can't mask a fresh release.)
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import { checkForUpdates } from "../services/version-check.js";
|
|
10
|
+
|
|
11
|
+
describe("checkForUpdates — session cache vs forced re-check", () => {
|
|
12
|
+
const realFetch = globalThis.fetch;
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.fetch = realFetch;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("serves the session cache by default and re-fetches only when forced", async () => {
|
|
18
|
+
let calls = 0;
|
|
19
|
+
let latest = "9.9.9";
|
|
20
|
+
globalThis.fetch = (async () => {
|
|
21
|
+
calls++;
|
|
22
|
+
return new Response(JSON.stringify({ version: latest }), {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
});
|
|
26
|
+
}) as typeof fetch;
|
|
27
|
+
|
|
28
|
+
// Force the first call so the test is independent of any cache another
|
|
29
|
+
// test in the same process may have primed.
|
|
30
|
+
const first = await checkForUpdates(true);
|
|
31
|
+
expect(calls).toBe(1);
|
|
32
|
+
expect(first.latestVersion).toBe("9.9.9");
|
|
33
|
+
|
|
34
|
+
// Default call is served from the session cache — no new fetch.
|
|
35
|
+
const cached = await checkForUpdates();
|
|
36
|
+
expect(calls).toBe(1);
|
|
37
|
+
expect(cached.latestVersion).toBe("9.9.9");
|
|
38
|
+
|
|
39
|
+
// A release lands; a forced re-check bypasses the cache and sees it.
|
|
40
|
+
latest = "9.9.10";
|
|
41
|
+
const forced = await checkForUpdates(true);
|
|
42
|
+
expect(calls).toBe(2);
|
|
43
|
+
expect(forced.latestVersion).toBe("9.9.10");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
diffVersions,
|
|
4
|
+
isKnownVersion,
|
|
5
|
+
UNKNOWN_VERSION,
|
|
6
|
+
} from "../services/version-snapshot.js";
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Plugins get updated by things claudeup does not control — Claude Code's own
|
|
@@ -8,20 +12,60 @@ import { diffVersions } from "../services/version-snapshot.js";
|
|
|
8
12
|
* source; logging only claudeup's own actions catches none of them.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
describe("
|
|
12
|
-
test("
|
|
15
|
+
describe("isKnownVersion", () => {
|
|
16
|
+
test('"0.0.0" is not a usable version', () => {
|
|
17
|
+
// Claude Code records this for a plugin whose version it cannot determine.
|
|
18
|
+
expect(isKnownVersion(UNKNOWN_VERSION)).toBe(false);
|
|
19
|
+
expect(isKnownVersion(undefined)).toBe(false);
|
|
20
|
+
expect(isKnownVersion("")).toBe(false);
|
|
21
|
+
expect(isKnownVersion("1.0.0")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("diffVersions — first run", () => {
|
|
26
|
+
test("no baseline reports nothing at all", () => {
|
|
27
|
+
// Without this, the first launch would flag every installed plugin as new,
|
|
28
|
+
// which is both wrong and the fastest way to train someone to ignore badges.
|
|
13
29
|
expect(
|
|
14
|
-
diffVersions({ "dev@magus": "2.12.
|
|
15
|
-
).toEqual([
|
|
30
|
+
diffVersions(null, { "dev@magus": "2.12.1", "a@mp": UNKNOWN_VERSION }),
|
|
31
|
+
).toEqual([]);
|
|
16
32
|
});
|
|
17
33
|
|
|
18
|
-
test("
|
|
19
|
-
|
|
34
|
+
test("an empty baseline is NOT the same as no baseline", () => {
|
|
35
|
+
// A real snapshot that happens to be empty means we have looked before and
|
|
36
|
+
// saw nothing, so anything present now is genuinely new.
|
|
37
|
+
const changes = diffVersions({}, { "dev@magus": "2.12.1" });
|
|
38
|
+
expect(changes).toEqual([
|
|
39
|
+
{ pluginId: "dev@magus", kind: "installed", to: "2.12.1" },
|
|
40
|
+
]);
|
|
20
41
|
});
|
|
42
|
+
});
|
|
21
43
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
44
|
+
describe("diffVersions — installs", () => {
|
|
45
|
+
test("a plugin absent from the baseline is reported as installed, not updated", () => {
|
|
46
|
+
const changes = diffVersions({ "other@mp": "1.0.0" }, { "new@mp": "3.1.0" });
|
|
47
|
+
expect(changes).toEqual([
|
|
48
|
+
{ pluginId: "new@mp", kind: "installed", to: "3.1.0" },
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("an install with an unknown version is still reported", () => {
|
|
53
|
+
// The version is unusable but the install is real — the UI shows "new"
|
|
54
|
+
// with no version rather than suppressing the plugin entirely.
|
|
55
|
+
const changes = diffVersions({}, { "a@mp": UNKNOWN_VERSION });
|
|
56
|
+
expect(changes).toEqual([
|
|
57
|
+
{ pluginId: "a@mp", kind: "installed", to: UNKNOWN_VERSION },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("diffVersions — updates", () => {
|
|
63
|
+
test("reports a genuine version transition", () => {
|
|
64
|
+
expect(
|
|
65
|
+
diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
|
|
66
|
+
).toEqual([
|
|
67
|
+
{ pluginId: "dev@magus", kind: "updated", from: "2.12.0", to: "2.12.1" },
|
|
68
|
+
]);
|
|
25
69
|
});
|
|
26
70
|
|
|
27
71
|
test("unchanged versions report nothing", () => {
|
|
@@ -30,32 +74,38 @@ describe("diffVersions", () => {
|
|
|
30
74
|
).toEqual([]);
|
|
31
75
|
});
|
|
32
76
|
|
|
33
|
-
test(
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
).toEqual([]);
|
|
39
|
-
expect(
|
|
40
|
-
diffVersions({ "a@mp": "1.0.0" }, { "a@mp": "0.0.0" }),
|
|
41
|
-
).toEqual([]);
|
|
77
|
+
test("learning a previously unknown version is not an update", () => {
|
|
78
|
+
// This is exactly what v4.22.0's registry overlay caused: versions that
|
|
79
|
+
// could not be resolved suddenly could be. Reporting it would announce
|
|
80
|
+
// claudeup's own bugfix as if the user's plugins had changed.
|
|
81
|
+
expect(diffVersions({ "a@mp": UNKNOWN_VERSION }, { "a@mp": "5.3.0" })).toEqual([]);
|
|
42
82
|
});
|
|
43
83
|
|
|
84
|
+
test("losing a known version is not an update either", () => {
|
|
85
|
+
expect(diffVersions({ "a@mp": "5.3.0" }, { "a@mp": UNKNOWN_VERSION })).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a downgrade IS reported", () => {
|
|
89
|
+
// Rolling back is a real change, arguably the most important to surface.
|
|
90
|
+
expect(diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" })).toEqual([
|
|
91
|
+
{ pluginId: "a@mp", kind: "updated", from: "2.0.0", to: "1.0.0" },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("diffVersions — removals and mixed sets", () => {
|
|
44
97
|
test("a removed plugin is not reported", () => {
|
|
45
98
|
expect(diffVersions({ "gone@mp": "1.0.0" }, {})).toEqual([]);
|
|
46
99
|
});
|
|
47
100
|
|
|
48
|
-
test("
|
|
101
|
+
test("classifies each plugin independently in one pass", () => {
|
|
49
102
|
const changes = diffVersions(
|
|
50
|
-
{
|
|
51
|
-
{ "
|
|
103
|
+
{ same: "1.0.0", moved: "2.0.0", learned: UNKNOWN_VERSION },
|
|
104
|
+
{ same: "1.0.0", moved: "2.1.0", learned: "9.0.0", fresh: "0.1.0" },
|
|
52
105
|
);
|
|
53
|
-
expect(changes
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// Rolling back is a change the user should see, not silently hidden.
|
|
58
|
-
const changes = diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" });
|
|
59
|
-
expect(changes).toEqual([{ pluginId: "a@mp", from: "2.0.0", to: "1.0.0" }]);
|
|
106
|
+
expect(changes).toEqual([
|
|
107
|
+
{ pluginId: "moved", kind: "updated", from: "2.0.0", to: "2.1.0" },
|
|
108
|
+
{ pluginId: "fresh", kind: "installed", to: "0.1.0" },
|
|
109
|
+
]);
|
|
60
110
|
});
|
|
61
111
|
});
|
|
@@ -78,6 +78,11 @@ export interface PluginInfo {
|
|
|
78
78
|
* rather than by claudeup itself. Drives the "updated" badge.
|
|
79
79
|
*/
|
|
80
80
|
recentlyUpdatedFrom?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Set when claudeup is seeing this plugin installed for the first time.
|
|
83
|
+
* Never set on the very first run, when there is no baseline to compare to.
|
|
84
|
+
*/
|
|
85
|
+
recentlyInstalled?: boolean;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
/**
|
|
@@ -33,6 +33,9 @@ async function fetchLatestVersion(): Promise<string | null> {
|
|
|
33
33
|
signal: controller.signal,
|
|
34
34
|
headers: {
|
|
35
35
|
Accept: "application/json",
|
|
36
|
+
// Always revalidate — a CDN/HTTP-cached response serves a stale
|
|
37
|
+
// "latest" right after a release (the 4.25-vs-4.26 flicker).
|
|
38
|
+
"Cache-Control": "no-cache",
|
|
36
39
|
},
|
|
37
40
|
});
|
|
38
41
|
|
|
@@ -70,12 +73,15 @@ function getUpdateType(
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
|
-
* Check if a new version is available
|
|
74
|
-
* Returns cached result
|
|
76
|
+
* Check if a new version is available.
|
|
77
|
+
* Returns the session-cached result unless `force` requests a fresh check.
|
|
75
78
|
*/
|
|
76
|
-
export async function checkForUpdates(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
export async function checkForUpdates(
|
|
80
|
+
force = false,
|
|
81
|
+
): Promise<VersionCheckResult> {
|
|
82
|
+
// Return the session-cached result unless a fresh check is forced — a periodic
|
|
83
|
+
// re-check passes force:true so a release landing mid-session updates the banner.
|
|
84
|
+
if (!force && cachedResult) {
|
|
79
85
|
return cachedResult;
|
|
80
86
|
}
|
|
81
87
|
|
|
@@ -1,32 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* version-snapshot.ts — detect plugin
|
|
2
|
+
* version-snapshot.ts — detect plugin changes that happened outside claudeup.
|
|
3
3
|
*
|
|
4
4
|
* Plugins get updated by things claudeup does not control: Claude Code's own
|
|
5
5
|
* `plugin install`/`update`, the `claudeup claude` prerunner, or a direct CLI
|
|
6
6
|
* call. All of them write `installed_plugins.json`; none of them tell the user
|
|
7
|
-
* anything the next time claudeup opens, so versions
|
|
7
|
+
* anything the next time claudeup opens, so versions move underfoot silently.
|
|
8
8
|
*
|
|
9
9
|
* Rather than log what claudeup itself did — which misses every other source —
|
|
10
10
|
* this records what claudeup last *saw* installed and diffs against reality on
|
|
11
11
|
* load. Any change is reported, regardless of who made it.
|
|
12
12
|
*
|
|
13
13
|
* The snapshot is advisory UI state, kept separate from the update-check cache
|
|
14
|
-
* so a TTL expiry or a corrupt cache can never make plugins look
|
|
14
|
+
* so a TTL expiry or a corrupt cache can never make plugins look changed.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { promises as fs } from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import os from "node:os";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Sentinel recorded by Claude Code for a plugin that is installed but whose
|
|
23
|
+
* version is unknown — every plugin in the official Anthropic marketplace uses
|
|
24
|
+
* it. It is not a real version and must never be rendered as one.
|
|
25
|
+
*/
|
|
26
|
+
export const UNKNOWN_VERSION = "0.0.0";
|
|
27
|
+
|
|
28
|
+
/** True when a version string carries no usable information. */
|
|
29
|
+
export function isKnownVersion(v: string | undefined): v is string {
|
|
30
|
+
return !!v && v !== UNKNOWN_VERSION;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type PluginChangeKind = "installed" | "updated";
|
|
34
|
+
|
|
35
|
+
export interface PluginChange {
|
|
22
36
|
pluginId: string;
|
|
23
|
-
|
|
37
|
+
kind: PluginChangeKind;
|
|
38
|
+
/** Previous version. Only set for "updated", and always a known version. */
|
|
39
|
+
from?: string;
|
|
40
|
+
/** Current version. May be UNKNOWN_VERSION — callers must not render it raw. */
|
|
24
41
|
to: string;
|
|
25
42
|
}
|
|
26
43
|
|
|
27
44
|
interface SnapshotFile {
|
|
28
45
|
/** pluginId -> installedVersion as of the last time claudeup rendered it. */
|
|
29
46
|
seen: Record<string, string>;
|
|
47
|
+
/** Set on the run that first created the baseline. */
|
|
48
|
+
seededAt: string;
|
|
30
49
|
updatedAt: string;
|
|
31
50
|
}
|
|
32
51
|
|
|
@@ -41,35 +60,56 @@ async function read(): Promise<SnapshotFile | null> {
|
|
|
41
60
|
if (!data || typeof data.seen !== "object" || data.seen === null) return null;
|
|
42
61
|
return data;
|
|
43
62
|
} catch {
|
|
44
|
-
return null; // absent or corrupt — treated as "
|
|
63
|
+
return null; // absent or corrupt — treated as "no baseline yet"
|
|
45
64
|
}
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
/**
|
|
49
|
-
* Diff
|
|
68
|
+
* Diff what is installed now against the previous snapshot.
|
|
69
|
+
*
|
|
70
|
+
* Three cases are deliberately silent, because each reflects claudeup's own
|
|
71
|
+
* knowledge changing rather than anything happening to the user's plugins:
|
|
72
|
+
*
|
|
73
|
+
* 1. No baseline (first run). Every plugin would otherwise be reported as
|
|
74
|
+
* newly installed on the very first launch — a guaranteed false flood that
|
|
75
|
+
* trains the user to ignore the badge. The first run seeds and says nothing.
|
|
50
76
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
77
|
+
* 2. A version moving to or from UNKNOWN_VERSION. That is claudeup learning
|
|
78
|
+
* (or losing) a version it could not previously resolve — notably after
|
|
79
|
+
* v4.22.0 taught it to read the registry — not the plugin changing.
|
|
80
|
+
*
|
|
81
|
+
* 3. A plugin disappearing. Uninstalls are visible elsewhere; nothing to badge.
|
|
82
|
+
*
|
|
83
|
+
* A downgrade IS reported: rolling back is a real change worth surfacing.
|
|
56
84
|
*/
|
|
57
85
|
export function diffVersions(
|
|
58
86
|
previous: Record<string, string> | null,
|
|
59
87
|
current: Record<string, string>,
|
|
60
|
-
):
|
|
88
|
+
): PluginChange[] {
|
|
61
89
|
if (!previous) return [];
|
|
62
|
-
|
|
90
|
+
|
|
91
|
+
const changes: PluginChange[] = [];
|
|
63
92
|
for (const [pluginId, to] of Object.entries(current)) {
|
|
64
93
|
const from = previous[pluginId];
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
|
|
95
|
+
// Never seen before → newly installed, not an update. There is no
|
|
96
|
+
// meaningful "from", and `to` may legitimately be UNKNOWN_VERSION.
|
|
97
|
+
if (from === undefined) {
|
|
98
|
+
changes.push({ pluginId, kind: "installed", to });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (from === to) continue;
|
|
103
|
+
|
|
104
|
+
// One side carries no real version: we learned or lost it, nothing moved.
|
|
105
|
+
if (!isKnownVersion(from) || !isKnownVersion(to)) continue;
|
|
106
|
+
|
|
107
|
+
changes.push({ pluginId, kind: "updated", from, to });
|
|
68
108
|
}
|
|
69
109
|
return changes;
|
|
70
110
|
}
|
|
71
111
|
|
|
72
|
-
/** Load the previous snapshot's version map, or null
|
|
112
|
+
/** Load the previous snapshot's version map, or null when no baseline exists. */
|
|
73
113
|
export async function loadSeenVersions(): Promise<Record<string, string> | null> {
|
|
74
114
|
const data = await read();
|
|
75
115
|
return data ? data.seen : null;
|
|
@@ -77,7 +117,7 @@ export async function loadSeenVersions(): Promise<Record<string, string> | null>
|
|
|
77
117
|
|
|
78
118
|
/**
|
|
79
119
|
* Persist what is installed now, so the same change is not reported twice.
|
|
80
|
-
* Best-effort: a failure here must never break the UI
|
|
120
|
+
* Best-effort: a failure here must never break the UI — it only means the badge
|
|
81
121
|
* shows again next launch.
|
|
82
122
|
*/
|
|
83
123
|
export async function saveSeenVersions(
|
|
@@ -86,9 +126,12 @@ export async function saveSeenVersions(
|
|
|
86
126
|
try {
|
|
87
127
|
const file = snapshotPath();
|
|
88
128
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
129
|
+
const existing = await read();
|
|
130
|
+
const now = new Date().toISOString();
|
|
89
131
|
const payload: SnapshotFile = {
|
|
90
132
|
seen: current,
|
|
91
|
-
|
|
133
|
+
seededAt: existing?.seededAt ?? now,
|
|
134
|
+
updatedAt: now,
|
|
92
135
|
};
|
|
93
136
|
await fs.writeFile(file, JSON.stringify(payload, null, 2), "utf-8");
|
|
94
137
|
} catch {
|
package/src/ui/App.tsx
CHANGED
|
@@ -348,11 +348,22 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
348
348
|
} | null>(null);
|
|
349
349
|
const gitignoreModal = useGitignoreModal();
|
|
350
350
|
|
|
351
|
-
// Check for updates on startup (
|
|
351
|
+
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
352
|
+
// the session cache) so the banner reflects a release that lands while
|
|
353
|
+
// claudeup is left open — otherwise it shows the stale startup value.
|
|
352
354
|
useEffect(() => {
|
|
353
355
|
checkForUpdates()
|
|
354
356
|
.then(setUpdateInfo)
|
|
355
357
|
.catch(() => {});
|
|
358
|
+
const id = setInterval(
|
|
359
|
+
() => {
|
|
360
|
+
checkForUpdates(true)
|
|
361
|
+
.then(setUpdateInfo)
|
|
362
|
+
.catch(() => {});
|
|
363
|
+
},
|
|
364
|
+
15 * 60 * 1000, // 15 min
|
|
365
|
+
);
|
|
366
|
+
return () => clearInterval(id);
|
|
356
367
|
}, []);
|
|
357
368
|
|
|
358
369
|
// Auto-dismiss recovery banner after 5 seconds
|
|
@@ -17,6 +17,7 @@ import { theme } from "../theme.js";
|
|
|
17
17
|
import { highlightMatches } from "../../utils/fuzzy-search.js";
|
|
18
18
|
import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
|
|
19
19
|
import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
|
|
20
|
+
import { isKnownVersion } from "../../services/version-snapshot.js";
|
|
20
21
|
|
|
21
22
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -125,19 +126,28 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
125
126
|
// to render exactly like a healthy install.
|
|
126
127
|
const notInstalled = isEnabledButNotInstalled(plugin);
|
|
127
128
|
|
|
128
|
-
//
|
|
129
|
+
// A known version is one we can actually show. "0.0.0" means installed with an
|
|
130
|
+
// unknown version (every official Anthropic plugin records it) — never render it.
|
|
131
|
+
const shownVersion = isKnownVersion(plugin.installedVersion)
|
|
132
|
+
? plugin.installedVersion
|
|
133
|
+
: undefined;
|
|
134
|
+
|
|
129
135
|
let versionStr = "";
|
|
130
136
|
if (plugin.isOrphaned) {
|
|
131
137
|
versionStr = " deprecated";
|
|
132
138
|
} else if (notInstalled) {
|
|
133
139
|
versionStr = " not installed";
|
|
134
|
-
} else if (hasAnyScope
|
|
135
|
-
if (plugin.recentlyUpdatedFrom) {
|
|
140
|
+
} else if (hasAnyScope) {
|
|
141
|
+
if (plugin.recentlyUpdatedFrom && shownVersion) {
|
|
136
142
|
// Show what actually happened, not just where it landed — the update may
|
|
137
143
|
// have come from Claude Code or the prerunner without ever telling us.
|
|
138
|
-
versionStr = ` v${plugin.recentlyUpdatedFrom} → v${
|
|
139
|
-
} else {
|
|
140
|
-
|
|
144
|
+
versionStr = ` v${plugin.recentlyUpdatedFrom} → v${shownVersion} updated`;
|
|
145
|
+
} else if (plugin.recentlyInstalled) {
|
|
146
|
+
// Newly seen. Version is optional here: a plugin can be genuinely
|
|
147
|
+
// installed with no version we can name.
|
|
148
|
+
versionStr = shownVersion ? ` v${shownVersion} new` : " new";
|
|
149
|
+
} else if (shownVersion) {
|
|
150
|
+
versionStr = ` v${shownVersion}`;
|
|
141
151
|
if (plugin.hasUpdate && plugin.version) {
|
|
142
152
|
versionStr += ` → v${plugin.version}`;
|
|
143
153
|
}
|
|
@@ -160,9 +170,7 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
160
170
|
|
|
161
171
|
if (plugin.isOrphaned) {
|
|
162
172
|
const ver =
|
|
163
|
-
plugin.installedVersion
|
|
164
|
-
? ` v${plugin.installedVersion}`
|
|
165
|
-
: "";
|
|
173
|
+
isKnownVersion(plugin.installedVersion) ? ` v${plugin.installedVersion}` : "";
|
|
166
174
|
return (
|
|
167
175
|
<text>
|
|
168
176
|
<span fg={theme.colors.danger}> ■■■ </span>
|
|
@@ -184,7 +192,7 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
184
192
|
<MetaText
|
|
185
193
|
text={versionStr}
|
|
186
194
|
tone={
|
|
187
|
-
plugin.recentlyUpdatedFrom
|
|
195
|
+
plugin.recentlyUpdatedFrom || plugin.recentlyInstalled
|
|
188
196
|
? "success"
|
|
189
197
|
: notInstalled || plugin.hasUpdate
|
|
190
198
|
? "warning"
|
|
@@ -239,9 +247,9 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
239
247
|
components.push(`${Object.keys(plugin.lspServers).length} LSP`);
|
|
240
248
|
}
|
|
241
249
|
|
|
242
|
-
const showVersion = plugin.version
|
|
250
|
+
const showVersion = isKnownVersion(plugin.version ?? undefined);
|
|
243
251
|
const showInstalledVersion =
|
|
244
|
-
isInstalled && plugin.installedVersion
|
|
252
|
+
isInstalled && isKnownVersion(plugin.installedVersion);
|
|
245
253
|
|
|
246
254
|
return (
|
|
247
255
|
<box flexDirection="column">
|
|
@@ -90,7 +90,12 @@ export function PluginsScreen() {
|
|
|
90
90
|
const previous = await loadSeenVersions();
|
|
91
91
|
for (const change of diffVersions(previous, current)) {
|
|
92
92
|
const target = pluginData.find((p) => p.id === change.pluginId);
|
|
93
|
-
if (target)
|
|
93
|
+
if (!target) continue;
|
|
94
|
+
if (change.kind === "updated") {
|
|
95
|
+
target.recentlyUpdatedFrom = change.from;
|
|
96
|
+
} else {
|
|
97
|
+
target.recentlyInstalled = true;
|
|
98
|
+
}
|
|
94
99
|
}
|
|
95
100
|
// Record now so the same change is not reported on the next launch.
|
|
96
101
|
void saveSeenVersions(current);
|