claudeup 4.32.0 → 4.32.1

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.32.0",
3
+ "version": "4.32.1",
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.32.0",
68
- "claudeup-darwin-x64": "4.32.0",
69
- "claudeup-linux-x64": "4.32.0"
67
+ "claudeup-darwin-arm64": "4.32.1",
68
+ "claudeup-darwin-x64": "4.32.1",
69
+ "claudeup-linux-x64": "4.32.1"
70
70
  }
71
71
  }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isInstalledInScope,
4
+ resolveScopeAction,
5
+ } from "../services/plugin-manager.js";
6
+
7
+ /**
8
+ * The scope keys (u / p / l) and the Enter scope picker both decide between
9
+ * install, update and uninstall. That decision used to read `scope.enabled` —
10
+ * the `enabledPlugins` flag from a settings file — and ignore `scope.version`,
11
+ * which is the registry-backed answer to "did Claude Code actually install
12
+ * this".
13
+ *
14
+ * Those two disagree in a real, common state: a plugin listed in
15
+ * `enabledPlugins` with no entry in `installed_plugins.json` for the current
16
+ * project path. The list row renders it "not installed" (isEnabledButNotInstalled),
17
+ * and pressing its scope key ran `claude plugin uninstall`. The user asked to
18
+ * install the thing the UI told them was missing, and it was removed instead.
19
+ */
20
+
21
+ describe("isInstalledInScope", () => {
22
+ test("enabled with a registry version is installed", () => {
23
+ expect(isInstalledInScope({ enabled: true, version: "3.0.2" })).toBe(true);
24
+ });
25
+
26
+ test("enabled with NO version is not installed — the broken state", () => {
27
+ // dev@magus, measured: `.claude/settings.json` had "dev@magus": true while
28
+ // installed_plugins.json had no entry resolving for the project path.
29
+ expect(isInstalledInScope({ enabled: true })).toBe(false);
30
+ });
31
+
32
+ test("a version with the flag off is not installed either", () => {
33
+ expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(false);
34
+ });
35
+
36
+ test("an absent scope is not installed", () => {
37
+ expect(isInstalledInScope(undefined)).toBe(false);
38
+ });
39
+
40
+ test('"0.0.0" counts as installed — Anthropic official plugins record it', () => {
41
+ expect(isInstalledInScope({ enabled: true, version: "0.0.0" })).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe("resolveScopeAction", () => {
46
+ test("REGRESSION: enabled but not installed resolves to install, never uninstall", () => {
47
+ // This is the reported bug. Before the fix this returned "uninstall".
48
+ expect(resolveScopeAction({ enabled: true }, "3.0.2")).toBe("install");
49
+ });
50
+
51
+ test("a scope that was never enabled resolves to install", () => {
52
+ expect(resolveScopeAction(undefined, "3.0.2")).toBe("install");
53
+ expect(resolveScopeAction({ enabled: false }, "3.0.2")).toBe("install");
54
+ });
55
+
56
+ test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
57
+ expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, "3.0.2")).toBe(
58
+ "uninstall",
59
+ );
60
+ });
61
+
62
+ test("a healthy install behind the catalog resolves to update", () => {
63
+ expect(resolveScopeAction({ enabled: true, version: "2.9.0" }, "3.0.2")).toBe(
64
+ "update",
65
+ );
66
+ });
67
+
68
+ test('an unknown catalog version ("0.0.0") never fabricates an update', () => {
69
+ // There is nothing to update *to*, so toggling must still mean uninstall.
70
+ expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, "0.0.0")).toBe(
71
+ "uninstall",
72
+ );
73
+ });
74
+
75
+ test("installed with unknown version resolves to update when a real version is published", () => {
76
+ // "0.0.0" installed against a real catalog version is a genuine upgrade
77
+ // path, not the broken state — it must not be treated as uninstall.
78
+ expect(resolveScopeAction({ enabled: true, version: "0.0.0" }, "3.0.2")).toBe(
79
+ "update",
80
+ );
81
+ });
82
+ });
@@ -135,6 +135,48 @@ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
135
135
  return enabledSomewhere && !plugin.installedVersion;
136
136
  }
137
137
 
