claudeup 4.5.0 → 4.5.2

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.5.0",
3
+ "version": "4.5.2",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Dual-write prevention tests
2
+ * Version tracking tests
3
3
  *
4
- * Verifies that claudeup does NOT write plugin state (via saveGlobalInstalledPluginVersion
5
- * or saveVersionAfterInstall) when the Claude CLI has already handled the write.
4
+ * The Claude CLI's `plugin install` command does NOT update `installedPluginVersions`
5
+ * in settings.json it only manages `enabledPlugins`. Claudeup must save the version
6
+ * itself after a successful CLI install/update.
6
7
  *
7
- * RED before fix: PluginsScreen has 6 saveVersionAfterInstall calls after CLI ops;
8
- * prerunner calls saveGlobalInstalledPluginVersion unconditionally.
9
- * GREEN after fix: those calls are removed.
8
+ * PluginsScreen: no saveVersionAfterInstall helper (removed in v4.5.0), but each
9
+ * action handler calls saveVersionForScope after CLI success.
10
+ * Prerunner: calls saveGlobalInstalledPluginVersion after successful updatePlugin,
11
+ * but NOT when CLI is unavailable or update fails (no phantom state).
10
12
  */
11
13
 
12
14
  import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
@@ -121,6 +123,7 @@ mock.module("../services/claude-cli.js", () => ({
121
123
  updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
122
124
  isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
123
125
  addMarketplace: mock(() => Promise.resolve()),
126
+ updateMarketplace: mock(() => Promise.resolve()),
124
127
  }));
125
128
 
126
129
  mock.module("../services/plugin-manager.js", () => ({
@@ -194,16 +197,16 @@ describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
194
197
  );
195
198
  });
196
199
 
