claudeup 4.4.0 → 4.5.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.4.0",
3
+ "version": "4.5.1",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Version tracking tests
3
+ *
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.
7
+ *
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).
12
+ */
13
+
14
+ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import * as url from "node:url";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // PART 1: PluginsScreen source-level policy tests
21
+ //
22
+ // The dual-write calls in PluginsScreen are inside React event handlers.
23
+ // Rendering the full TUI component in a unit test would require the opentui
24
+ // terminal runtime. Instead we assert the source does NOT contain the
25
+ // forbidden call pattern — this is a code-policy test that:
26
+ // - FAILS before the fix (6 saveVersionAfterInstall calls follow CLI calls)
27
+ // - PASSES after the fix (those calls are removed)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
31
+ const PLUGINS_SCREEN_PATH = path.resolve(
32
+ __dirname,
33
+ "../ui/screens/PluginsScreen.tsx",
34
+ );
35
+
36
+ describe("PluginsScreen — no dual-write after CLI operations", () => {
37
+ let source: string;
38
+
39
+ beforeEach(() => {
40
+ source = fs.readFileSync(PLUGINS_SCREEN_PATH, "utf8");
41
+ });
42
+
43
+ it("does not call saveVersionAfterInstall immediately after cliUpdatePlugin", () => {
44
+ // Find every occurrence of cliUpdatePlugin in the source. Each one should
45
+ // NOT be immediately followed (within 3 lines) by saveVersionAfterInstall.
46
+ const lines = source.split("\n");
47
+ const violations: number[] = [];
48
+
49
+ for (let i = 0; i < lines.length; i++) {
50
+ if (lines[i].includes("cliUpdatePlugin(")) {
51
+ // Check the next 3 lines for a dual-write call
52
+ for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
53
+ if (lines[j].includes("saveVersionAfterInstall(")) {
54
+ violations.push(i + 1); // 1-indexed line number
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ expect(violations).toEqual([]);
62
+ });
63
+
64
+ it("does not call saveVersionAfterInstall immediately after cliInstallPlugin", () => {
65
+ const lines = source.split("\n");
66
+ const violations: number[] = [];
67
+
68
+ for (let i = 0; i < lines.length; i++) {
69
+ if (lines[i].includes("cliInstallPlugin(")) {
70
+ for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
71
+ if (lines[j].includes("saveVersionAfterInstall(")) {
72
+ violations.push(i + 1);
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ expect(violations).toEqual([]);
80
+ });
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // PART 2: Prerunner — saveGlobalInstalledPluginVersion guarded by CLI success
85
+ //
86
+ // We mock the prerunner's module dependencies so we can call prerunClaude()
87
+ // directly and inspect call counts.
88
+ // ---------------------------------------------------------------------------
89
+
90
+ // Mutable state shared between mock factories and test assertions.
91
+ let mockSaveGlobal: ReturnType<typeof mock>;
92
+ let mockUpdatePlugin: ReturnType<typeof mock>;
93
+ let mockIsClaudeAvailable: ReturnType<typeof mock>;
94
+ let mockGetAvailablePlugins: ReturnType<typeof mock>;
95
+
96
+ // We must register mock.module before importing the module under test.
97
+ // Bun hoists mock.module() calls, so the mocks are active when the
98
+ // module is first loaded.
99
+
100
+ mock.module("../services/claude-settings.js", () => ({
101
+ recoverMarketplaceSettings: mock(() =>
102
+ Promise.resolve({ enabledAutoUpdate: [], removed: [] }),
103
+ ),
104
+ migrateMarketplaceRename: mock(() =>
105
+ Promise.resolve({
106
+ projectMigrated: 0,
107
+ globalMigrated: 0,
108
+ localMigrated: 0,
109
+ registryMigrated: 0,
110
+ knownMarketplacesMigrated: false,
111
+ }),
112
+ ),
113
+ getGlobalEnabledPlugins: mock(() => Promise.resolve({})),
114
+ getEnabledPlugins: mock(() => Promise.resolve({})),
115
+ getLocalEnabledPlugins: mock(() => Promise.resolve({})),
116
+ saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
117
+ mockSaveGlobal(...args),
118
+ readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
119
+ writeGlobalSettings: mock(() => Promise.resolve()),
120
+ }));
121
+
122
+ mock.module("../services/claude-cli.js", () => ({
123
+ updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
124
+ isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
125
+ addMarketplace: mock(() => Promise.resolve()),
126
+ updateMarketplace: mock(() => Promise.resolve()),
127
+ }));
128
+
129
+ mock.module("../services/plugin-manager.js", () => ({
130
+ getAvailablePlugins: (...args: unknown[]) =>
131
+ mockGetAvailablePlugins(...args),
132
+ clearMarketplaceCache: mock(() => undefined),
133
+ }));
134
+
135
+ mock.module("../services/update-cache.js", () => ({
136
+ UpdateCache: class {
137
+ shouldCheckForUpdates = mock(() => Promise.resolve(true));
138
+ saveCheck = mock(() => Promise.resolve());
139
+ },
140
+ }));
141
+
142
+ mock.module("../services/claude-runner.js", () => ({
143
+ runClaude: mock(() => Promise.resolve(0)),
144
+ }));
145
+
146
+ mock.module("../data/marketplaces.js", () => ({
147
+ defaultMarketplaces: [],
148
+ }));
149
+
150
+ mock.module("../utils/string-utils.js", () => ({
151
+ parsePluginId: mock((_id: string) => null),
152
+ }));
153
+
154
+ mock.module("fs-extra", () => ({
155
+ default: {
156
+ pathExists: mock(() => Promise.resolve(false)),
157
+ readJson: mock(() => Promise.resolve({})),
158
+ writeJson: mock(() => Promise.resolve()),
159
+ ensureDir: mock(() => Promise.resolve()),
160
+ },
161
+ }));
162
+
163
+ // Now import the module under test (after mock.module registrations).
164
+ const { prerunClaude } = await import("../prerunner/index.js");
165
+
166
+ // Helper: build a minimal PluginInfo-like object
167
+ function makePlugin(overrides: Partial<{
168
+ id: string;
169
+ enabled: boolean;
170
+ hasUpdate: boolean;
171
+ installedVersion: string;
172
+ version: string;
173
+ }> = {}): {
174
+ id: string;
175
+ enabled: boolean;
176
+ hasUpdate: boolean;
177
+ installedVersion: string;
178
+ version: string;
179
+ } {
180
+ return {
181
+ id: "test-plugin@magus",
182
+ enabled: true,
183
+ hasUpdate: true,
184
+ installedVersion: "1.0.0",
185
+ version: "1.1.0",
186
+ ...overrides,
187
+ };
188
+ }
189
+
190
+ describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
191
+ beforeEach(() => {
192
+ mockSaveGlobal = mock(() => Promise.resolve());
193
+ mockUpdatePlugin = mock(() => Promise.resolve());
194
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
195
+ mockGetAvailablePlugins = mock(() =>
196
+ Promise.resolve([makePlugin()]),
197
+ );
198
+ });
199
+
200
+ it("calls saveGlobalInstalledPluginVersion once after successful CLI update", async () => {
201
+ // CLI is available, updatePlugin succeeds.
202
+ // The CLI does NOT update installedPluginVersions, so claudeup must save
203
+ // the version after a successful update.
204
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
205
+ mockUpdatePlugin = mock(() => Promise.resolve());
206
+
207
+ await prerunClaude(["--help"], { force: true });
208
+
209
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
210
+ });
211
+
212
+ it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
213
+ // CLI unavailable — we must NOT write phantom state.
214
+ mockIsClaudeAvailable = mock(() => Promise.resolve(false));
215
+
216
+ await prerunClaude(["--help"], { force: true });
217
+
218
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
219
+ });
220
+
221
+ it("does NOT call saveGlobalInstalledPluginVersion when CLI updatePlugin throws", async () => {
222
+ // CLI available but updatePlugin fails — must NOT write phantom state.
223
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
224
+ mockUpdatePlugin = mock(() =>
225
+ Promise.reject(new Error("CLI update failed")),
226
+ );
227
+
228
+ await prerunClaude(["--help"], { force: true });
229
+
230
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
231
+ });
232
+ });
@@ -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, saveGlobalInstalledPluginVersion, 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;
@@ -194,9 +206,12 @@ export async function prerunClaude(claudeArgs, options = {}) {
194
206
  plugin.installedVersion &&
195
207
  plugin.version) {
196
208
  try {
197
- if (cliAvailable) {
198
- await updatePlugin(plugin.id, "user");
209
+ if (!cliAvailable) {
210
+ // CLI unavailable — skip update entirely, don't write phantom state
211
+ continue;
199
212
  }
213
+ await updatePlugin(plugin.id, "user");
214
+ // CLI does NOT update installedPluginVersions — save it ourselves
200
215
  await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
201
216
  autoUpdatedPlugins.push({
202
217
  pluginId: plugin.id,
@@ -13,15 +13,16 @@ import {
13
13
  getGlobalEnabledPlugins,
14
14
  getEnabledPlugins,
15
15
  getLocalEnabledPlugins,
16
- saveGlobalInstalledPluginVersion,
17
16
  readGlobalSettings,
18
17
  writeGlobalSettings,
18
+ saveGlobalInstalledPluginVersion,
19
19
  } from "../services/claude-settings.js";
20
20
  import { parsePluginId } from "../utils/string-utils.js";
21
21
  import { defaultMarketplaces } from "../data/marketplaces.js";
22
22
  import {
23
23
  updatePlugin,
24
24
  addMarketplace,
25
+ updateMarketplace,
25
26
  isClaudeAvailable,
26
27
  } from "../services/claude-cli.js";
27
28
 
@@ -85,6 +86,11 @@ async function getReferencedMarketplaces(
85
86
  /**
86
87
  * Check which referenced marketplaces are missing locally and auto-add them.
87
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.
88
94
  */
89
95
  async function autoAddMissingMarketplaces(
90
96
  projectPath?: string,
@@ -102,14 +108,20 @@ async function autoAddMissingMarketplaces(
102
108
  if (!defaultMp?.source.repo) continue;
103
109
 
104
110
  try {
105
- await addMarketplace(defaultMp.source.repo);
111
+ // Try `marketplace update` first — it re-clones missing directories
112
+ await updateMarketplace(mpName);
106
113
  added.push(mpName);
107
- } catch (error) {
108
- // Non-fatal: log and continue
109
- console.warn(
110
- `⚠ Failed to auto-add marketplace ${mpName}:`,
111
- error instanceof Error ? error.message : "Unknown error",
112
- );
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
+ }
113
125
  }
114
126
  }
115
127
 
@@ -278,9 +290,12 @@ export async function prerunClaude(
278
290
  plugin.version
279
291
  ) {
280
292
  try {
281
- if (cliAvailable) {
282
- await updatePlugin(plugin.id, "user");
293
+ if (!cliAvailable) {
294
+ // CLI unavailable — skip update entirely, don't write phantom state
295
+ continue;
283
296
  }
297
+ await updatePlugin(plugin.id, "user");
298
+ // CLI does NOT update installedPluginVersions — save it ourselves
284
299
  await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
285
300
 
286
301
  autoUpdatedPlugins.push({
@@ -8,10 +8,9 @@ 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";
11
+ import { getAvailablePlugins, refreshAllMarketplaces, clearMarketplaceCache, getLocalMarketplacesInfo, saveInstalledPluginVersion, } from "../../services/plugin-manager.js";
12
12
  import { setMcpEnvVar, getMcpEnvVars, readSettings, saveGlobalInstalledPluginVersion, saveLocalInstalledPluginVersion, } from "../../services/claude-settings.js";
13
13
  import { saveProfile } from "../../services/profiles.js";
14
- import { saveInstalledPluginVersion } from "../../services/plugin-manager.js";
15
14
  import { installPlugin as cliInstallPlugin, uninstallPlugin as cliUninstallPlugin, updatePlugin as cliUpdatePlugin, } from "../../services/claude-cli.js";
16
15
  import { getPluginEnvRequirements, getPluginSourcePath, } from "../../services/plugin-mcp-config.js";
17
16
  import { getPluginSetupFromSource, checkMissingDeps, installPluginDeps, } from "../../services/plugin-setup.js";
@@ -207,6 +206,23 @@ export function PluginsScreen() {
207
206
  else if (event.name === "s")
208
207
  handleSaveAsProfile();
209
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
+ };
210
226
  // ── Action handlers ────────────────────────────────────────────────────────
211
227
  const handleRefresh = async () => {
212
228
  progress.show("Refreshing cache...");
@@ -355,25 +371,6 @@ export function PluginsScreen() {
355
371
  console.error("Error installing plugin deps:", error);
356
372
  }
357
373
  };
358
- /**
359
- * Save installed plugin version to settings after CLI install/update.
360
- */
361
- const saveVersionAfterInstall = async (pluginId, version, scope) => {
362
- try {
363
- if (scope === "user") {
364
- await saveGlobalInstalledPluginVersion(pluginId, version);
365
- }
366
- else if (scope === "local") {
367
- await saveLocalInstalledPluginVersion(pluginId, version, state.projectPath);
368
- }
369
- else {
370
- await saveInstalledPluginVersion(pluginId, version, state.projectPath);
371
- }
372
- }
373
- catch {
374
- // Non-fatal: version display may be stale but plugin still works
375
- }
376
- };
377
374
  const handleSelect = async () => {
378
375
  const item = selectableItems[pluginsState.selectedIndex];
379
376
  if (!item)
@@ -459,11 +456,11 @@ export function PluginsScreen() {
459
456
  }
460
457
  else if (action === "update") {
461
458
  await cliUpdatePlugin(plugin.id, scope);
462
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
459
+ await saveVersionForScope(plugin.id, latestVersion, scope);
463
460
  }
464
461
  else {
465
462
  await cliInstallPlugin(plugin.id, scope);
466
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
463
+ await saveVersionForScope(plugin.id, latestVersion, scope);
467
464
  modal.hideModal();
468
465
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
469
466
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -488,7 +485,9 @@ export function PluginsScreen() {
488
485
  modal.loading(`Updating ${plugin.name}...`);
489
486
  try {
490
487
  await cliUpdatePlugin(plugin.id, scope);
491
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
488
+ if (plugin.version) {
489
+ await saveVersionForScope(plugin.id, plugin.version, scope);
490
+ }
492
491
  modal.hideModal();
493
492
  fetchData();
494
493
  }
@@ -508,7 +507,9 @@ export function PluginsScreen() {
508
507
  try {
509
508
  for (const plugin of updatable) {
510
509
  await cliUpdatePlugin(plugin.id, scope);
511
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
510
+ if (plugin.version) {
511
+ await saveVersionForScope(plugin.id, plugin.version, scope);
512
+ }
512
513
  }
513
514
  modal.hideModal();
514
515
  fetchData();
@@ -558,11 +559,11 @@ export function PluginsScreen() {
558
559
  }
559
560
  else if (action === "update") {
560
561
  await cliUpdatePlugin(plugin.id, scope);
561
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
562
+ await saveVersionForScope(plugin.id, latestVersion, scope);
562
563
  }
563
564
  else {
564
565
  await cliInstallPlugin(plugin.id, scope);
565
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
566
+ await saveVersionForScope(plugin.id, latestVersion, scope);
566
567
  modal.hideModal();
567
568
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
568
569
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -12,6 +12,7 @@ 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 {
@@ -22,7 +23,6 @@ import {
22
23
  saveLocalInstalledPluginVersion,
23
24
  } from "../../services/claude-settings.js";
24
25
  import { saveProfile } from "../../services/profiles.js";
25
- import { saveInstalledPluginVersion } from "../../services/plugin-manager.js";
26
26
  import {
27
27
  installPlugin as cliInstallPlugin,
28
28
  uninstallPlugin as cliUninstallPlugin,
@@ -244,6 +244,27 @@ export function PluginsScreen() {
244
244
  else if (event.name === "s") handleSaveAsProfile();
245
245
  });
246
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
+
247
268
  // ── Action handlers ────────────────────────────────────────────────────────
248
269
 
249
270
  const handleRefresh = async () => {
@@ -452,27 +473,6 @@ export function PluginsScreen() {
452
473
  }
453
474
  };
454
475
 
455
- /**
456
- * Save installed plugin version to settings after CLI install/update.
457
- */
458
- const saveVersionAfterInstall = async (
459
- pluginId: string,
460
- version: string,
461
- scope: PluginScope,
462
- ): Promise<void> => {
463
- try {
464
- if (scope === "user") {
465
- await saveGlobalInstalledPluginVersion(pluginId, version);
466
- } else if (scope === "local") {
467
- await saveLocalInstalledPluginVersion(pluginId, version, state.projectPath);
468
- } else {
469
- await saveInstalledPluginVersion(pluginId, version, state.projectPath);
470
- }
471
- } catch {
472
- // Non-fatal: version display may be stale but plugin still works
473
- }
474
- };
475
-
476
476
  const handleSelect = async () => {
477
477
  const item = selectableItems[pluginsState.selectedIndex];
478
478
  if (!item) return;
@@ -573,10 +573,10 @@ export function PluginsScreen() {
573
573
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
574
574
  } else if (action === "update") {
575
575
  await cliUpdatePlugin(plugin.id, scope);
576
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
576
+ await saveVersionForScope(plugin.id, latestVersion, scope);
577
577
  } else {
578
578
  await cliInstallPlugin(plugin.id, scope);
579
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
579
+ await saveVersionForScope(plugin.id, latestVersion, scope);
580
580
  modal.hideModal();
581
581
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
582
582
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -602,7 +602,9 @@ export function PluginsScreen() {
602
602
  modal.loading(`Updating ${plugin.name}...`);
603
603
  try {
604
604
  await cliUpdatePlugin(plugin.id, scope);
605
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
605
+ if (plugin.version) {
606
+ await saveVersionForScope(plugin.id, plugin.version, scope);
607
+ }
606
608
  modal.hideModal();
607
609
  fetchData();
608
610
  } catch (error) {
@@ -623,7 +625,9 @@ export function PluginsScreen() {
623
625
  try {
624
626
  for (const plugin of updatable) {
625
627
  await cliUpdatePlugin(plugin.id, scope);
626
- await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
628
+ if (plugin.version) {
629
+ await saveVersionForScope(plugin.id, plugin.version, scope);
630
+ }
627
631
  }
628
632
  modal.hideModal();
629
633
  fetchData();
@@ -679,10 +683,10 @@ export function PluginsScreen() {
679
683
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
680
684
  } else if (action === "update") {
681
685
  await cliUpdatePlugin(plugin.id, scope);
682
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
686
+ await saveVersionForScope(plugin.id, latestVersion, scope);
683
687
  } else {
684
688
  await cliInstallPlugin(plugin.id, scope);
685
- await saveVersionAfterInstall(plugin.id, latestVersion, scope);
689
+ await saveVersionForScope(plugin.id, latestVersion, scope);
686
690
  modal.hideModal();
687
691
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
688
692
  await installPluginSystemDeps(plugin.name, plugin.marketplace);