claudeup 4.23.0 → 4.25.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.23.0",
3
+ "version": "4.25.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.23.0",
68
- "claudeup-darwin-x64": "4.23.0",
69
- "claudeup-linux-x64": "4.23.0"
67
+ "claudeup-darwin-arm64": "4.25.0",
68
+ "claudeup-darwin-x64": "4.25.0",
69
+ "claudeup-linux-x64": "4.25.0"
70
70
  }
71
71
  }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isEnabledButNotInstalled } from "../services/plugin-manager.js";
3
+ import type { PluginInfo } from "../services/plugin-manager.js";
4
+
5
+ /**
6
+ * "Enabled but not installed" is a broken state that previously rendered
7
+ * identically to a healthy install: a green scope square and no version.
8
+ *
9
+ * It is also invisible to every update path, because the check is
10
+ * `installedVersion && compare(latest, installedVersion) > 0` — with no
11
+ * installed version that is false, so neither the update list nor the
12
+ * prerunner's auto-update will ever touch it.
13
+ */
14
+
15
+ const plugin = (over: Partial<PluginInfo>): PluginInfo =>
16
+ ({
17
+ id: "x@magus",
18
+ name: "x",
19
+ version: "1.0.0",
20
+ description: "",
21
+ marketplace: "magus",
22
+ marketplaceDisplay: "Magus",
23
+ enabled: true,
24
+ ...over,
25
+ }) as PluginInfo;
26
+
27
+ describe("isEnabledButNotInstalled", () => {
28
+ test("enabled in project scope with no installed version is flagged", () => {
29
+ // The real case: terminal/code-analysis are in this project's enabledPlugins
30
+ // but have no registry entry for it and none at user scope.
31
+ expect(
32
+ isEnabledButNotInstalled(
33
+ plugin({ projectScope: { enabled: true }, installedVersion: undefined }),
34
+ ),
35
+ ).toBe(true);
36
+ });
37
+
38
+ test("a healthy install is not flagged", () => {
39
+ expect(
40
+ isEnabledButNotInstalled(
41
+ plugin({ projectScope: { enabled: true }, installedVersion: "1.0.0" }),
42
+ ),
43
+ ).toBe(false);
44
+ });
45
+
46
+ test('installedVersion "0.0.0" means installed-with-unknown-version, not missing', () => {
47
+ // Anthropic's official plugins record exactly this. Treating it as missing
48
+ // would mislabel every one of them as broken.
49
+ expect(
50
+ isEnabledButNotInstalled(
51
+ plugin({ userScope: { enabled: true }, installedVersion: "0.0.0" }),
52
+ ),
53
+ ).toBe(false);
54
+ });
55
+
56
+ test("not enabled anywhere is not flagged — it is simply available", () => {
57
+ expect(isEnabledButNotInstalled(plugin({ installedVersion: undefined }))).toBe(false);
58
+ });
59
+
60
+ test("orphaned plugins are excluded — they have their own 'deprecated' state", () => {
61
+ expect(
62
+ isEnabledButNotInstalled(
63
+ plugin({
64
+ isOrphaned: true,
65
+ projectScope: { enabled: true },
66
+ installedVersion: undefined,
67
+ }),
68
+ ),
69
+ ).toBe(false);
70
+ });
71
+
72
+ test("any enabled scope counts, not just project", () => {
73
+ for (const scope of ["userScope", "projectScope", "localScope"] as const) {
74
+ expect(
75
+ isEnabledButNotInstalled(plugin({ [scope]: { enabled: true } })),
76
+ ).toBe(true);
77
+ }
78
+ });
79
+
80
+ test("a scope present but disabled does not count as enabled", () => {
81
+ expect(
82
+ isEnabledButNotInstalled(
83
+ plugin({ projectScope: { enabled: false }, installedVersion: undefined }),
84
+ ),
85
+ ).toBe(false);
86
+ });
87
+ });
@@ -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
+ });
@@ -72,6 +72,36 @@ 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;
81
+ }
82
+
83
+ /**
84
+ * True when a plugin is enabled in some scope but no installed version resolves
85
+ * anywhere — it is listed in `enabledPlugins` but was never actually installed.
86
+ *
87
+ * This is a broken state, not a healthy one: Claude Code will not load the
88
+ * plugin, and because the update check is `installedVersion && compare(...)`,
89
+ * it is also invisible to both the update list and the prerunner's auto-update.
90
+ * It previously rendered identically to a working install.
91
+ *
92
+ * `isOrphaned` is a different failure — installed, but no longer offered by any
93
+ * marketplace — and is rendered as "deprecated" already.
94
+ *
95
+ * "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
96
+ * record exactly that), so it must NOT count as missing.
97
+ */
98
+ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
99
+ if (plugin.isOrphaned) return false;
100
+ const enabledSomewhere =
101
+ !!plugin.userScope?.enabled ||
102
+ !!plugin.projectScope?.enabled ||
103
+ !!plugin.localScope?.enabled;
104
+ return enabledSomewhere && !plugin.installedVersion;
75
105
  }
76
106
 
77
107
 
@@ -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
+ }
@@ -15,6 +15,7 @@ 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 { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
18
19
 
19
20
  // ─── Category renderers ───────────────────────────────────────────────────────
20
21
 
@@ -113,14 +114,26 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
113
114
  const hasLocal = !!plugin.localScope?.enabled;
114
115
  const hasAnyScope = hasUser || hasProject || hasLocal;
115
116
 
117
+ // Enabled somewhere but nothing actually installed — a broken state that used
118
+ // to render exactly like a healthy install.
119
+ const notInstalled = isEnabledButNotInstalled(plugin);
120
+
116
121
  // Build version string — only show if plugin is installed in at least one scope
117
122
  let versionStr = "";
118
123
  if (plugin.isOrphaned) {
119
124
  versionStr = " deprecated";
125
+ } else if (notInstalled) {
126
+ versionStr = " not installed";
120
127
  } else if (hasAnyScope && plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
121
- versionStr = ` v${plugin.installedVersion}`;
122
- if (plugin.hasUpdate && plugin.version) {
123
- versionStr += ` v${plugin.version}`;
128
+ if (plugin.recentlyUpdatedFrom) {
129
+ // Show what actually happened, not just where it landed — the update may
130
+ // have come from Claude Code or the prerunner without ever telling us.
131
+ versionStr = ` v${plugin.recentlyUpdatedFrom} → v${plugin.installedVersion} updated`;
132
+ } else {
133
+ versionStr = ` v${plugin.installedVersion}`;
134
+ if (plugin.hasUpdate && plugin.version) {
135
+ versionStr += ` → v${plugin.version}`;
136
+ }
124
137
  }
125
138
  }
126
139
 
@@ -158,8 +171,19 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
158
171
  <span> </span>
159
172
  <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
160
173
  <span> </span>
161
- <span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>{displayName}</span>
162
- <MetaText text={versionStr} tone={plugin.hasUpdate ? "warning" : "muted"} />
174
+ <span fg={hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted}>
175
+ {displayName}
176
+ </span>
177
+ <MetaText
178
+ text={versionStr}
179
+ tone={
180
+ plugin.recentlyUpdatedFrom
181
+ ? "success"
182
+ : notInstalled || plugin.hasUpdate
183
+ ? "warning"
184
+ : "muted"
185
+ }
186
+ />
163
187
  </text>
164
188
  );
165
189
  }
@@ -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,