claudeup 4.27.1 → 4.28.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__/plugin-releases.test.ts +98 -0
- package/src/services/local-marketplace.ts +5 -0
- package/src/services/marketplace-fetcher.ts +6 -0
- package/src/services/plugin-manager.ts +7 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/types/index.ts +17 -0
- package/src/ui/renderers/pluginRenderers.tsx +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.28.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.28.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.28.0",
|
|
69
|
+
"claudeup-linux-x64": "4.28.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MAX_RELEASES,
|
|
4
|
+
normalizeReleases,
|
|
5
|
+
} from "../services/plugin-releases.js";
|
|
6
|
+
import type { PluginRelease } from "../types/index.js";
|
|
7
|
+
|
|
8
|
+
/** Narrow to a populated result, failing the test rather than the assertion's type. */
|
|
9
|
+
function expectEntries(input: unknown): PluginRelease[] {
|
|
10
|
+
const out = normalizeReleases(input);
|
|
11
|
+
if (!out) throw new Error("expected releases, got undefined");
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("normalizeReleases", () => {
|
|
16
|
+
test("passes through a well-formed entry", () => {
|
|
17
|
+
expect(
|
|
18
|
+
normalizeReleases([
|
|
19
|
+
{
|
|
20
|
+
version: "3.3.0",
|
|
21
|
+
date: "2026-07-29",
|
|
22
|
+
kinds: ["Changed", "Fixed"],
|
|
23
|
+
summary: "Model resolution is now live",
|
|
24
|
+
},
|
|
25
|
+
]),
|
|
26
|
+
).toEqual([
|
|
27
|
+
{
|
|
28
|
+
version: "3.3.0",
|
|
29
|
+
date: "2026-07-29",
|
|
30
|
+
kinds: ["Changed", "Fixed"],
|
|
31
|
+
summary: "Model resolution is now live",
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("omits optional fields rather than emitting undefined", () => {
|
|
37
|
+
const [entry] = expectEntries([
|
|
38
|
+
{ version: "1.0.0", summary: "First release" },
|
|
39
|
+
]);
|
|
40
|
+
expect(entry).toEqual({ version: "1.0.0", summary: "First release" });
|
|
41
|
+
expect("date" in entry).toBe(false);
|
|
42
|
+
expect("kinds" in entry).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("returns undefined for anything that is not an array", () => {
|
|
46
|
+
for (const input of [undefined, null, "3.3.0", 7, {}, true]) {
|
|
47
|
+
expect(normalizeReleases(input)).toBeUndefined();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The manifest is fetched over the network, so no field's type can be assumed.
|
|
52
|
+
test("drops entries missing or mistyping the required fields", () => {
|
|
53
|
+
expect(
|
|
54
|
+
normalizeReleases([
|
|
55
|
+
{ version: "1.0.0", summary: "kept" },
|
|
56
|
+
{ version: "1.0.1" }, // no summary
|
|
57
|
+
{ summary: "no version" },
|
|
58
|
+
{ version: 2, summary: "version is a number" },
|
|
59
|
+
{ version: "1.0.2", summary: 42 },
|
|
60
|
+
{ version: " ", summary: "blank version" },
|
|
61
|
+
{ version: "1.0.3", summary: " " },
|
|
62
|
+
null,
|
|
63
|
+
"not an object",
|
|
64
|
+
]),
|
|
65
|
+
).toEqual([{ version: "1.0.0", summary: "kept" }]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns undefined when every entry is unusable", () => {
|
|
69
|
+
expect(normalizeReleases([{ nope: true }, null])).toBeUndefined();
|
|
70
|
+
expect(normalizeReleases([])).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("caps the list so one manifest cannot flood the panel", () => {
|
|
74
|
+
const many = Array.from({ length: 500 }, (_, i) => ({
|
|
75
|
+
version: `1.0.${i}`,
|
|
76
|
+
summary: `entry ${i}`,
|
|
77
|
+
}));
|
|
78
|
+
const out = expectEntries(many);
|
|
79
|
+
expect(out).toHaveLength(MAX_RELEASES);
|
|
80
|
+
// Newest-first ordering is preserved — the cap takes the head, not the tail.
|
|
81
|
+
expect(out[0].version).toBe("1.0.0");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("keeps only string members of kinds", () => {
|
|
85
|
+
const [entry] = expectEntries([
|
|
86
|
+
{ version: "1.0.0", summary: "x", kinds: ["Added", 3, null, "Fixed"] },
|
|
87
|
+
]);
|
|
88
|
+
expect(entry.kinds).toEqual(["Added", "Fixed"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("trims surrounding whitespace", () => {
|
|
92
|
+
expect(
|
|
93
|
+
normalizeReleases([
|
|
94
|
+
{ version: " 1.0.0 ", date: " 2026-07-29 ", summary: " padded " },
|
|
95
|
+
]),
|
|
96
|
+
).toEqual([{ version: "1.0.0", date: "2026-07-29", summary: "padded" }]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -2,6 +2,8 @@ import fs from "fs-extra";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { execSync } from "node:child_process";
|
|
5
|
+
import type { PluginRelease } from "../types/index.js";
|
|
6
|
+
import { normalizeReleases } from "./plugin-releases.js";
|
|
5
7
|
|
|
6
8
|
export interface LocalMarketplacePlugin {
|
|
7
9
|
name: string;
|
|
@@ -10,6 +12,8 @@ export interface LocalMarketplacePlugin {
|
|
|
10
12
|
source?: string;
|
|
11
13
|
category?: string;
|
|
12
14
|
author?: { name: string; email?: string };
|
|
15
|
+
/** Recent release history, as published in the marketplace manifest. */
|
|
16
|
+
releases?: PluginRelease[];
|
|
13
17
|
// Extended info from plugin.json or marketplace.json
|
|
14
18
|
strict?: boolean;
|
|
15
19
|
lspServers?: Record<string, unknown>;
|
|
@@ -185,6 +189,7 @@ async function scanSingleMarketplace(
|
|
|
185
189
|
source: plugin.source,
|
|
186
190
|
category: plugin.category,
|
|
187
191
|
author: plugin.author,
|
|
192
|
+
releases: normalizeReleases(plugin.releases),
|
|
188
193
|
strict: plugin.strict,
|
|
189
194
|
lspServers: plugin.lspServers,
|
|
190
195
|
agents,
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* imported via plugin-manager.ts).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { PluginRelease } from "../types/index.js";
|
|
10
11
|
import { isValidGitHubRepo } from "../utils/string-utils.js";
|
|
11
12
|
import type { LocalMarketplace } from "./local-marketplace.js";
|
|
13
|
+
import { normalizeReleases } from "./plugin-releases.js";
|
|
12
14
|
|
|
13
15
|
export interface MarketplacePlugin {
|
|
14
16
|
name: string;
|
|
@@ -18,6 +20,7 @@ export interface MarketplacePlugin {
|
|
|
18
20
|
author?: { name: string; email?: string };
|
|
19
21
|
homepage?: string;
|
|
20
22
|
tags?: string[];
|
|
23
|
+
releases?: PluginRelease[];
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
|
|
@@ -92,6 +95,7 @@ export async function fetchMarketplacePlugins(
|
|
|
92
95
|
author?: { name: string; email?: string };
|
|
93
96
|
homepage?: string;
|
|
94
97
|
tags?: string[];
|
|
98
|
+
releases?: unknown;
|
|
95
99
|
}
|
|
96
100
|
const data = (await response.json()) as {
|
|
97
101
|
plugins?: RawPlugin[];
|
|
@@ -116,6 +120,7 @@ export async function fetchMarketplacePlugins(
|
|
|
116
120
|
author: plugin.author,
|
|
117
121
|
homepage: plugin.homepage,
|
|
118
122
|
tags: plugin.tags,
|
|
123
|
+
releases: normalizeReleases(plugin.releases),
|
|
119
124
|
});
|
|
120
125
|
}
|
|
121
126
|
}
|
|
@@ -161,5 +166,6 @@ export async function resolveMarketplacePlugins(
|
|
|
161
166
|
description: p.description,
|
|
162
167
|
category: p.category,
|
|
163
168
|
author: p.author,
|
|
169
|
+
releases: p.releases,
|
|
164
170
|
}));
|
|
165
171
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
removeFromInstalledPluginsRegistry,
|
|
17
17
|
} from "./claude-settings.js";
|
|
18
18
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
19
|
+
import type { PluginRelease } from "../types/index.js";
|
|
19
20
|
import {
|
|
20
21
|
scanLocalMarketplaces,
|
|
21
22
|
repairAllMarketplaces,
|
|
@@ -66,6 +67,8 @@ export interface PluginInfo {
|
|
|
66
67
|
author?: { name: string; email?: string };
|
|
67
68
|
homepage?: string;
|
|
68
69
|
tags?: string[];
|
|
70
|
+
/** Recent release history, newest first. Absent when the manifest publishes none. */
|
|
71
|
+
releases?: PluginRelease[];
|
|
69
72
|
agents?: string[];
|
|
70
73
|
commands?: string[];
|
|
71
74
|
skills?: string[];
|
|
@@ -215,6 +218,7 @@ export async function getAvailablePlugins(
|
|
|
215
218
|
author: plugin.author,
|
|
216
219
|
homepage: plugin.homepage,
|
|
217
220
|
tags: plugin.tags,
|
|
221
|
+
releases: plugin.releases,
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
224
|
}
|
|
@@ -253,6 +257,7 @@ export async function getAvailablePlugins(
|
|
|
253
257
|
skills: localPlugin.skills,
|
|
254
258
|
mcpServers: localPlugin.mcpServers,
|
|
255
259
|
lspServers: localPlugin.lspServers,
|
|
260
|
+
releases: localPlugin.releases,
|
|
256
261
|
});
|
|
257
262
|
}
|
|
258
263
|
}
|
|
@@ -405,6 +410,7 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
405
410
|
author: plugin.author,
|
|
406
411
|
homepage: plugin.homepage,
|
|
407
412
|
tags: plugin.tags,
|
|
413
|
+
releases: plugin.releases,
|
|
408
414
|
});
|
|
409
415
|
}
|
|
410
416
|
}
|
|
@@ -443,6 +449,7 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
443
449
|
skills: localPlugin.skills,
|
|
444
450
|
mcpServers: localPlugin.mcpServers,
|
|
445
451
|
lspServers: localPlugin.lspServers,
|
|
452
|
+
releases: localPlugin.releases,
|
|
446
453
|
});
|
|
447
454
|
}
|
|
448
455
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalisation for the `releases` array published in marketplace manifests.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from both readers because both need it: `marketplace-fetcher.ts`
|
|
5
|
+
* (manifest over the network) and `local-marketplace.ts` (same manifest from the
|
|
6
|
+
* on-disk cache). Putting it in either would make one import the other, and
|
|
7
|
+
* marketplace-fetcher was split out precisely to avoid importing local-marketplace,
|
|
8
|
+
* whose module-level `os.homedir()` constants contaminate the test suite.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PluginRelease } from "../types/index.js";
|
|
12
|
+
|
|
13
|
+
/** Most release entries the panel will show, however many the manifest declares. */
|
|
14
|
+
export const MAX_RELEASES = 5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Coerce a manifest `releases` value into renderable shape, or undefined.
|
|
18
|
+
*
|
|
19
|
+
* The remote manifest is fetched over the network from a repo whose contents we do
|
|
20
|
+
* not control at read time, so nothing about its shape may be assumed — a number
|
|
21
|
+
* where a string belongs, or a ten-thousand-entry array, would otherwise reach the
|
|
22
|
+
* renderer. Entries missing a version or summary are dropped rather than rendered
|
|
23
|
+
* blank, and the array is capped so one manifest cannot flood the detail panel.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeReleases(input: unknown): PluginRelease[] | undefined {
|
|
26
|
+
if (!Array.isArray(input)) return undefined;
|
|
27
|
+
|
|
28
|
+
const out: PluginRelease[] = [];
|
|
29
|
+
for (const raw of input.slice(0, MAX_RELEASES)) {
|
|
30
|
+
if (!raw || typeof raw !== "object") continue;
|
|
31
|
+
const r = raw as Record<string, unknown>;
|
|
32
|
+
if (typeof r.version !== "string" || typeof r.summary !== "string")
|
|
33
|
+
continue;
|
|
34
|
+
if (!r.version.trim() || !r.summary.trim()) continue;
|
|
35
|
+
out.push({
|
|
36
|
+
version: r.version.trim(),
|
|
37
|
+
...(typeof r.date === "string" && r.date.trim()
|
|
38
|
+
? { date: r.date.trim() }
|
|
39
|
+
: {}),
|
|
40
|
+
...(Array.isArray(r.kinds)
|
|
41
|
+
? { kinds: r.kinds.filter((k): k is string => typeof k === "string") }
|
|
42
|
+
: {}),
|
|
43
|
+
summary: r.summary.trim(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return out.length > 0 ? out : undefined;
|
|
47
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -51,6 +51,23 @@ export interface DiscoveredMarketplace {
|
|
|
51
51
|
config?: MarketplaceSource;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* One entry of a plugin's recent release history, as published in marketplace.json.
|
|
56
|
+
*
|
|
57
|
+
* Kept separate from `description` on purpose: `description` says what a plugin *is*
|
|
58
|
+
* and changes rarely, while this says what *changed* and turns over every release.
|
|
59
|
+
* Folding the second into the first is what left plugin panels showing a changelog
|
|
60
|
+
* line where the plugin's purpose should be.
|
|
61
|
+
*/
|
|
62
|
+
export interface PluginRelease {
|
|
63
|
+
version: string;
|
|
64
|
+
/** ISO date (YYYY-MM-DD). Absent on entries whose heading carried no date. */
|
|
65
|
+
date?: string;
|
|
66
|
+
/** Change categories in the entry — "Added", "Changed", "Fixed", … */
|
|
67
|
+
kinds?: string[];
|
|
68
|
+
summary: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
export interface Plugin {
|
|
55
72
|
name: string;
|
|
56
73
|
version: string;
|
|
@@ -18,6 +18,7 @@ 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
20
|
import { isKnownVersion } from "../../services/version-snapshot.js";
|
|
21
|
+
import type { PluginRelease } from "../../types/index.js";
|
|
21
22
|
|
|
22
23
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -358,6 +359,9 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
358
359
|
</box>
|
|
359
360
|
</DetailSection>
|
|
360
361
|
|
|
362
|
+
{/* Recent releases */}
|
|
363
|
+
<ReleaseHistory releases={plugin.releases} installedVersion={plugin.installedVersion} />
|
|
364
|
+
|
|
361
365
|
{/* Update action */}
|
|
362
366
|
{isInstalled && plugin.hasUpdate ? (
|
|
363
367
|
<ActionHints
|
|
@@ -368,6 +372,73 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
368
372
|
);
|
|
369
373
|
}
|
|
370
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Recent release history, newest first.
|
|
377
|
+
*
|
|
378
|
+
* Releases the user does not have yet are marked and coloured; the installed one is
|
|
379
|
+
* flagged; older ones are dimmed. The list is already ordered newest-first, so the
|
|
380
|
+
* installed entry's index is all that's needed to tell those three groups apart —
|
|
381
|
+
* no version comparison, and no way for it to disagree with the published order.
|
|
382
|
+
* When the installed version isn't in the window (unknown "0.0.0", or older than the
|
|
383
|
+
* releases published), everything renders neutrally rather than guessing.
|
|
384
|
+
*/
|
|
385
|
+
function ReleaseHistory({
|
|
386
|
+
releases,
|
|
387
|
+
installedVersion,
|
|
388
|
+
}: {
|
|
389
|
+
releases?: PluginRelease[];
|
|
390
|
+
installedVersion?: string;
|
|
391
|
+
}): React.ReactNode {
|
|
392
|
+
if (!releases?.length) return null;
|
|
393
|
+
|
|
394
|
+
const installedAt = installedVersion
|
|
395
|
+
? releases.findIndex((r) => r.version === installedVersion)
|
|
396
|
+
: -1;
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<DetailSection>
|
|
400
|
+
<text>{"─".repeat(24)}</text>
|
|
401
|
+
<text>
|
|
402
|
+
<strong>Recent releases:</strong>
|
|
403
|
+
</text>
|
|
404
|
+
<box marginTop={1} flexDirection="column">
|
|
405
|
+
{releases.map((release, i) => {
|
|
406
|
+
const isNewer = installedAt > 0 && i < installedAt;
|
|
407
|
+
const isInstalled = installedAt >= 0 && i === installedAt;
|
|
408
|
+
const versionColor = isNewer
|
|
409
|
+
? theme.colors.warning
|
|
410
|
+
: isInstalled
|
|
411
|
+
? theme.colors.success
|
|
412
|
+
: theme.colors.link;
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<box key={release.version} flexDirection="column" marginBottom={1}>
|
|
416
|
+
<text>
|
|
417
|
+
<span fg={versionColor}>
|
|
418
|
+
{isNewer ? "▲ " : isInstalled ? "● " : " "}v{release.version}
|
|
419
|
+
</span>
|
|
420
|
+
{release.date ? (
|
|
421
|
+
<span fg={theme.colors.muted}> {release.date}</span>
|
|
422
|
+
) : null}
|
|
423
|
+
{release.kinds?.length ? (
|
|
424
|
+
<span fg={theme.colors.dim}> {release.kinds.join(" · ")}</span>
|
|
425
|
+
) : null}
|
|
426
|
+
{isInstalled ? (
|
|
427
|
+
<span fg={theme.colors.success}> installed</span>
|
|
428
|
+
) : null}
|
|
429
|
+
</text>
|
|
430
|
+
<text fg={isNewer ? theme.colors.text : theme.colors.muted}>
|
|
431
|
+
{" "}
|
|
432
|
+
{release.summary}
|
|
433
|
+
</text>
|
|
434
|
+
</box>
|
|
435
|
+
);
|
|
436
|
+
})}
|
|
437
|
+
</box>
|
|
438
|
+
</DetailSection>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
371
442
|
// ─── Public dispatch functions ────────────────────────────────────────────────
|
|
372
443
|
|
|
373
444
|
/**
|