138
+ /**
139
+ * True when a scope genuinely has the plugin installed.
140
+ *
141
+ * `enabled` on its own is not proof. It is the `enabledPlugins` flag out of a
142
+ * settings file, and only claudeup and the user maintain it; `version` comes
143
+ * from `installed_plugins.json` via `overlayRegistryVersions`, which is what
144
+ * Claude Code actually loaded. When the two disagree — flag set, no registry
145
+ * entry — the plugin is in the broken enabled-but-not-installed state that the
146
+ * list already renders as "not installed".
147
+ *
148
+ * Every action that touches the filesystem must use this, not the flag alone.
149
+ * Deciding on the flag is what made the scope keys uninstall a plugin the row
150
+ * had just labelled "not installed".
151
+ *
152
+ * A version of "0.0.0" still counts as installed — Anthropic's official plugins
153
+ * record exactly that — which is why this tests presence, not `isKnownVersion`.
154
+ */
155
+ export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
156
+ return !!scope?.enabled && !!scope.version;
157
+ }
158
+
159
+ /**
160
+ * What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
161
+ *
162
+ * `install` doubles as the repair for enabled-but-not-installed. Measured
163
+ * against Claude Code 2.1.223 in exactly that state — `dev@magus` flagged in
164
+ * `.claude/settings.json` with no registry entry for the project path —
165
+ * `claude plugin install dev@magus --scope project` installed it normally and
166
+ * wrote the registry entry. It does not report "already installed", so this
167
+ * needs no uninstall-first dance (unlike content drift, see repairPlugin).
168
+ */
169
+ export function resolveScopeAction(
170
+ scope: ScopeStatus | undefined,
171
+ latestVersion: string,
172
+ ): "install" | "update" | "uninstall" {
173
+ if (!isInstalledInScope(scope)) return "install";
174
+ const installed = scope?.version;
175
+ const hasUpdate =
176
+ !!installed && latestVersion !== "0.0.0" && installed !== latestVersion;
177
+ return hasUpdate ? "update" : "uninstall";
178
+ }
179
+
138
180
 
