claudeup 4.24.0 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/marketplace-version.test.ts +81 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/version-snapshot.test.ts +61 -0
- package/src/data/marketplaces.ts +4 -1
- package/src/data/predefined-profiles.ts +0 -1
- package/src/services/marketplace-fetcher.ts +29 -1
- package/src/services/plugin-manager.ts +18 -2
- package/src/services/version-snapshot.ts +97 -0
- package/src/types/index.ts +1 -0
- package/src/ui/adapters/pluginsAdapter.ts +4 -6
- package/src/ui/components/CategoryHeader.tsx +5 -0
- package/src/ui/renderers/pluginRenderers.tsx +23 -4
- package/src/ui/screens/PluginsScreen.tsx +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.26.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.26.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.26.0",
|
|
69
|
+
"claudeup-linux-x64": "4.26.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
clearMarketplaceCache,
|
|
4
|
+
fetchMarketplacePlugins,
|
|
5
|
+
getMarketplaceVersion,
|
|
6
|
+
} from "../services/marketplace-fetcher";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Coverage for capturing a marketplace's `metadata.version` during a fetch.
|
|
10
|
+
*
|
|
11
|
+
* The plugins TUI header renders "Name vX.Y.Z (count)" when a marketplace
|
|
12
|
+
* declares a version, so `fetchMarketplacePlugins` must stash the version and
|
|
13
|
+
* expose it via `getMarketplaceVersion`. Marketplaces without a version must
|
|
14
|
+
* yield `undefined` rather than crashing or leaking a stray "vundefined".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const realFetch = globalThis.fetch;
|
|
18
|
+
|
|
19
|
+
function mockFetchOnce(body: unknown): void {
|
|
20
|
+
globalThis.fetch = (async () =>
|
|
21
|
+
new Response(JSON.stringify(body), {
|
|
22
|
+
status: 200,
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
})) as typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("fetchMarketplacePlugins — metadata.version capture", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
clearMarketplaceCache();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
globalThis.fetch = realFetch;
|
|
34
|
+
clearMarketplaceCache();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("captures metadata.version so getMarketplaceVersion returns it", async () => {
|
|
38
|
+
mockFetchOnce({
|
|
39
|
+
metadata: { version: "8.0.0" },
|
|
40
|
+
plugins: [{ name: "dev", description: "Dev workflow" }],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
44
|
+
|
|
45
|
+
expect(getMarketplaceVersion("magus")).toBe("8.0.0");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("yields undefined when marketplace.json has no metadata.version", async () => {
|
|
49
|
+
mockFetchOnce({
|
|
50
|
+
plugins: [{ name: "core", description: "Anthropic core plugin" }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await fetchMarketplacePlugins("official", "anthropics/official");
|
|
54
|
+
|
|
55
|
+
expect(getMarketplaceVersion("official")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("yields undefined for a marketplace with metadata but no version field", async () => {
|
|
59
|
+
mockFetchOnce({
|
|
60
|
+
metadata: { description: "no version here" },
|
|
61
|
+
plugins: [{ name: "core", description: "" }],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await fetchMarketplacePlugins("no-version", "owner/no-version");
|
|
65
|
+
|
|
66
|
+
expect(getMarketplaceVersion("no-version")).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("clears captured versions on clearMarketplaceCache", async () => {
|
|
70
|
+
mockFetchOnce({
|
|
71
|
+
metadata: { version: "1.0.0" },
|
|
72
|
+
plugins: [],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await fetchMarketplacePlugins("magus-marketing", "MadAppGang/magus-marketing");
|
|
76
|
+
expect(getMarketplaceVersion("magus-marketing")).toBe("1.0.0");
|
|
77
|
+
|
|
78
|
+
clearMarketplaceCache();
|
|
79
|
+
expect(getMarketplaceVersion("magus-marketing")).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change B: our own marketplaces (magus, magus-marketing) are always listed in
|
|
3
|
+
* the plugins TUI — even when unregistered — and sorted to the top.
|
|
4
|
+
*
|
|
5
|
+
* `magus-marketing` is deliberately NOT `featured`, so before this change it was
|
|
6
|
+
* invisible until manually registered. The `owned` flag both (a) qualifies it
|
|
7
|
+
* for the plugin-manager "always include" gate and (b) sorts it first.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
defaultMarketplaces,
|
|
12
|
+
getAllMarketplaces,
|
|
13
|
+
} from "../data/marketplaces.js";
|
|
14
|
+
import type { LocalMarketplace } from "../services/local-marketplace.js";
|
|
15
|
+
import type { Marketplace } from "../types/index.js";
|
|
16
|
+
import { buildPluginBrowserItems } from "../ui/adapters/pluginsAdapter.js";
|
|
17
|
+
|
|
18
|
+
describe("owned marketplaces", () => {
|
|
19
|
+
it("marks magus and magus-marketing as owned, and leaves others untouched", () => {
|
|
20
|
+
const magus = defaultMarketplaces.find((m) => m.name === "magus");
|
|
21
|
+
const marketing = defaultMarketplaces.find(
|
|
22
|
+
(m) => m.name === "magus-marketing",
|
|
23
|
+
);
|
|
24
|
+
expect(magus?.owned).toBe(true);
|
|
25
|
+
expect(marketing?.owned).toBe(true);
|
|
26
|
+
expect(
|
|
27
|
+
defaultMarketplaces.find((m) => m.name === "claude-plugins-official")
|
|
28
|
+
?.owned,
|
|
29
|
+
).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("qualifies magus-marketing for the always-show gate without being featured", () => {
|
|
33
|
+
const marketing = defaultMarketplaces.find(
|
|
34
|
+
(m) => m.name === "magus-marketing",
|
|
35
|
+
);
|
|
36
|
+
expect(marketing).toBeDefined();
|
|
37
|
+
// The plugin-manager gate: configured || official || featured || owned.
|
|
38
|
+
const alwaysShown = !!(
|
|
39
|
+
marketing?.official ||
|
|
40
|
+
marketing?.featured ||
|
|
41
|
+
marketing?.owned
|
|
42
|
+
);
|
|
43
|
+
expect(alwaysShown).toBe(true);
|
|
44
|
+
expect(marketing?.featured).toBeUndefined(); // and NOT via featured
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("sorts owned first and deprecated last, regardless of input order", () => {
|
|
48
|
+
const mk = (name: string, extra: Partial<Marketplace>): Marketplace => ({
|
|
49
|
+
name,
|
|
50
|
+
displayName: name,
|
|
51
|
+
source: { source: "github", repo: `x/${name}` },
|
|
52
|
+
description: "",
|
|
53
|
+
...extra,
|
|
54
|
+
});
|
|
55
|
+
// Deliberately "wrong" order: official, deprecated, owned.
|
|
56
|
+
const marketplaces: Marketplace[] = [
|
|
57
|
+
mk("official-one", { official: true }),
|
|
58
|
+
mk("claude-code-plugins", { deprecated: true }),
|
|
59
|
+
mk("mine", { owned: true }),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const items = buildPluginBrowserItems({
|
|
63
|
+
marketplaces,
|
|
64
|
+
plugins: [],
|
|
65
|
+
collapsedMarketplaces: new Set(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const order: string[] = [];
|
|
69
|
+
for (const i of items)
|
|
70
|
+
if (i.kind === "category") order.push(i.marketplace.name);
|
|
71
|
+
|
|
72
|
+
expect(order[0]).toBe("mine"); // owned first
|
|
73
|
+
expect(order[order.length - 1]).toBe("claude-code-plugins"); // deprecated last
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("preserves owned when getAllMarketplaces rebuilds a locally-cached marketplace", () => {
|
|
77
|
+
// The local-cache path reconstructs the Marketplace object; owned must
|
|
78
|
+
// survive so already-cloned magus still sorts first / always shows.
|
|
79
|
+
const local = new Map<string, LocalMarketplace>([
|
|
80
|
+
[
|
|
81
|
+
"magus",
|
|
82
|
+
{
|
|
83
|
+
name: "Magus",
|
|
84
|
+
description: "",
|
|
85
|
+
gitRepo: "MadAppGang/magus",
|
|
86
|
+
plugins: [],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
]);
|
|
90
|
+
const magus = getAllMarketplaces(local).find((m) => m.name === "magus");
|
|
91
|
+
expect(magus?.owned).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { diffVersions } from "../services/version-snapshot.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugins get updated by things claudeup does not control — Claude Code's own
|
|
6
|
+
* `plugin update` writes installed_plugins.json without touching claudeup's
|
|
7
|
+
* bookkeeping. Diffing what we last rendered against reality catches every
|
|
8
|
+
* source; logging only claudeup's own actions catches none of them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe("diffVersions", () => {
|
|
12
|
+
test("reports a genuine version transition", () => {
|
|
13
|
+
expect(
|
|
14
|
+
diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
|
|
15
|
+
).toEqual([{ pluginId: "dev@magus", from: "2.12.0", to: "2.12.1" }]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("first run reports nothing — otherwise every plugin looks updated", () => {
|
|
19
|
+
expect(diffVersions(null, { "dev@magus": "2.12.1" })).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a newly installed plugin is not an update", () => {
|
|
23
|
+
// Absent from the snapshot means we have never seen it, not that it moved.
|
|
24
|
+
expect(diffVersions({}, { "dev@magus": "2.12.1" })).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("unchanged versions report nothing", () => {
|
|
28
|
+
expect(
|
|
29
|
+
diffVersions({ "dev@magus": "2.12.1" }, { "dev@magus": "2.12.1" }),
|
|
30
|
+
).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('"0.0.0" transitions are ignored on both sides', () => {
|
|
34
|
+
// 0.0.0 means installed-with-unknown-version (Anthropic official plugins).
|
|
35
|
+
// Treating it as a real version produces bogus "0.0.0 → x" badges.
|
|
36
|
+
expect(
|
|
37
|
+
diffVersions({ "a@mp": "0.0.0" }, { "a@mp": "1.0.0" }),
|
|
38
|
+
).toEqual([]);
|
|
39
|
+
expect(
|
|
40
|
+
diffVersions({ "a@mp": "1.0.0" }, { "a@mp": "0.0.0" }),
|
|
41
|
+
).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a removed plugin is not reported", () => {
|
|
45
|
+
expect(diffVersions({ "gone@mp": "1.0.0" }, {})).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("reports every changed plugin, leaving unchanged ones out", () => {
|
|
49
|
+
const changes = diffVersions(
|
|
50
|
+
{ "a@mp": "1.0.0", "b@mp": "2.0.0", "c@mp": "3.0.0" },
|
|
51
|
+
{ "a@mp": "1.1.0", "b@mp": "2.0.0", "c@mp": "4.0.0" },
|
|
52
|
+
);
|
|
53
|
+
expect(changes.map((c) => c.pluginId).sort()).toEqual(["a@mp", "c@mp"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a downgrade is still reported", () => {
|
|
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" }]);
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/data/marketplaces.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Marketplace } from "../types/index.js";
|
|
2
1
|
import type { LocalMarketplace } from "../services/local-marketplace.js";
|
|
2
|
+
import type { Marketplace } from "../types/index.js";
|
|
3
3
|
import { formatMarketplaceName } from "../utils/string-utils.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -38,6 +38,7 @@ export const defaultMarketplaces: Marketplace[] = [
|
|
|
38
38
|
"Professional plugins for frontend, backend, and code analysis",
|
|
39
39
|
official: false,
|
|
40
40
|
featured: true, // Always show plugins (expanded by default)
|
|
41
|
+
owned: true, // Ours: always listed, sorted to the top
|
|
41
42
|
},
|
|
42
43
|
{
|
|
43
44
|
name: "magus-marketing",
|
|
@@ -49,6 +50,7 @@ export const defaultMarketplaces: Marketplace[] = [
|
|
|
49
50
|
description:
|
|
50
51
|
"SEO, cold email outreach, AI image generation, and video editing plugins",
|
|
51
52
|
official: false,
|
|
53
|
+
owned: true, // Ours: always listed even when uninstalled, sorted to the top
|
|
52
54
|
// Not featured: a niche channel, so keep it collapsed by default.
|
|
53
55
|
},
|
|
54
56
|
{
|
|
@@ -138,6 +140,7 @@ export function getAllMarketplaces(
|
|
|
138
140
|
defaultMp?.official ?? repo.toLowerCase().includes("anthropics/"),
|
|
139
141
|
featured: defaultMp?.featured,
|
|
140
142
|
deprecated: defaultMp?.deprecated,
|
|
143
|
+
owned: defaultMp?.owned,
|
|
141
144
|
});
|
|
142
145
|
}
|
|
143
146
|
}
|
|
@@ -23,8 +23,26 @@ export interface MarketplacePlugin {
|
|
|
23
23
|
// Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
|
|
24
24
|
const marketplaceCache = new Map<string, MarketplacePlugin[]>();
|
|
25
25
|
|
|
26
|
+
// Session-level cache for each marketplace's declared version (from
|
|
27
|
+
// marketplace.json's top-level `metadata.version`). Mirrors `marketplaceCache`
|
|
28
|
+
// so the TUI can surface a marketplace's version without re-fetching. Only
|
|
29
|
+
// populated for marketplaces that actually declare a version.
|
|
30
|
+
const marketplaceVersionCache = new Map<string, string>();
|
|
31
|
+
|
|
26
32
|
export function clearMarketplaceCache(): void {
|
|
27
33
|
marketplaceCache.clear();
|
|
34
|
+
marketplaceVersionCache.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the version a marketplace declared in its `metadata.version`, if one
|
|
39
|
+
* was seen during a successful fetch this session. Returns `undefined` for
|
|
40
|
+
* marketplaces that declare no version (or haven't been fetched yet).
|
|
41
|
+
*/
|
|
42
|
+
export function getMarketplaceVersion(
|
|
43
|
+
marketplaceName: string,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
return marketplaceVersionCache.get(marketplaceName);
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
export async function fetchMarketplacePlugins(
|
|
@@ -75,9 +93,19 @@ export async function fetchMarketplacePlugins(
|
|
|
75
93
|
homepage?: string;
|
|
76
94
|
tags?: string[];
|
|
77
95
|
}
|
|
78
|
-
const data = (await response.json()) as {
|
|
96
|
+
const data = (await response.json()) as {
|
|
97
|
+
plugins?: RawPlugin[];
|
|
98
|
+
metadata?: { version?: string };
|
|
99
|
+
};
|
|
79
100
|
const plugins: MarketplacePlugin[] = [];
|
|
80
101
|
|
|
102
|
+
// Capture the marketplace's declared version (if any) so the TUI can
|
|
103
|
+
// display it. Absent/blank versions are simply not cached.
|
|
104
|
+
const version = data.metadata?.version;
|
|
105
|
+
if (typeof version === "string" && version.length > 0) {
|
|
106
|
+
marketplaceVersionCache.set(marketplaceName, version);
|
|
107
|
+
}
|
|
108
|
+
|
|
81
109
|
if (data.plugins && Array.isArray(data.plugins)) {
|
|
82
110
|
for (const plugin of data.plugins) {
|
|
83
111
|
plugins.push({
|
|
@@ -72,6 +72,12 @@ export interface PluginInfo {
|
|
|
72
72
|
mcpServers?: string[];
|
|
73
73
|
lspServers?: Record<string, unknown>;
|
|
74
74
|
isOrphaned?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Set when this plugin's installed version changed since claudeup last
|
|
77
|
+
* rendered it — including updates made by Claude Code or the prerunner
|
|
78
|
+
* rather than by claudeup itself. Drives the "updated" badge.
|
|
79
|
+
*/
|
|
80
|
+
recentlyUpdatedFrom?: string;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
/**
|
|
@@ -148,7 +154,12 @@ export async function getAvailablePlugins(
|
|
|
148
154
|
// Always include official and featured marketplaces so users can browse them
|
|
149
155
|
const marketplaceNames = new Set<string>();
|
|
150
156
|
for (const mp of defaultMarketplaces) {
|
|
151
|
-
if (
|
|
157
|
+
if (
|
|
158
|
+
configuredMarketplaces[mp.name] ||
|
|
159
|
+
mp.official ||
|
|
160
|
+
mp.featured ||
|
|
161
|
+
mp.owned
|
|
162
|
+
) {
|
|
152
163
|
marketplaceNames.add(mp.name);
|
|
153
164
|
}
|
|
154
165
|
}
|
|
@@ -333,7 +344,12 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
333
344
|
// Always include official and featured marketplaces so users can browse them
|
|
334
345
|
const marketplaceNames = new Set<string>();
|
|
335
346
|
for (const mp of defaultMarketplaces) {
|
|
336
|
-
if (
|
|
347
|
+
if (
|
|
348
|
+
configuredMarketplaces[mp.name] ||
|
|
349
|
+
mp.official ||
|
|
350
|
+
mp.featured ||
|
|
351
|
+
mp.owned
|
|
352
|
+
) {
|
|
337
353
|
marketplaceNames.add(mp.name);
|
|
338
354
|
}
|
|
339
355
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version-snapshot.ts — detect plugin version changes that happened outside claudeup.
|
|
3
|
+
*
|
|
4
|
+
* Plugins get updated by things claudeup does not control: Claude Code's own
|
|
5
|
+
* `plugin install`/`update`, the `claudeup claude` prerunner, or a direct CLI
|
|
6
|
+
* call. All of them write `installed_plugins.json`; none of them tell the user
|
|
7
|
+
* anything the next time claudeup opens, so versions silently move underfoot.
|
|
8
|
+
*
|
|
9
|
+
* Rather than log what claudeup itself did — which misses every other source —
|
|
10
|
+
* this records what claudeup last *saw* installed and diffs against reality on
|
|
11
|
+
* load. Any change is reported, regardless of who made it.
|
|
12
|
+
*
|
|
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 updated.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
|
|
21
|
+
export interface VersionChange {
|
|
22
|
+
pluginId: string;
|
|
23
|
+
from: string;
|
|
24
|
+
to: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SnapshotFile {
|
|
28
|
+
/** pluginId -> installedVersion as of the last time claudeup rendered it. */
|
|
29
|
+
seen: Record<string, string>;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function snapshotPath(): string {
|
|
34
|
+
return path.join(os.homedir(), ".claude", "claudeup-version-snapshot.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function read(): Promise<SnapshotFile | null> {
|
|
38
|
+
try {
|
|
39
|
+
const raw = await fs.readFile(snapshotPath(), "utf-8");
|
|
40
|
+
const data = JSON.parse(raw) as SnapshotFile;
|
|
41
|
+
if (!data || typeof data.seen !== "object" || data.seen === null) return null;
|
|
42
|
+
return data;
|
|
43
|
+
} catch {
|
|
44
|
+
return null; // absent or corrupt — treated as "first run"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Diff the versions currently installed against the previous snapshot.
|
|
50
|
+
*
|
|
51
|
+
* Returns only genuine transitions. Deliberately excluded:
|
|
52
|
+
* - first run (no snapshot): everything would look "updated"
|
|
53
|
+
* - a plugin absent from the snapshot: newly installed, not updated
|
|
54
|
+
* - unchanged versions
|
|
55
|
+
* - "0.0.0", which means installed-with-unknown-version rather than a real one
|
|
56
|
+
*/
|
|
57
|
+
export function diffVersions(
|
|
58
|
+
previous: Record<string, string> | null,
|
|
59
|
+
current: Record<string, string>,
|
|
60
|
+
): VersionChange[] {
|
|
61
|
+
if (!previous) return [];
|
|
62
|
+
const changes: VersionChange[] = [];
|
|
63
|
+
for (const [pluginId, to] of Object.entries(current)) {
|
|
64
|
+
const from = previous[pluginId];
|
|
65
|
+
if (!from || from === to) continue;
|
|
66
|
+
if (from === "0.0.0" || to === "0.0.0") continue;
|
|
67
|
+
changes.push({ pluginId, from, to });
|
|
68
|
+
}
|
|
69
|
+
return changes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Load the previous snapshot's version map, or null on first run. */
|
|
73
|
+
export async function loadSeenVersions(): Promise<Record<string, string> | null> {
|
|
74
|
+
const data = await read();
|
|
75
|
+
return data ? data.seen : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Persist what is installed now, so the same change is not reported twice.
|
|
80
|
+
* Best-effort: a failure here must never break the UI, it only means the badge
|
|
81
|
+
* shows again next launch.
|
|
82
|
+
*/
|
|
83
|
+
export async function saveSeenVersions(
|
|
84
|
+
current: Record<string, string>,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
try {
|
|
87
|
+
const file = snapshotPath();
|
|
88
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
89
|
+
const payload: SnapshotFile = {
|
|
90
|
+
seen: current,
|
|
91
|
+
updatedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
await fs.writeFile(file, JSON.stringify(payload, null, 2), "utf-8");
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface Marketplace {
|
|
|
42
42
|
official?: boolean;
|
|
43
43
|
featured?: boolean; // Featured marketplaces have plugins fetched by default (like official)
|
|
44
44
|
deprecated?: boolean; // Deprecated marketplaces are sorted to the bottom of the list
|
|
45
|
+
owned?: boolean; // Our own marketplaces (magus*): always listed even when uninstalled, and sorted to the top
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export interface DiscoveredMarketplace {
|
|
@@ -97,12 +97,10 @@ export function buildPluginBrowserItems({
|
|
|
97
97
|
pluginsByMarketplace.set(plugin.marketplace, existing);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// Sort marketplaces:
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return aDeprecated - bDeprecated;
|
|
105
|
-
});
|
|
100
|
+
// Sort marketplaces: our own (magus*) first, deprecated last, others between.
|
|
101
|
+
const rank = (m: Marketplace) =>
|
|
102
|
+
m.owned ? 0 : m.deprecated || m.name === "claude-code-plugins" ? 2 : 1;
|
|
103
|
+
const sortedMarketplaces = [...marketplaces].sort((a, b) => rank(a) - rank(b));
|
|
106
104
|
|
|
107
105
|
const items: PluginBrowserItem[] = [];
|
|
108
106
|
|
|
@@ -3,6 +3,8 @@ import React from "react";
|
|
|
3
3
|
interface CategoryHeaderProps {
|
|
4
4
|
/** Category title */
|
|
5
5
|
title: string;
|
|
6
|
+
/** Optional version to render after the title (e.g., "8.0.0" → " v8.0.0") */
|
|
7
|
+
version?: string;
|
|
6
8
|
/** Status badge (e.g., "✓ Configured", "3 plugins") */
|
|
7
9
|
status?: string;
|
|
8
10
|
/** Status badge color */
|
|
@@ -15,12 +17,14 @@ interface CategoryHeaderProps {
|
|
|
15
17
|
|
|
16
18
|
export function CategoryHeader({
|
|
17
19
|
title,
|
|
20
|
+
version,
|
|
18
21
|
status,
|
|
19
22
|
statusColor = "green",
|
|
20
23
|
expanded = true,
|
|
21
24
|
count,
|
|
22
25
|
}: CategoryHeaderProps) {
|
|
23
26
|
const expandIcon = expanded ? "▼" : "▶";
|
|
27
|
+
const versionBadge = version ? ` v${version}` : "";
|
|
24
28
|
const countBadge = count !== undefined ? ` (${count})` : "";
|
|
25
29
|
const statusText = status ? ` ${status}` : "";
|
|
26
30
|
|
|
@@ -31,6 +35,7 @@ export function CategoryHeader({
|
|
|
31
35
|
<span fg="white">
|
|
32
36
|
<strong> {title}</strong>
|
|
33
37
|
</span>
|
|
38
|
+
<span fg="#666666">{versionBadge}</span>
|
|
34
39
|
<span fg="#666666">{countBadge}</span>
|
|
35
40
|
<span fg="#666666"> ────</span>
|
|
36
41
|
<span fg={statusColor}>{statusText}</span>
|
|
@@ -15,21 +15,27 @@ import {
|
|
|
15
15
|
} from "../components/primitives/index.js";
|
|
16
16
|
import { theme } from "../theme.js";
|
|
17
17
|
import { highlightMatches } from "../../utils/fuzzy-search.js";
|
|
18
|
+
import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
|
|
18
19
|
import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
|
|
19
20
|
|
|
20
21
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
21
22
|
|
|
22
23
|
function categoryRow(item: PluginCategoryItem, isSelected: boolean): React.ReactNode {
|
|
23
24
|
const mp = item.marketplace;
|
|
25
|
+
// Marketplaces that declare a `metadata.version` render "Name v8.0.0 (15)";
|
|
26
|
+
// those without keep the plain "Name (15)" form.
|
|
27
|
+
const version = getMarketplaceVersion(mp.name);
|
|
24
28
|
|
|
25
29
|
if (isSelected) {
|
|
26
30
|
const arrow = item.isExpanded ? "▼" : "▶";
|
|
31
|
+
const versionLabel = version ? ` v${version}` : "";
|
|
27
32
|
const count = item.pluginCount > 0 ? ` (${item.pluginCount})` : "";
|
|
28
33
|
return (
|
|
29
34
|
<SelectableRow selected={true}>
|
|
30
35
|
<strong>
|
|
31
36
|
{" "}
|
|
32
37
|
{arrow} {mp.displayName}
|
|
38
|
+
{versionLabel}
|
|
33
39
|
{count}{" "}
|
|
34
40
|
</strong>
|
|
35
41
|
</SelectableRow>
|
|
@@ -49,6 +55,7 @@ function categoryRow(item: PluginCategoryItem, isSelected: boolean): React.React
|
|
|
49
55
|
return (
|
|
50
56
|
<CategoryHeader
|
|
51
57
|
title={mp.displayName}
|
|
58
|
+
version={version}
|
|
52
59
|
expanded={item.isExpanded}
|
|
53
60
|
count={item.pluginCount}
|
|
54
61
|
status={item.badge}
|
|
@@ -125,9 +132,15 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
125
132
|
} else if (notInstalled) {
|
|
126
133
|
versionStr = " not installed";
|
|
127
134
|
} else if (hasAnyScope && plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
if (plugin.recentlyUpdatedFrom) {
|
|
136
|
+
// Show what actually happened, not just where it landed — the update may
|
|
137
|
+
// have come from Claude Code or the prerunner without ever telling us.
|
|
138
|
+
versionStr = ` v${plugin.recentlyUpdatedFrom} → v${plugin.installedVersion} updated`;
|
|
139
|
+
} else {
|
|
140
|
+
versionStr = ` v${plugin.installedVersion}`;
|
|
141
|
+
if (plugin.hasUpdate && plugin.version) {
|
|
142
|
+
versionStr += ` → v${plugin.version}`;
|
|
143
|
+
}
|
|
131
144
|
}
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -170,7 +183,13 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
170
183
|
</span>
|
|
171
184
|
<MetaText
|
|
172
185
|
text={versionStr}
|
|
173
|
-
tone={
|
|
186
|
+
tone={
|
|
187
|
+
plugin.recentlyUpdatedFrom
|
|
188
|
+
? "success"
|
|
189
|
+
: notInstalled || plugin.hasUpdate
|
|
190
|
+
? "warning"
|
|
191
|
+
: "muted"
|
|
192
|
+
}
|
|
174
193
|
/>
|
|
175
194
|
</text>
|
|
176
195
|
);
|
|
@@ -7,6 +7,11 @@ import { ScrollableList } from "../components/ScrollableList.js";
|
|
|
7
7
|
import { EmptyFilterState } from "../components/EmptyFilterState.js";
|
|
8
8
|
import { fuzzyFilter } from "../../utils/fuzzy-search.js";
|
|
9
9
|
import { getAllMarketplaces } from "../../data/marketplaces.js";
|
|
10
|
+
import {
|
|
11
|
+
diffVersions,
|
|
12
|
+
loadSeenVersions,
|
|
13
|
+
saveSeenVersions,
|
|
14
|
+
} from "../../services/version-snapshot.js";
|
|
10
15
|
import {
|
|
11
16
|
getAvailablePlugins,
|
|
12
17
|
refreshAllMarketplaces,
|
|
@@ -73,6 +78,23 @@ export function PluginsScreen() {
|
|
|
73
78
|
const localMarketplaces = await getLocalMarketplacesInfo();
|
|
74
79
|
const allMarketplaces = getAllMarketplaces(localMarketplaces);
|
|
75
80
|
const pluginData = await getAvailablePlugins(state.projectPath);
|
|
81
|
+
|
|
82
|
+
// Surface version changes made outside claudeup (Claude Code's own
|
|
83
|
+
// plugin update, the prerunner, a manual CLI call). Diffing against
|
|
84
|
+
// what we last rendered catches all of them; logging only what
|
|
85
|
+
// claudeup did would miss every one.
|
|
86
|
+
const current: Record<string, string> = {};
|
|
87
|
+
for (const p of pluginData) {
|
|
88
|
+
if (p.installedVersion) current[p.id] = p.installedVersion;
|
|
89
|
+
}
|
|
90
|
+
const previous = await loadSeenVersions();
|
|
91
|
+
for (const change of diffVersions(previous, current)) {
|
|
92
|
+
const target = pluginData.find((p) => p.id === change.pluginId);
|
|
93
|
+
if (target) target.recentlyUpdatedFrom = change.from;
|
|
94
|
+
}
|
|
95
|
+
// Record now so the same change is not reported on the next launch.
|
|
96
|
+
void saveSeenVersions(current);
|
|
97
|
+
|
|
76
98
|
dispatch({
|
|
77
99
|
type: "PLUGINS_DATA_SUCCESS",
|
|
78
100
|
marketplaces: allMarketplaces,
|