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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.27.1",
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.27.1",
68
- "claudeup-darwin-x64": "4.27.1",
69
- "claudeup-linux-x64": "4.27.1"
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
+ }
@@ -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
  /**