139
181
  export async function getAvailablePlugins(
140
182
  projectPath?: string,
@@ -16,9 +16,13 @@ import {
16
16
  import { theme } from "../theme.js";
17
17
  import { highlightMatches } from "../../utils/fuzzy-search.js";
18
18
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
19
- import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
19
+ import {
20
+ isEnabledButNotInstalled,
21
+ isInstalledInScope,
22
+ } from "../../services/plugin-manager.js";
20
23
  import { isKnownVersion } from "../../services/version-snapshot.js";
21
24
  import type { PluginRelease } from "../../types/index.js";
25
+ import type { ScopeStatus } from "../../services/plugin-manager.js";
22
26
 
23
27
  // ─── Category renderers ───────────────────────────────────────────────────────
24
28
 
@@ -223,10 +227,14 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
223
227
 
224
228
  function pluginDetail(item: PluginPluginItem): React.ReactNode {
225
229
  const { plugin } = item;
230
+ // "Installed" has to mean the same thing here as in the list row. Reading the
231
+ // `enabledPlugins` flag alone made this panel print "● Installed" for the
232
+ // exact plugin the row beside it was labelling "not installed".
226
233
  const isInstalled =
227
- plugin.userScope?.enabled ||
228
- plugin.projectScope?.enabled ||
229
- plugin.localScope?.enabled;
234
+ isInstalledInScope(plugin.userScope) ||
235
+ isInstalledInScope(plugin.projectScope) ||
236
+ isInstalledInScope(plugin.localScope);
237
+ const brokenInstall = isEnabledButNotInstalled(plugin);
230
238
 
231
239
  // Orphaned/deprecated plugin
232
240
  if (plugin.isOrphaned) {
@@ -284,10 +292,30 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
284
292
 
285
293
  {/* Status line */}
286
294
  <box marginTop={1}>
287
- <text fg={isInstalled ? theme.colors.success : theme.colors.muted}>
288
- {isInstalled ? "● Installed" : "○ Not installed"}
295
+ <text
296
+ fg={
297
+ isInstalled
298
+ ? theme.colors.success
299
+ : brokenInstall
300
+ ? theme.colors.warning
301
+ : theme.colors.muted
302
+ }
303
+ >
304
+ {isInstalled
305
+ ? "● Installed"
306
+ : brokenInstall
307
+ ? "○ Not installed — enabled in settings, no files on disk"
308
+ : "○ Not installed"}
289
309
  </text>
290
310
  </box>
311
+ {brokenInstall ? (
312
+ <box>
313
+ <text fg={theme.colors.muted}>
314
+ Claude Code cannot load it in this state. Press the scope key to
315
+ repair.
316
+ </text>
317
+ </box>
318
+ ) : null}
291
319
 
292
320
  {/* Description */}
293
321
  <box marginTop={1} marginBottom={1}>
@@ -331,48 +359,27 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
331
359
  <strong>Scopes:</strong>
332
360
  </text>
333
361
  <box marginTop={1} flexDirection="column">
334
- <text>
335
- <span bg={theme.scopes.user} fg="black">
336
- {" "}
337
- u{" "}
338
- </span>
339
- <span fg={plugin.userScope?.enabled ? theme.scopes.user : theme.colors.muted}>
340
- {plugin.userScope?.enabled ? " ● " : " ○ "}
341
- </span>
342
- <span fg={theme.scopes.user}>User</span>
343
- <span> global</span>
344
- {plugin.userScope?.version ? (
345
- <span fg={theme.scopes.user}> v{plugin.userScope.version}</span>
346
- ) : null}
347
- </text>
348
- <text>
349
- <span bg={theme.scopes.project} fg="black">
350
- {" "}
351
- p{" "}
352
- </span>
353
- <span fg={plugin.projectScope?.enabled ? theme.scopes.project : theme.colors.muted}>
354
- {plugin.projectScope?.enabled ? " ● " : " ○ "}
355
- </span>
356
- <span fg={theme.scopes.project}>Project</span>
357
- <span> team</span>
358
- {plugin.projectScope?.version ? (
359
- <span fg={theme.scopes.project}> v{plugin.projectScope.version}</span>
360
- ) : null}
361
- </text>
362
- <text>
363
- <span bg={theme.scopes.local} fg="black">
364
- {" "}
365
- l{" "}
366
- </span>
367
- <span fg={plugin.localScope?.enabled ? theme.scopes.local : theme.colors.muted}>
368
- {plugin.localScope?.enabled ? " ● " : " ○ "}
369
- </span>
370
- <span fg={theme.scopes.local}>Local</span>
371
- <span> private</span>
372
- {plugin.localScope?.version ? (
373
- <span fg={theme.scopes.local}> v{plugin.localScope.version}</span>
374
- ) : null}
375
- </text>
362
+ <ScopeLine
363
+ keyHint="u"
364
+ name="User"
365
+ qualifier="global"
366
+ color={theme.scopes.user}
367
+ scope={plugin.userScope}
368
+ />
369
+ <ScopeLine
370
+ keyHint="p"
371
+ name="Project"
372
+ qualifier="team"
373
+ color={theme.scopes.project}
374
+ scope={plugin.projectScope}
375
+ />
376
+ <ScopeLine
377
+ keyHint="l"
378
+ name="Local"
379
+ qualifier="private"
380
+ color={theme.scopes.local}
381
+ scope={plugin.localScope}
382
+ />
376
383
  </box>
377
384
  </DetailSection>
378
385
 
@@ -389,6 +396,47 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
389
396
  );
390
397
  }
391
398
 
399
+ /**
400
+ * One line of the detail panel's scope breakdown.
401
+ *
402
+ * The filled dot means installed — registry-backed, same as everywhere else.
403
+ * A scope that is enabled with nothing installed is called out explicitly
404
+ * rather than shown as a plain empty dot, because those two states need
405
+ * different actions from the user and used to look identical.
406
+ */
407
+ function ScopeLine({
408
+ keyHint,
409
+ name,
410
+ qualifier,
411
+ color,
412
+ scope,
413
+ }: {
414
+ keyHint: string;
415
+ name: string;
416
+ qualifier: string;
417
+ color: string;
418
+ scope?: ScopeStatus;
419
+ }): React.ReactNode {
420
+ const installed = isInstalledInScope(scope);
421
+ return (
422
+ <text>
423
+ <span bg={color} fg="black">
424
+ {" "}
425
+ {keyHint}{" "}
426
+ </span>
427
+ <span fg={installed ? color : theme.colors.muted}>
428
+ {installed ? " ● " : " ○ "}
429
+ </span>
430
+ <span fg={color}>{name}</span>
431
+ <span> {qualifier}</span>
432
+ {scope?.version ? <span fg={color}> v{scope.version}</span> : null}
433
+ {!installed && scope?.enabled ? (
434
+ <span fg={theme.colors.warning}> enabled, not installed</span>
435
+ ) : null}
436
+ </text>
437
+ );
438
+ }
439
+
392
440
  /**
393
441
  * Recent release history, newest first.
394
442
  *
@@ -18,8 +18,11 @@ import {
18
18
  refreshAllMarketplaces,
19
19
  clearMarketplaceCache,
20
20
  getLocalMarketplacesInfo,
21
+ isInstalledInScope,
22
+ resolveScopeAction,
21
23
  saveInstalledPluginVersion,
22
24
  type PluginInfo,
25
+ type ScopeStatus,
23
26
  } from "../../services/plugin-manager.js";
24
27
  import {
25
28
  setMcpEnvVar,
@@ -605,10 +608,10 @@ export function PluginsScreen() {
605
608
 
606
609
  const buildScopeLabel = (
607
610
  name: string,
608
- scope: { enabled?: boolean; version?: string } | undefined,
611
+ scope: ScopeStatus | undefined,
609
612
  desc: string,
610
613
  ) => {
611
- const installed = scope?.enabled;
614
+ const installed = isInstalledInScope(scope);
612
615
  const ver = scope?.version;
613
616
  const hasUpdate =
614
617
  ver &&
@@ -619,6 +622,10 @@ export function PluginsScreen() {
619
622
  label += ` (${desc})`;
620
623
  if (ver) label += ` v${ver}`;
621
624
  if (hasUpdate) label += ` → v${latestVersion}`;
625
+ // Enabled in settings with nothing installed. Without saying so the
626
+ // row reads exactly like a scope that was never enabled, and the
627
+ // user cannot tell that picking it repairs rather than adds.
628
+ if (!installed && scope?.enabled) label += " — not installed, repairs";
622
629
  return label;
623
630
  };
624
631
 
@@ -650,8 +657,6 @@ export function PluginsScreen() {
650
657
  : scopeValue === "project"
651
658
  ? plugin.projectScope
652
659
  : plugin.localScope;
653
- const isInstalledInScope = selectedScope?.enabled;
654
- const installedVersion = selectedScope?.version;
655
660
  const scopeLabel =
656
661
  scopeValue === "user"
657
662
  ? "User"
@@ -659,20 +664,9 @@ export function PluginsScreen() {
659
664
  ? "Project"
660
665
  : "Local";
661
666
 
662
- const hasUpdateInScope =
663
- isInstalledInScope &&
664
- installedVersion &&
665
- latestVersion !== "0.0.0" &&
666
- installedVersion !== latestVersion;
667
-
668
- let action: "update" | "install" | "uninstall";
669
- if (isInstalledInScope && hasUpdateInScope) {
670
- action = "update";
671
- } else if (isInstalledInScope) {
672
- action = "uninstall";
673
- } else {
674
- action = "install";
675
- }
667
+ // Decided from the registry-backed version, not the `enabledPlugins`
668
+ // flag alone — see resolveScopeAction.
669
+ const action = resolveScopeAction(selectedScope, latestVersion);
676
670
 
677
671
  try {
678
672
  const scope = scopeValue as PluginScope;
@@ -843,23 +837,12 @@ export function PluginsScreen() {
843
837
  : scope === "project"
844
838
  ? plugin.projectScope
845
839
  : plugin.localScope;
846
- const isInstalledInScope = scopeData?.enabled;
847
- const installedVersion = scopeData?.version;
848
-
849
- const hasUpdateInScope =
850
- isInstalledInScope &&
851
- installedVersion &&
852
- latestVersion !== "0.0.0" &&
853
- installedVersion !== latestVersion;
854
-
855
- let action: "update" | "install" | "uninstall";
856
- if (isInstalledInScope && hasUpdateInScope) {
857
- action = "update";
858
- } else if (isInstalledInScope) {
859
- action = "uninstall";
860
- } else {
861
- action = "install";
862
- }
840
+
841
+ // This used to key off `scopeData.enabled` alone, so a plugin the list
842
+ // had just labelled "not installed" answered its own scope key by
843
+ // uninstalling itself — the `enabledPlugins` flag was set, the registry
844
+ // entry was missing, and only the flag was consulted.
845
+ const action = resolveScopeAction(scopeData, latestVersion);
863
846
 
864
847
  try {
865
848
  if (action === "uninstall") {