claudeup 4.25.0 → 4.27.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 +79 -29
- 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 +17 -2
- package/src/services/version-snapshot.ts +63 -20
- 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 +27 -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.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.27.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.27.0",
|
|
69
|
+
"claudeup-linux-x64": "4.27.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
|
+
});
|
|
@@ -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
|
});
|
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({
|
|
@@ -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
|
/**
|
|
@@ -154,7 +159,12 @@ export async function getAvailablePlugins(
|
|
|
154
159
|
// Always include official and featured marketplaces so users can browse them
|
|
155
160
|
const marketplaceNames = new Set<string>();
|
|
156
161
|
for (const mp of defaultMarketplaces) {
|
|
157
|
-
if (
|
|
162
|
+
if (
|
|
163
|
+
configuredMarketplaces[mp.name] ||
|
|
164
|
+
mp.official ||
|
|
165
|
+
mp.featured ||
|
|
166
|
+
mp.owned
|
|
167
|
+
) {
|
|
158
168
|
marketplaceNames.add(mp.name);
|
|
159
169
|
}
|
|
160
170
|
}
|
|
@@ -339,7 +349,12 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
339
349
|
// Always include official and featured marketplaces so users can browse them
|
|
340
350
|
const marketplaceNames = new Set<string>();
|
|
341
351
|
for (const mp of defaultMarketplaces) {
|
|
342
|
-
if (
|
|
352
|
+
if (
|
|
353
|
+
configuredMarketplaces[mp.name] ||
|
|
354
|
+
mp.official ||
|
|
355
|
+
mp.featured ||
|
|
356
|
+
mp.owned
|
|
357
|
+
) {
|
|
343
358
|
marketplaceNames.add(mp.name);
|
|
344
359
|
}
|
|
345
360
|
}
|
|
@@ -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/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,28 @@ 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";
|
|
20
|
+
import { isKnownVersion } from "../../services/version-snapshot.js";
|
|
19
21
|
|
|
20
22
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
21
23
|
|
|
22
24
|
function categoryRow(item: PluginCategoryItem, isSelected: boolean): React.ReactNode {
|
|
23
25
|
const mp = item.marketplace;
|
|
26
|
+
// Marketplaces that declare a `metadata.version` render "Name v8.0.0 (15)";
|
|
27
|
+
// those without keep the plain "Name (15)" form.
|
|
28
|
+
const version = getMarketplaceVersion(mp.name);
|
|
24
29
|
|
|
25
30
|
if (isSelected) {
|
|
26
31
|
const arrow = item.isExpanded ? "▼" : "▶";
|
|
32
|
+
const versionLabel = version ? ` v${version}` : "";
|
|
27
33
|
const count = item.pluginCount > 0 ? ` (${item.pluginCount})` : "";
|
|
28
34
|
return (
|
|
29
35
|
<SelectableRow selected={true}>
|
|
30
36
|
<strong>
|
|
31
37
|
{" "}
|
|
32
38
|
{arrow} {mp.displayName}
|
|
39
|
+
{versionLabel}
|
|
33
40
|
{count}{" "}
|
|
34
41
|
</strong>
|
|
35
42
|
</SelectableRow>
|
|
@@ -49,6 +56,7 @@ function categoryRow(item: PluginCategoryItem, isSelected: boolean): React.React
|
|
|
49
56
|
return (
|
|
50
57
|
<CategoryHeader
|
|
51
58
|
title={mp.displayName}
|
|
59
|
+
version={version}
|
|
52
60
|
expanded={item.isExpanded}
|
|
53
61
|
count={item.pluginCount}
|
|
54
62
|
status={item.badge}
|
|
@@ -118,19 +126,28 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
118
126
|
// to render exactly like a healthy install.
|
|
119
127
|
const notInstalled = isEnabledButNotInstalled(plugin);
|
|
120
128
|
|
|
121
|
-
//
|
|
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
|
+
|
|
122
135
|
let versionStr = "";
|
|
123
136
|
if (plugin.isOrphaned) {
|
|
124
137
|
versionStr = " deprecated";
|
|
125
138
|
} else if (notInstalled) {
|
|
126
139
|
versionStr = " not installed";
|
|
127
|
-
} else if (hasAnyScope
|
|
128
|
-
if (plugin.recentlyUpdatedFrom) {
|
|
140
|
+
} else if (hasAnyScope) {
|
|
141
|
+
if (plugin.recentlyUpdatedFrom && shownVersion) {
|
|
129
142
|
// Show what actually happened, not just where it landed — the update may
|
|
130
143
|
// have come from Claude Code or the prerunner without ever telling us.
|
|
131
|
-
versionStr = ` v${plugin.recentlyUpdatedFrom} → v${
|
|
132
|
-
} else {
|
|
133
|
-
|
|
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}`;
|
|
134
151
|
if (plugin.hasUpdate && plugin.version) {
|
|
135
152
|
versionStr += ` → v${plugin.version}`;
|
|
136
153
|
}
|
|
@@ -153,9 +170,7 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
153
170
|
|
|
154
171
|
if (plugin.isOrphaned) {
|
|
155
172
|
const ver =
|
|
156
|
-
plugin.installedVersion
|
|
157
|
-
? ` v${plugin.installedVersion}`
|
|
158
|
-
: "";
|
|
173
|
+
isKnownVersion(plugin.installedVersion) ? ` v${plugin.installedVersion}` : "";
|
|
159
174
|
return (
|
|
160
175
|
<text>
|
|
161
176
|
<span fg={theme.colors.danger}> ■■■ </span>
|
|
@@ -177,7 +192,7 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
177
192
|
<MetaText
|
|
178
193
|
text={versionStr}
|
|
179
194
|
tone={
|
|
180
|
-
plugin.recentlyUpdatedFrom
|
|
195
|
+
plugin.recentlyUpdatedFrom || plugin.recentlyInstalled
|
|
181
196
|
? "success"
|
|
182
197
|
: notInstalled || plugin.hasUpdate
|
|
183
198
|
? "warning"
|
|
@@ -232,9 +247,9 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
232
247
|
components.push(`${Object.keys(plugin.lspServers).length} LSP`);
|
|
233
248
|
}
|
|
234
249
|
|
|
235
|
-
const showVersion = plugin.version
|
|
250
|
+
const showVersion = isKnownVersion(plugin.version ?? undefined);
|
|
236
251
|
const showInstalledVersion =
|
|
237
|
-
isInstalled && plugin.installedVersion
|
|
252
|
+
isInstalled && isKnownVersion(plugin.installedVersion);
|
|
238
253
|
|
|
239
254
|
return (
|
|
240
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);
|