claudeup 4.27.1 → 4.29.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.29.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.29.0",
68
+ "claudeup-darwin-x64": "4.29.0",
69
+ "claudeup-linux-x64": "4.29.0"
70
70
  }
71
71
  }
@@ -33,13 +33,41 @@ function badgeFor(marketplace: Marketplace): string | undefined {
33
33
  return category && "badge" in category ? category.badge : undefined;
34
34
  }
35
35
 
36
+ /** Tone for a marketplace when it has plugins and is expanded. */
37
+ function toneFor(marketplace: Marketplace): string | undefined {
38
+ const items = buildPluginBrowserItems({
39
+ marketplaces: [marketplace],
40
+ plugins: [
41
+ { id: `demo@${marketplace.name}`, name: "demo", marketplace: marketplace.name } as never,
42
+ ],
43
+ collapsedMarketplaces: new Set(),
44
+ });
45
+ const category = items.find((i) => i.kind === "category");
46
+ return category && "tone" in category ? category.tone : undefined;
47
+ }
48
+
36
49
  describe("marketplace category badge", () => {
50
+ test("the byline uses a low-key tone, not an attention-grabbing one", () => {
51
+ // It is a signature, not a status. Green reads as "action succeeded" and
52
+ // competes with the plugin rows for attention.
53
+ expect(
54
+ toneFor(mp({ name: "magus", source: { source: "github", repo: "MadAppGang/magus" } })),
55
+ ).toBe("byline");
56
+
57
+ // A third-party marketplace still uses the green "Added" status tone.
58
+ expect(
59
+ toneFor(
60
+ mp({ name: "sp", source: { source: "github", repo: "obra/superpowers-marketplace" } }),
61
+ ),
62
+ ).toBe("green");
63
+ });
64
+
37
65
  test("MadAppGang marketplaces carry the byline instead of a generic label", () => {
38
66
  for (const name of ["magus", "magus-marketing", "magus-alpha"]) {
39
67
  const badge = badgeFor(
40
68
  mp({ name, source: { source: "github", repo: `MadAppGang/${name}` } }),
41
69
  );
42
- expect(badge).toBe("made with ❤️ by MadAppGang");
70
+ expect(badge).toBe("by MadAppGang");
43
71
  }
44
72
  });
45
73
 
@@ -48,7 +76,7 @@ describe("marketplace category badge", () => {
48
76
  const badge = badgeFor(
49
77
  mp({ name: "magus", source: { source: "github", repo: "madappgang/magus" } }),
50
78
  );
51
- expect(badge).toBe("made with ❤️ by MadAppGang");
79
+ expect(badge).toBe("by MadAppGang");
52
80
  });
53
81
 
54
82
  test("someone else's marketplace does NOT claim the byline", () => {
@@ -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,7 +18,7 @@ export interface PluginCategoryItem {
18
18
  isExpanded: boolean;
19
19
  isCommunitySection?: boolean;
20
20
  /** Visual tone for the category row */
21
- tone: "yellow" | "gray" | "green" | "red" | "purple" | "teal";
21
+ tone: "yellow" | "gray" | "green" | "red" | "purple" | "teal" | "byline";
22
22
  /** Badge text shown on category row (e.g. "★ Official") */
23
23
  badge?: string;
24
24
  }
@@ -66,7 +66,7 @@ function categoryStyling(
66
66
  return { tone: "yellow", badge: "★ Official" };
67
67
  }
68
68
  if (isMadAppGang(mp)) {
69
- return { tone: "green", badge: "made with ❤️ by MadAppGang" };
69
+ return { tone: "byline", badge: "by MadAppGang" };
70
70
  }
71
71
  return { tone: "green", badge: "✓ Added" };
72
72
  }
@@ -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
 
@@ -51,6 +52,7 @@ function categoryRow(item: PluginCategoryItem, isSelected: boolean): React.React
51
52
  red: "red",
52
53
  purple: theme.colors.accent,
53
54
  teal: "cyan",
55
+ byline: theme.colors.byline,
54
56
  };
55
57
 
56
58
  return (
@@ -358,6 +360,9 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
358
360
  </box>
359
361
  </DetailSection>
360
362
 
363
+ {/* Recent releases */}
364
+ <ReleaseHistory releases={plugin.releases} installedVersion={plugin.installedVersion} />
365
+
361
366
  {/* Update action */}
362
367
  {isInstalled && plugin.hasUpdate ? (
363
368
  <ActionHints
@@ -368,6 +373,73 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
368
373
  );
369
374
  }
370
375
 
376
+ /**
377
+ * Recent release history, newest first.
378
+ *
379
+ * Releases the user does not have yet are marked and coloured; the installed one is
380
+ * flagged; older ones are dimmed. The list is already ordered newest-first, so the
381
+ * installed entry's index is all that's needed to tell those three groups apart —
382
+ * no version comparison, and no way for it to disagree with the published order.
383
+ * When the installed version isn't in the window (unknown "0.0.0", or older than the
384
+ * releases published), everything renders neutrally rather than guessing.
385
+ */
386
+ function ReleaseHistory({
387
+ releases,
388
+ installedVersion,
389
+ }: {
390
+ releases?: PluginRelease[];
391
+ installedVersion?: string;
392
+ }): React.ReactNode {
393
+ if (!releases?.length) return null;
394
+
395
+ const installedAt = installedVersion
396
+ ? releases.findIndex((r) => r.version === installedVersion)
397
+ : -1;
398
+
399
+ return (
400
+ <DetailSection>
401
+ <text>{"─".repeat(24)}</text>
402
+ <text>
403
+ <strong>Recent releases:</strong>
404
+ </text>
405
+ <box marginTop={1} flexDirection="column">
406
+ {releases.map((release, i) => {
407
+ const isNewer = installedAt > 0 && i < installedAt;
408
+ const isInstalled = installedAt >= 0 && i === installedAt;
409
+ const versionColor = isNewer
410
+ ? theme.colors.warning
411
+ : isInstalled
412
+ ? theme.colors.success
413
+ : theme.colors.link;
414
+
415
+ return (
416
+ <box key={release.version} flexDirection="column" marginBottom={1}>
417
+ <text>
418
+ <span fg={versionColor}>
419
+ {isNewer ? "▲ " : isInstalled ? "● " : " "}v{release.version}
420
+ </span>
421
+ {release.date ? (
422
+ <span fg={theme.colors.muted}> {release.date}</span>
423
+ ) : null}
424
+ {release.kinds?.length ? (
425
+ <span fg={theme.colors.dim}> {release.kinds.join(" · ")}</span>
426
+ ) : null}
427
+ {isInstalled ? (
428
+ <span fg={theme.colors.success}> installed</span>
429
+ ) : null}
430
+ </text>
431
+ <text fg={isNewer ? theme.colors.text : theme.colors.muted}>
432
+ {" "}
433
+ {release.summary}
434
+ </text>
435
+ </box>
436
+ );
437
+ })}
438
+ </box>
439
+ </DetailSection>
440
+ );
441
+ }
442
+
371
443
  // ─── Public dispatch functions ────────────────────────────────────────────────
372
444
 
373
445
  /**
package/src/ui/theme.ts CHANGED
@@ -6,6 +6,8 @@ export const theme = {
6
6
  border: "#444444",
7
7
  link: "#5c9aff",
8
8
  accent: "#7e57c2",
9
+ /** Low-key brand tint for bylines — readable, deliberately not attention-grabbing. */
10
+ byline: "#8a7fa8",
9
11
  success: "green",
10
12
  warning: "yellow",
11
13
  danger: "red",