197
- it("does NOT call saveGlobalInstalledPluginVersion when CLI is available and updatePlugin succeeds", async () => {
200
+ it("calls saveGlobalInstalledPluginVersion once after successful CLI update", async () => {
198
201
  // CLI is available, updatePlugin succeeds.
199
- // The FIXED code removes the saveGlobalInstalledPluginVersion call entirely
200
- // the CLI already wrote the state.
202
+ // The CLI does NOT update installedPluginVersions, so claudeup must save
203
+ // the version after a successful update.
201
204
  mockIsClaudeAvailable = mock(() => Promise.resolve(true));
202
205
  mockUpdatePlugin = mock(() => Promise.resolve());
203
206
 
204
207
  await prerunClaude(["--help"], { force: true });
205
208
 
206
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
209
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
207
210
  });
208
211
 
209
212
  it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
package/src/main.js CHANGED
@@ -27,8 +27,54 @@ async function main() {
27
27
  }
28
28
  // Handle "claudeup update" - self-update command
29
29
  if (args[0] === "update") {
30
- // Detect how claudeup was installed by checking the executable path
31
30
  const { execSync } = await import("node:child_process");
31
+ const { existsSync } = await import("node:fs");
32
+ // Detect all installations of claudeup across package managers
33
+ const installations = [];
34
+ // Check bun global
35
+ try {
36
+ const bunGlobalBin = execSync("bun pm -g bin", {
37
+ encoding: "utf-8",
38
+ timeout: 5000,
39
+ }).trim();
40
+ const bunPath = `${bunGlobalBin}/claudeup`;
41
+ if (existsSync(bunPath))
42
+ installations.push({ manager: "bun", path: bunPath });
43
+ }
44
+ catch {
45
+ // bun not installed or no global claudeup
46
+ }
47
+ // Check npm global
48
+ try {
49
+ const npmPrefix = execSync("npm prefix -g", {
50
+ encoding: "utf-8",
51
+ timeout: 5000,
52
+ }).trim();
53
+ const npmPath = `${npmPrefix}/bin/claudeup`;
54
+ if (existsSync(npmPath))
55
+ installations.push({ manager: "npm", path: npmPath });
56
+ }
57
+ catch {
58
+ // npm not installed or no global claudeup
59
+ }
60
+ // Warn about duplicate installations
61
+ if (installations.length > 1) {
62
+ const activePath = execSync("which claudeup", {
63
+ encoding: "utf-8",
64
+ timeout: 5000,
65
+ }).trim();
66
+ console.log(`⚠ claudeup is installed via multiple package managers:\n`);
67
+ for (const inst of installations) {
68
+ const tag = inst.path === activePath ? " (active)" : " (shadowed)";
69
+ console.log(` ${inst.manager}: ${inst.path}${tag}`);
70
+ }
71
+ console.log(`\nTo fix, keep one and remove the other:`);
72
+ for (const inst of installations) {
73
+ console.log(` ${inst.manager} uninstall -g claudeup`);
74
+ }
75
+ console.log();
76
+ }
77
+ // Determine which package manager to use for the update
32
78
  let usesBun = false;
33
79
  try {
34
80
  const claudeupPath = execSync("which claudeup", {
@@ -42,9 +88,7 @@ async function main() {
42
88
  }
43
89
  const pkgManager = usesBun ? "bun" : "npm";
44
90
  console.log(`Updating claudeup using ${pkgManager}...`);
45
- const installArgs = usesBun
46
- ? ["install", "-g", "claudeup@latest"]
47
- : ["install", "-g", "claudeup@latest"];
91
+ const installArgs = ["install", "-g", "claudeup@latest"];
48
92
  const proc = spawn(pkgManager, installArgs, {
49
93
  stdio: "inherit",
50
94
  shell: false, // Avoid shell for security (fixes DEP0190 warning)
package/src/main.tsx CHANGED
@@ -38,8 +38,62 @@ async function main(): Promise<void> {
38
38
 
39
39
  // Handle "claudeup update" - self-update command
40
40
  if (args[0] === "update") {
41
- // Detect how claudeup was installed by checking the executable path
42
41
  const { execSync } = await import("node:child_process");
42
+ const { existsSync } = await import("node:fs");
43
+
44
+ // Detect all installations of claudeup across package managers
45
+ const installations: Array<{
46
+ manager: "bun" | "npm";
47
+ path: string;
48
+ }> = [];
49
+
50
+ // Check bun global
51
+ try {
52
+ const bunGlobalBin = execSync("bun pm -g bin", {
53
+ encoding: "utf-8",
54
+ timeout: 5000,
55
+ }).trim();
56
+ const bunPath = `${bunGlobalBin}/claudeup`;
57
+ if (existsSync(bunPath)) installations.push({ manager: "bun", path: bunPath });
58
+ } catch {
59
+ // bun not installed or no global claudeup
60
+ }
61
+
62
+ // Check npm global
63
+ try {
64
+ const npmPrefix = execSync("npm prefix -g", {
65
+ encoding: "utf-8",
66
+ timeout: 5000,
67
+ }).trim();
68
+ const npmPath = `${npmPrefix}/bin/claudeup`;
69
+ if (existsSync(npmPath)) installations.push({ manager: "npm", path: npmPath });
70
+ } catch {
71
+ // npm not installed or no global claudeup
72
+ }
73
+
74
+ // Warn about duplicate installations
75
+ if (installations.length > 1) {
76
+ const activePath = execSync("which claudeup", {
77
+ encoding: "utf-8",
78
+ timeout: 5000,
79
+ }).trim();
80
+
81
+ console.log(
82
+ `⚠ claudeup is installed via multiple package managers:\n`,
83
+ );
84
+ for (const inst of installations) {
85
+ const tag =
86
+ inst.path === activePath ? " (active)" : " (shadowed)";
87
+ console.log(` ${inst.manager}: ${inst.path}${tag}`);
88
+ }
89
+ console.log(`\nTo fix, keep one and remove the other:`);
90
+ for (const inst of installations) {
91
+ console.log(` ${inst.manager} uninstall -g claudeup`);
92
+ }
93
+ console.log();
94
+ }
95
+
96
+ // Determine which package manager to use for the update
43
97
  let usesBun = false;
44
98
  try {
45
99
  const claudeupPath = execSync("which claudeup", {
@@ -54,9 +108,7 @@ async function main(): Promise<void> {
54
108
  const pkgManager = usesBun ? "bun" : "npm";
55
109
  console.log(`Updating claudeup using ${pkgManager}...`);
56
110
 
57
- const installArgs = usesBun
58
- ? ["install", "-g", "claudeup@latest"]
59
- : ["install", "-g", "claudeup@latest"];
111
+ const installArgs = ["install", "-g", "claudeup@latest"];
60
112
 
61
113
  const proc = spawn(pkgManager, installArgs, {
62
114
  stdio: "inherit",
@@ -4,10 +4,10 @@ import os from "node:os";
4
4
  import { UpdateCache } from "../services/update-cache.js";
5
5
  import { getAvailablePlugins, clearMarketplaceCache, } from "../services/plugin-manager.js";
6
6
  import { runClaude } from "../services/claude-runner.js";
7
- import { recoverMarketplaceSettings, migrateMarketplaceRename, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, } from "../services/claude-settings.js";
7
+ import { recoverMarketplaceSettings, migrateMarketplaceRename, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, saveGlobalInstalledPluginVersion, } from "../services/claude-settings.js";
8
8
  import { parsePluginId } from "../utils/string-utils.js";
9
9
  import { defaultMarketplaces } from "../data/marketplaces.js";
10
- import { updatePlugin, addMarketplace, isClaudeAvailable, } from "../services/claude-cli.js";
10
+ import { updatePlugin, addMarketplace, updateMarketplace, isClaudeAvailable, } from "../services/claude-cli.js";
11
11
  const MARKETPLACES_DIR = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
12
12
  /**
13
13
  * Collect all unique marketplace names from enabled plugins across all settings scopes.
@@ -55,6 +55,11 @@ async function getReferencedMarketplaces(projectPath) {
55
55
  /**
56
56
  * Check which referenced marketplaces are missing locally and auto-add them.
57
57
  * Only adds marketplaces with known repos (from defaultMarketplaces).
58
+ *
59
+ * Uses `marketplace update` (not `marketplace add`) for recovery because
60
+ * `add` short-circuits when the marketplace is already declared in settings
61
+ * — even if the directory was deleted. `update` detects the missing dir and
62
+ * re-clones automatically.
58
63
  */
59
64
  async function autoAddMissingMarketplaces(projectPath) {
60
65
  const referenced = await getReferencedMarketplaces(projectPath);
@@ -69,12 +74,19 @@ async function autoAddMissingMarketplaces(projectPath) {
69
74
  if (!defaultMp?.source.repo)
70
75
  continue;
71
76
  try {
72
- await addMarketplace(defaultMp.source.repo);
77
+ // Try `marketplace update` first — it re-clones missing directories
78
+ await updateMarketplace(mpName);
73
79
  added.push(mpName);
74
80
  }
75
- catch (error) {
76
- // Non-fatal: log and continue
77
- console.warn(`⚠ Failed to auto-add marketplace ${mpName}:`, error instanceof Error ? error.message : "Unknown error");
81
+ catch {
82
+ // If update fails (marketplace not in settings yet), try add
83
+ try {
84
+ await addMarketplace(defaultMp.source.repo);
85
+ added.push(mpName);
86
+ }
87
+ catch (error) {
88
+ console.warn(`⚠ Failed to auto-add marketplace ${mpName}:`, error instanceof Error ? error.message : "Unknown error");
89
+ }
78
90
  }
79
91
  }
80
92
  return added;
@@ -199,7 +211,8 @@ export async function prerunClaude(claudeArgs, options = {}) {
199
211
  continue;
200
212
  }
201
213
  await updatePlugin(plugin.id, "user");
202
- // CLI wrote all plugin state; no claudeup write needed
214
+ // CLI does NOT update installedPluginVersions save it ourselves
215
+ await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
203
216
  autoUpdatedPlugins.push({
204
217
  pluginId: plugin.id,
205
218
  oldVersion: plugin.installedVersion,
@@ -15,12 +15,14 @@ import {
15
15
  getLocalEnabledPlugins,
16
16
  readGlobalSettings,
17
17
  writeGlobalSettings,
18
+ saveGlobalInstalledPluginVersion,
18
19
  } from "../services/claude-settings.js";
19
20
  import { parsePluginId } from "../utils/string-utils.js";
20
21
  import { defaultMarketplaces } from "../data/marketplaces.js";
21
22
  import {
22
23
  updatePlugin,
23
24
  addMarketplace,
25
+ updateMarketplace,
24
26
  isClaudeAvailable,
25
27
  } from "../services/claude-cli.js";
26
28
 
@@ -84,6 +86,11 @@ async function getReferencedMarketplaces(
84
86
  /**
85
87
  * Check which referenced marketplaces are missing locally and auto-add them.
86
88
  * Only adds marketplaces with known repos (from defaultMarketplaces).
89
+ *
90
+ * Uses `marketplace update` (not `marketplace add`) for recovery because
91
+ * `add` short-circuits when the marketplace is already declared in settings
92
+ * — even if the directory was deleted. `update` detects the missing dir and
93
+ * re-clones automatically.
87
94
  */
88
95
  async function autoAddMissingMarketplaces(
89
96
  projectPath?: string,
@@ -101,14 +108,20 @@ async function autoAddMissingMarketplaces(
101
108
  if (!defaultMp?.source.repo) continue;
102
109
 
103
110
  try {
104
- await addMarketplace(defaultMp.source.repo);
111
+ // Try `marketplace update` first — it re-clones missing directories
112
+ await updateMarketplace(mpName);
105
113
  added.push(mpName);
106
- } catch (error) {
107
- // Non-fatal: log and continue
108
- console.warn(
109
- `⚠ Failed to auto-add marketplace ${mpName}:`,
110
- error instanceof Error ? error.message : "Unknown error",
111
- );
114
+ } catch {
115
+ // If update fails (marketplace not in settings yet), try add
116
+ try {
117
+ await addMarketplace(defaultMp.source.repo);
118
+ added.push(mpName);
119
+ } catch (error) {
120
+ console.warn(
121
+ `⚠ Failed to auto-add marketplace ${mpName}:`,
122
+ error instanceof Error ? error.message : "Unknown error",
123
+ );
124
+ }
112
125
  }
113
126
  }
114
127
 
@@ -282,7 +295,8 @@ export async function prerunClaude(
282
295
  continue;
283
296
  }
284
297
  await updatePlugin(plugin.id, "user");
285
- // CLI wrote all plugin state; no claudeup write needed
298
+ // CLI does NOT update installedPluginVersions save it ourselves
299
+ await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
286
300
 
287
301
  autoUpdatedPlugins.push({
288
302
  pluginId: plugin.id,
@@ -8,8 +8,8 @@ import { ScrollableList } from "../components/ScrollableList.js";
8
8
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
9
9
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
10
10
  import { getAllMarketplaces } from "../../data/marketplaces.js";
11
- import { getAvailablePlugins, refreshAllMarketplaces, clearMarketplaceCache, getLocalMarketplacesInfo, } from "../../services/plugin-manager.js";
12
- import { setMcpEnvVar, getMcpEnvVars, readSettings, } from "../../services/claude-settings.js";
11
+ import { getAvailablePlugins, refreshAllMarketplaces, clearMarketplaceCache, getLocalMarketplacesInfo, saveInstalledPluginVersion, } from "../../services/plugin-manager.js";
12
+ import { setMcpEnvVar, getMcpEnvVars, readSettings, saveGlobalInstalledPluginVersion, saveLocalInstalledPluginVersion, } from "../../services/claude-settings.js";
13
13
  import { saveProfile } from "../../services/profiles.js";
14
14
  import { installPlugin as cliInstallPlugin, uninstallPlugin as cliUninstallPlugin, updatePlugin as cliUpdatePlugin, } from "../../services/claude-cli.js";
15
15
  import { getPluginEnvRequirements, getPluginSourcePath, } from "../../services/plugin-mcp-config.js";
@@ -206,6 +206,23 @@ export function PluginsScreen() {
206
206
  else if (event.name === "s")
207
207
  handleSaveAsProfile();
208
208
  });
209
+ // ── Helpers ───────────────────────────────────────────────────────────────
210
+ /**
211
+ * Save the installed plugin version to the correct settings file for the scope.
212
+ * The Claude CLI's `plugin install` does NOT update installedPluginVersions,
213
+ * so claudeup must do it after a successful CLI install/update.
214
+ */
215
+ const saveVersionForScope = async (pluginId, version, scope) => {
216
+ if (scope === "user") {
217
+ await saveGlobalInstalledPluginVersion(pluginId, version);
218
+ }
219
+ else if (scope === "local") {
220
+ await saveLocalInstalledPluginVersion(pluginId, version, state.projectPath);
221
+ }
222
+ else {
223
+ await saveInstalledPluginVersion(pluginId, version, state.projectPath);
224
+ }
225
+ };
209
226
  // ── Action handlers ────────────────────────────────────────────────────────
210
227
  const handleRefresh = async () => {
211
228
  progress.show("Refreshing cache...");
@@ -439,9 +456,11 @@ export function PluginsScreen() {
439
456
  }
440
457
  else if (action === "update") {
441
458
  await cliUpdatePlugin(plugin.id, scope);
459
+ await saveVersionForScope(plugin.id, latestVersion, scope);
442
460
  }
443
461
  else {
444
462
  await cliInstallPlugin(plugin.id, scope);
463
+ await saveVersionForScope(plugin.id, latestVersion, scope);
445
464
  modal.hideModal();
446
465
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
447
466
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -466,6 +485,9 @@ export function PluginsScreen() {
466
485
  modal.loading(`Updating ${plugin.name}...`);
467
486
  try {
468
487
  await cliUpdatePlugin(plugin.id, scope);
488
+ if (plugin.version) {
489
+ await saveVersionForScope(plugin.id, plugin.version, scope);
490
+ }
469
491
  modal.hideModal();
470
492
  fetchData();
471
493
  }
@@ -485,6 +507,9 @@ export function PluginsScreen() {
485
507
  try {
486
508
  for (const plugin of updatable) {
487
509
  await cliUpdatePlugin(plugin.id, scope);
510
+ if (plugin.version) {
511
+ await saveVersionForScope(plugin.id, plugin.version, scope);
512
+ }
488
513
  }
489
514
  modal.hideModal();
490
515
  fetchData();
@@ -534,9 +559,11 @@ export function PluginsScreen() {
534
559
  }
535
560
  else if (action === "update") {
536
561
  await cliUpdatePlugin(plugin.id, scope);
562
+ await saveVersionForScope(plugin.id, latestVersion, scope);
537
563
  }
538
564
  else {
539
565
  await cliInstallPlugin(plugin.id, scope);
566
+ await saveVersionForScope(plugin.id, latestVersion, scope);
540
567
  modal.hideModal();
541
568
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
542
569
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -12,12 +12,15 @@ import {
12
12
  refreshAllMarketplaces,
13
13
  clearMarketplaceCache,
14
14
  getLocalMarketplacesInfo,
15
+ saveInstalledPluginVersion,
15
16
  type PluginInfo,
16
17
  } from "../../services/plugin-manager.js";
17
18
  import {
18
19
  setMcpEnvVar,
19
20
  getMcpEnvVars,
20
21
  readSettings,
22
+ saveGlobalInstalledPluginVersion,
23
+ saveLocalInstalledPluginVersion,
21
24
  } from "../../services/claude-settings.js";
22
25
  import { saveProfile } from "../../services/profiles.js";
23
26
  import {
@@ -241,6 +244,27 @@ export function PluginsScreen() {
241
244
  else if (event.name === "s") handleSaveAsProfile();
242
245
  });
243
246
 
247
+ // ── Helpers ───────────────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Save the installed plugin version to the correct settings file for the scope.
251
+ * The Claude CLI's `plugin install` does NOT update installedPluginVersions,
252
+ * so claudeup must do it after a successful CLI install/update.
253
+ */
254
+ const saveVersionForScope = async (
255
+ pluginId: string,
256
+ version: string,
257
+ scope: PluginScope,
258
+ ): Promise<void> => {
259
+ if (scope === "user") {
260
+ await saveGlobalInstalledPluginVersion(pluginId, version);
261
+ } else if (scope === "local") {
262
+ await saveLocalInstalledPluginVersion(pluginId, version, state.projectPath);
263
+ } else {
264
+ await saveInstalledPluginVersion(pluginId, version, state.projectPath);
265
+ }
266
+ };
267
+
244
268
  // ── Action handlers ────────────────────────────────────────────────────────
245
269
 
246
270
  const handleRefresh = async () => {
@@ -549,8 +573,10 @@ export function PluginsScreen() {
549
573
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
550
574
  } else if (action === "update") {
551
575
  await cliUpdatePlugin(plugin.id, scope);
576
+ await saveVersionForScope(plugin.id, latestVersion, scope);
552
577
  } else {
553
578
  await cliInstallPlugin(plugin.id, scope);
579
+ await saveVersionForScope(plugin.id, latestVersion, scope);
554
580
  modal.hideModal();
555
581
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
556
582
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -576,6 +602,9 @@ export function PluginsScreen() {
576
602
  modal.loading(`Updating ${plugin.name}...`);
577
603
  try {
578
604
  await cliUpdatePlugin(plugin.id, scope);
605
+ if (plugin.version) {
606
+ await saveVersionForScope(plugin.id, plugin.version, scope);
607
+ }
579
608
  modal.hideModal();
580
609
  fetchData();
581
610
  } catch (error) {
@@ -596,6 +625,9 @@ export function PluginsScreen() {
596
625
  try {
597
626
  for (const plugin of updatable) {
598
627
  await cliUpdatePlugin(plugin.id, scope);
628
+ if (plugin.version) {
629
+ await saveVersionForScope(plugin.id, plugin.version, scope);
630
+ }
599
631
  }
600
632
  modal.hideModal();
601
633
  fetchData();
@@ -651,8 +683,10 @@ export function PluginsScreen() {
651
683
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
652
684
  } else if (action === "update") {
653
685
  await cliUpdatePlugin(plugin.id, scope);
686
+ await saveVersionForScope(plugin.id, latestVersion, scope);
654
687
  } else {
655
688
  await cliInstallPlugin(plugin.id, scope);
689
+ await saveVersionForScope(plugin.id, latestVersion, scope);
656
690
  modal.hideModal();
657
691
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
658
692
  await installPluginSystemDeps(plugin.name, plugin.marketplace);