claudeup 4.7.0 → 4.8.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.7.0",
3
+ "version": "4.8.0",
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,382 @@
1
+ /**
2
+ * Tests for gapFillInstalledPluginVersions()
3
+ *
4
+ * This function ensures global installedPluginVersions is at least as high as
5
+ * the highest version found across project and local scopes. When a plugin is
6
+ * updated at project scope (e.g. by `claude plugin marketplace update` in a
7
+ * project directory), global may lag behind, causing Claude Code to resolve
8
+ * stale cache paths.
9
+ *
10
+ * Each test uses a real temp filesystem with mocked os.homedir() to exercise
11
+ * the actual read/write paths in claude-settings.ts.
12
+ */
13
+
14
+ import {
15
+ describe,
16
+ it,
17
+ expect,
18
+ beforeEach,
19
+ afterEach,
20
+ afterAll,
21
+ mock,
22
+ } from "bun:test";
23
+ import fs from "fs-extra";
24
+ import path from "node:path";
25
+
26
+ // We need real os functions (tmpdir, etc.) but must mock homedir().
27
+ // Since mock.module is hoisted and replaces the module for ALL consumers,
28
+ // we grab the real os module via require before the mock takes effect.
29
+ const realOs = await import("node:os");
30
+ const realTmpdir = realOs.tmpdir();
31
+ const realHomedir = realOs.homedir();
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Temp directory helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ // Initialize tmpHome eagerly so module-level os.homedir() calls in
38
+ // claude-settings.ts (for INSTALLED_PLUGINS_FILE, KNOWN_MARKETPLACES_FILE)
39
+ // get a valid path during import. The mock closure reads this variable.
40
+ const initTmpHome = fs.mkdtempSync(path.join(realTmpdir, "gfill-init-"));
41
+ let tmpHome: string = initTmpHome;
42
+ let tmpProject: string;
43
+
44
+ /** Write a global settings file at <tmpHome>/.claude/settings.json */
45
+ async function writeGlobalSettings(settings: Record<string, unknown>) {
46
+ const dir = path.join(tmpHome, ".claude");
47
+ await fs.ensureDir(dir);
48
+ await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
49
+ }
50
+
51
+ /** Read back global settings after function execution */
52
+ async function readGlobalSettingsFromDisk(): Promise<Record<string, unknown>> {
53
+ const p = path.join(tmpHome, ".claude", "settings.json");
54
+ if (await fs.pathExists(p)) {
55
+ return fs.readJson(p);
56
+ }
57
+ return {};
58
+ }
59
+
60
+ /** Write project settings at <tmpProject>/.claude/settings.json */
61
+ async function writeProjectSettings(settings: Record<string, unknown>) {
62
+ const dir = path.join(tmpProject, ".claude");
63
+ await fs.ensureDir(dir);
64
+ await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
65
+ }
66
+
67
+ /** Write local settings at <tmpProject>/.claude/settings.local.json */
68
+ async function writeLocalSettings(settings: Record<string, unknown>) {
69
+ const dir = path.join(tmpProject, ".claude");
70
+ await fs.ensureDir(dir);
71
+ await fs.writeJson(path.join(dir, "settings.local.json"), settings, {
72
+ spaces: 2,
73
+ });
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Mock os.homedir() so getGlobalClaudeDir() points to our temp dir.
78
+ //
79
+ // Bun hoists mock.module() calls, so this must come before the import of the
80
+ // module under test.
81
+ // ---------------------------------------------------------------------------
82
+
83
+ mock.module("node:os", () => {
84
+ // Build a proxy that preserves all real os functions but overrides homedir
85
+ const mocked = { ...realOs, homedir: () => tmpHome };
86
+ return {
87
+ ...mocked,
88
+ default: mocked,
89
+ };
90
+ });
91
+
92
+ // Import function under test AFTER mock registration
93
+ const { gapFillInstalledPluginVersions } = await import(
94
+ "../services/claude-settings.js"
95
+ );
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Test suite
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe("gapFillInstalledPluginVersions", () => {
102
+ beforeEach(async () => {
103
+ // Create isolated temp dirs for each test
104
+ tmpHome = await fs.mkdtemp(path.join(realTmpdir, "gfill-home-"));
105
+ tmpProject = await fs.mkdtemp(path.join(realTmpdir, "gfill-proj-"));
106
+ // Ensure the global .claude dir exists
107
+ await fs.ensureDir(path.join(tmpHome, ".claude"));
108
+ });
109
+
110
+ afterEach(async () => {
111
+ await fs.remove(tmpHome);
112
+ await fs.remove(tmpProject);
113
+ });
114
+
115
+ afterAll(async () => {
116
+ // Clean up the initial temp home created for module-level evaluation
117
+ await fs.remove(initTmpHome).catch(() => {});
118
+ });
119
+
120
+ // Scenario 1: "The kanban bug" — project has newer version, global is stale
121
+ it("bumps global when project has a newer version (the kanban bug)", async () => {
122
+ await writeGlobalSettings({
123
+ installedPluginVersions: { "kanban@magus": "1.0.0" },
124
+ });
125
+ await writeProjectSettings({
126
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
127
+ });
128
+
129
+ const result = await gapFillInstalledPluginVersions(tmpProject);
130
+
131
+ expect(result).toEqual([
132
+ {
133
+ pluginId: "kanban@magus",
134
+ oldVersion: "1.0.0",
135
+ newVersion: "1.3.0",
136
+ },
137
+ ]);
138
+
139
+ // Verify the file was actually updated
140
+ const global = await readGlobalSettingsFromDisk();
141
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
142
+ "1.3.0",
143
+ );
144
+ });
145
+
146
+ // Scenario 2: Global is already current — no changes needed
147
+ it("returns empty array when global is already at the highest version", async () => {
148
+ await writeGlobalSettings({
149
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
150
+ });
151
+ await writeProjectSettings({
152
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
153
+ });
154
+
155
+ const result = await gapFillInstalledPluginVersions(tmpProject);
156
+
157
+ expect(result).toEqual([]);
158
+
159
+ // Verify global was NOT rewritten (check original content is intact)
160
+ const global = await readGlobalSettingsFromDisk();
161
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
162
+ "1.3.0",
163
+ );
164
+ });
165
+
166
+ // Scenario 3: Multiple plugins with mixed staleness
167
+ it("handles multiple plugins with mixed staleness correctly", async () => {
168
+ await writeGlobalSettings({
169
+ installedPluginVersions: {
170
+ "a@mp": "1.0.0",
171
+ "b@mp": "2.0.0",
172
+ },
173
+ });
174
+ await writeProjectSettings({
175
+ installedPluginVersions: {
176
+ "a@mp": "2.0.0",
177
+ "b@mp": "2.0.0",
178
+ "c@mp": "1.0.0",
179
+ },
180
+ });
181
+
182
+ const result = await gapFillInstalledPluginVersions(tmpProject);
183
+
184
+ // a@mp should be bumped, b@mp unchanged, c@mp added (missing from global)
185
+ const bumped = result.map((r) => r.pluginId).sort();
186
+ expect(bumped).toEqual(["a@mp", "c@mp"]);
187
+
188
+ const aEntry = result.find((r) => r.pluginId === "a@mp");
189
+ expect(aEntry).toEqual({
190
+ pluginId: "a@mp",
191
+ oldVersion: "1.0.0",
192
+ newVersion: "2.0.0",
193
+ });
194
+
195
+ const cEntry = result.find((r) => r.pluginId === "c@mp");
196
+ expect(cEntry).toEqual({
197
+ pluginId: "c@mp",
198
+ oldVersion: "(missing)",
199
+ newVersion: "1.0.0",
200
+ });
201
+
202
+ // Verify file state
203
+ const global = await readGlobalSettingsFromDisk();
204
+ const versions = (global as any).installedPluginVersions;
205
+ expect(versions["a@mp"]).toBe("2.0.0");
206
+ expect(versions["b@mp"]).toBe("2.0.0");
207
+ expect(versions["c@mp"]).toBe("1.0.0");
208
+ });
209
+
210
+ // Scenario 4: Local scope has the highest version
211
+ it("uses the highest version across all 3 scopes (local wins)", async () => {
212
+ await writeGlobalSettings({
213
+ installedPluginVersions: { "x@mp": "1.0.0" },
214
+ });
215
+ await writeProjectSettings({
216
+ installedPluginVersions: { "x@mp": "1.5.0" },
217
+ });
218
+ await writeLocalSettings({
219
+ installedPluginVersions: { "x@mp": "2.0.0" },
220
+ });
221
+
222
+ const result = await gapFillInstalledPluginVersions(tmpProject);
223
+
224
+ expect(result).toEqual([
225
+ {
226
+ pluginId: "x@mp",
227
+ oldVersion: "1.0.0",
228
+ newVersion: "2.0.0",
229
+ },
230
+ ]);
231
+
232
+ const global = await readGlobalSettingsFromDisk();
233
+ expect((global as any).installedPluginVersions["x@mp"]).toBe("2.0.0");
234
+ });
235
+
236
+ // Scenario 5: No project path — only global scope
237
+ it("returns empty array when called with no projectPath", async () => {
238
+ await writeGlobalSettings({
239
+ installedPluginVersions: { "a@mp": "1.0.0" },
240
+ });
241
+
242
+ const result = await gapFillInstalledPluginVersions();
243
+
244
+ expect(result).toEqual([]);
245
+ });
246
+
247
+ // Scenario 6: Idempotency — running twice is a no-op
248
+ it("is idempotent — second call returns empty array", async () => {
249
+ await writeGlobalSettings({
250
+ installedPluginVersions: { "a@mp": "1.0.0" },
251
+ });
252
+ await writeProjectSettings({
253
+ installedPluginVersions: { "a@mp": "2.0.0" },
254
+ });
255
+
256
+ // First call: should bump
257
+ const first = await gapFillInstalledPluginVersions(tmpProject);
258
+ expect(first).toHaveLength(1);
259
+ expect(first[0].newVersion).toBe("2.0.0");
260
+
261
+ // Second call: global is now at 2.0.0, so nothing to do
262
+ const second = await gapFillInstalledPluginVersions(tmpProject);
263
+ expect(second).toEqual([]);
264
+ });
265
+
266
+ // Scenario 7: Invalid semver versions are skipped
267
+ it("skips invalid semver versions in project scope", async () => {
268
+ await writeGlobalSettings({
269
+ installedPluginVersions: { "a@mp": "1.0.0" },
270
+ });
271
+ await writeProjectSettings({
272
+ installedPluginVersions: { "a@mp": "not-a-version" },
273
+ });
274
+
275
+ const result = await gapFillInstalledPluginVersions(tmpProject);
276
+
277
+ expect(result).toEqual([]);
278
+
279
+ // Global should remain unchanged
280
+ const global = await readGlobalSettingsFromDisk();
281
+ expect((global as any).installedPluginVersions["a@mp"]).toBe("1.0.0");
282
+ });
283
+
284
+ // Scenario 8: Project settings file doesn't exist (new project)
285
+ it("returns empty array when project settings file does not exist", async () => {
286
+ await writeGlobalSettings({
287
+ installedPluginVersions: { "a@mp": "1.0.0" },
288
+ });
289
+
290
+ // Use a path that doesn't have any .claude directory
291
+ const nonExistentProject = path.join(tmpProject, "no-such-project");
292
+
293
+ const result = await gapFillInstalledPluginVersions(nonExistentProject);
294
+
295
+ expect(result).toEqual([]);
296
+ });
297
+
298
+ // Scenario 9: Global version is higher than project — no downgrade
299
+ it("does not downgrade global when global is higher than project", async () => {
300
+ await writeGlobalSettings({
301
+ installedPluginVersions: { "kanban@magus": "2.0.0" },
302
+ });
303
+ await writeProjectSettings({
304
+ installedPluginVersions: { "kanban@magus": "1.0.0" },
305
+ });
306
+
307
+ const result = await gapFillInstalledPluginVersions(tmpProject);
308
+
309
+ expect(result).toEqual([]);
310
+
311
+ // Verify global was NOT downgraded
312
+ const global = await readGlobalSettingsFromDisk();
313
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe("2.0.0");
314
+ });
315
+
316
+ // Scenario 10: Preserves unrelated settings fields during write
317
+ it("preserves other global settings fields when bumping versions", async () => {
318
+ await writeGlobalSettings({
319
+ enabledPlugins: { "kanban@magus": true, "dev@magus": true },
320
+ env: { SOME_VAR: "value" },
321
+ installedPluginVersions: { "kanban@magus": "1.0.0" },
322
+ });
323
+ await writeProjectSettings({
324
+ installedPluginVersions: { "kanban@magus": "2.0.0" },
325
+ });
326
+
327
+ const result = await gapFillInstalledPluginVersions(tmpProject);
328
+
329
+ expect(result).toHaveLength(1);
330
+
331
+ // Verify other fields are preserved
332
+ const global = await readGlobalSettingsFromDisk();
333
+ expect((global as any).enabledPlugins).toEqual({ "kanban@magus": true, "dev@magus": true });
334
+ expect((global as any).env).toEqual({ SOME_VAR: "value" });
335
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe("2.0.0");
336
+ });
337
+
338
+ // Scenario 11: Invalid semver in GLOBAL scope gets overwritten
339
+ it("overwrites corrupted global version when project has valid semver", async () => {
340
+ await writeGlobalSettings({
341
+ installedPluginVersions: { "kanban@magus": "not-a-version" },
342
+ });
343
+ await writeProjectSettings({
344
+ installedPluginVersions: { "kanban@magus": "1.3.0" },
345
+ });
346
+
347
+ const result = await gapFillInstalledPluginVersions(tmpProject);
348
+
349
+ expect(result).toEqual([
350
+ {
351
+ pluginId: "kanban@magus",
352
+ oldVersion: "not-a-version",
353
+ newVersion: "1.3.0",
354
+ },
355
+ ]);
356
+
357
+ const global = await readGlobalSettingsFromDisk();
358
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
359
+ "1.3.0",
360
+ );
361
+ });
362
+
363
+ // Scenario 12: Both global and project have invalid semver — no crash, no change
364
+ it("does nothing when all versions are invalid semver", async () => {
365
+ await writeGlobalSettings({
366
+ installedPluginVersions: { "kanban@magus": "broken" },
367
+ });
368
+ await writeProjectSettings({
369
+ installedPluginVersions: { "kanban@magus": "also-broken" },
370
+ });
371
+
372
+ const result = await gapFillInstalledPluginVersions(tmpProject);
373
+
374
+ expect(result).toEqual([]);
375
+
376
+ // Global unchanged
377
+ const global = await readGlobalSettingsFromDisk();
378
+ expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
379
+ "broken",
380
+ );
381
+ });
382
+ });
@@ -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, cleanupExtraKnownMarketplaces, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, saveGlobalInstalledPluginVersion, } from "../services/claude-settings.js";
7
+ import { recoverMarketplaceSettings, migrateMarketplaceRename, cleanupExtraKnownMarketplaces, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, saveGlobalInstalledPluginVersion, gapFillInstalledPluginVersions, } 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, updateMarketplace, isClaudeAvailable, } from "../services/claude-cli.js";
10
+ import { updatePlugin, addMarketplace, 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.
@@ -56,10 +56,16 @@ async function getReferencedMarketplaces(projectPath) {
56
56
  * Check which referenced marketplaces are missing locally and auto-add them.
57
57
  * Only adds marketplaces with known repos (from defaultMarketplaces).
58
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.
59
+ * IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
60
+ * Claude Code's `marketplace update` calls cacheMarketplaceFromGit() which
61
+ * deletes the marketplace directory before re-cloning. If the clone fails
62
+ * (network timeout, auth error), the directory stays permanently deleted
63
+ * and ALL plugins from that marketplace break. See:
64
+ * ai-docs/plugin-marketplace-bug-investigation.md
65
+ *
66
+ * Claude Code's own background autoupdate handles marketplace refreshing
67
+ * after session start — claudeup should only recover genuinely missing
68
+ * marketplaces, not trigger additional refresh cycles.
63
69
  */
64
70
  async function autoAddMissingMarketplaces(projectPath) {
65
71
  const referenced = await getReferencedMarketplaces(projectPath);
@@ -74,19 +80,11 @@ async function autoAddMissingMarketplaces(projectPath) {
74
80
  if (!defaultMp?.source.repo)
75
81
  continue;
76
82
  try {
77
- // Try `marketplace update` first — it re-clones missing directories
78
- await updateMarketplace(mpName);
83
+ await addMarketplace(defaultMp.source.repo);
79
84
  added.push(mpName);
80
85
  }
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
- }
86
+ catch (error) {
87
+ console.warn(`⚠ Failed to auto-add marketplace ${mpName}:`, error instanceof Error ? error.message : "Unknown error");
90
88
  }
91
89
  }
92
90
  return added;
@@ -186,6 +184,22 @@ export async function prerunClaude(claudeArgs, options = {}) {
186
184
  if (addedHooks) {
187
185
  console.log(`✓ Added tmux-claude-continuity hooks to ~/.claude/settings.json`);
188
186
  }
187
+ // STEP 0.7: Gap-fill installedPluginVersions across scopes
188
+ // When a plugin is updated at project/local scope, global may lag behind.
189
+ // Sync the highest version from any scope up to global so Claude Code
190
+ // resolves the latest cached plugin paths at session startup.
191
+ try {
192
+ const gapFilled = await gapFillInstalledPluginVersions(process.cwd());
193
+ if (gapFilled.length > 0) {
194
+ console.log(`✓ Synced ${gapFilled.length} plugin version(s) to global:`);
195
+ for (const { pluginId, oldVersion, newVersion } of gapFilled) {
196
+ console.log(` - ${pluginId}: ${oldVersion} → ${newVersion}`);
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ // Non-fatal: gap-fill is best-effort
202
+ }
189
203
  // STEP 1: Check if we should update (time-based cache, or forced)
190
204
  const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
191
205
  if (options.force) {
@@ -17,13 +17,13 @@ import {
17
17
  readGlobalSettings,
18
18
  writeGlobalSettings,
19
19
  saveGlobalInstalledPluginVersion,
20
+ gapFillInstalledPluginVersions,
20
21
  } from "../services/claude-settings.js";
21
22
  import { parsePluginId } from "../utils/string-utils.js";
22
23
  import { defaultMarketplaces } from "../data/marketplaces.js";
23
24
  import {
24
25
  updatePlugin,
25
26
  addMarketplace,
26
- updateMarketplace,
27
27
  isClaudeAvailable,
28
28
  } from "../services/claude-cli.js";
29
29
 
@@ -88,10 +88,16 @@ async function getReferencedMarketplaces(
88
88
  * Check which referenced marketplaces are missing locally and auto-add them.
89
89
  * Only adds marketplaces with known repos (from defaultMarketplaces).
90
90
  *
91
- * Uses `marketplace update` (not `marketplace add`) for recovery because
92
- * `add` short-circuits when the marketplace is already declared in settings
93
- * even if the directory was deleted. `update` detects the missing dir and
94
- * re-clones automatically.
91
+ * IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
92
+ * Claude Code's `marketplace update` calls cacheMarketplaceFromGit() which
93
+ * deletes the marketplace directory before re-cloning. If the clone fails
94
+ * (network timeout, auth error), the directory stays permanently deleted
95
+ * and ALL plugins from that marketplace break. See:
96
+ * ai-docs/plugin-marketplace-bug-investigation.md
97
+ *
98
+ * Claude Code's own background autoupdate handles marketplace refreshing
99
+ * after session start — claudeup should only recover genuinely missing
100
+ * marketplaces, not trigger additional refresh cycles.
95
101
  */
96
102
  async function autoAddMissingMarketplaces(
97
103
  projectPath?: string,
@@ -109,20 +115,13 @@ async function autoAddMissingMarketplaces(
109
115
  if (!defaultMp?.source.repo) continue;
110
116
 
111
117
  try {
112
- // Try `marketplace update` first — it re-clones missing directories
113
- await updateMarketplace(mpName);
118
+ await addMarketplace(defaultMp.source.repo);
114
119
  added.push(mpName);
115
- } catch {
116
- // If update fails (marketplace not in settings yet), try add
117
- try {
118
- await addMarketplace(defaultMp.source.repo);
119
- added.push(mpName);
120
- } catch (error) {
121
- console.warn(
122
- `⚠ Failed to auto-add marketplace ${mpName}:`,
123
- error instanceof Error ? error.message : "Unknown error",
124
- );
125
- }
120
+ } catch (error) {
121
+ console.warn(
122
+ `⚠ Failed to auto-add marketplace ${mpName}:`,
123
+ error instanceof Error ? error.message : "Unknown error",
124
+ );
126
125
  }
127
126
  }
128
127
 
@@ -256,6 +255,24 @@ export async function prerunClaude(
256
255
  );
257
256
  }
258
257
 
258
+ // STEP 0.7: Gap-fill installedPluginVersions across scopes
259
+ // When a plugin is updated at project/local scope, global may lag behind.
260
+ // Sync the highest version from any scope up to global so Claude Code
261
+ // resolves the latest cached plugin paths at session startup.
262
+ try {
263
+ const gapFilled = await gapFillInstalledPluginVersions(process.cwd());
264
+ if (gapFilled.length > 0) {
265
+ console.log(
266
+ `✓ Synced ${gapFilled.length} plugin version(s) to global:`,
267
+ );
268
+ for (const { pluginId, oldVersion, newVersion } of gapFilled) {
269
+ console.log(` - ${pluginId}: ${oldVersion} → ${newVersion}`);
270
+ }
271
+ }
272
+ } catch {
273
+ // Non-fatal: gap-fill is best-effort
274
+ }
275
+
259
276
  // STEP 1: Check if we should update (time-based cache, or forced)
260
277
  const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
261
278
 
@@ -1,6 +1,7 @@
1
1
  import fs from "fs-extra";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import semver from "semver";
4
5
  import { parsePluginId } from "../utils/string-utils.js";
5
6
  const CLAUDE_DIR = ".claude";
6
7
  const SETTINGS_FILE = "settings.json";
@@ -377,6 +378,79 @@ export async function saveGlobalInstalledPluginVersion(pluginId, version) {
377
378
  // Update registry for user scope
378
379
  await updateInstalledPluginsRegistry(pluginId, version, "user");
379
380
  }
381
+ /**
382
+ * Gap-fill: ensure global installedPluginVersions is at least as high as
383
+ * project and local scopes. When a plugin is updated at project scope,
384
+ * global may lag behind, causing Claude Code to resolve stale cache paths.
385
+ *
386
+ * @returns Array of { pluginId, oldVersion, newVersion } for plugins that were bumped
387
+ */
388
+ export async function gapFillInstalledPluginVersions(projectPath) {
389
+ const globalVersions = await getGlobalInstalledPluginVersions();
390
+ // Collect versions from project and local scopes
391
+ const allVersions = {};
392
+ // Start with global versions
393
+ for (const [id, ver] of Object.entries(globalVersions)) {
394
+ allVersions[id] = [ver];
395
+ }
396
+ // Add project scope versions
397
+ if (projectPath) {
398
+ try {
399
+ const settings = await readSettings(projectPath);
400
+ for (const [id, ver] of Object.entries(settings.installedPluginVersions || {})) {
401
+ if (!allVersions[id])
402
+ allVersions[id] = [];
403
+ allVersions[id].push(ver);
404
+ }
405
+ }
406
+ catch {
407
+ /* skip unreadable */
408
+ }
409
+ // Add local scope versions
410
+ try {
411
+ const localSettings = await readLocalSettings(projectPath);
412
+ for (const [id, ver] of Object.entries(localSettings.installedPluginVersions || {})) {
413
+ if (!allVersions[id])
414
+ allVersions[id] = [];
415
+ allVersions[id].push(ver);
416
+ }
417
+ }
418
+ catch {
419
+ /* skip unreadable */
420
+ }
421
+ }
422
+ // Find plugins where global needs bumping
423
+ const bumped = [];
424
+ for (const [pluginId, versions] of Object.entries(allVersions)) {
425
+ // Filter to valid semver versions and sort descending
426
+ const valid = versions.filter((v) => semver.valid(v));
427
+ if (valid.length === 0)
428
+ continue;
429
+ const highest = valid.sort((a, b) => semver.rcompare(a, b))[0];
430
+ const globalVer = globalVersions[pluginId];
431
+ // If global is missing, invalid semver, or lower than the highest, bump it
432
+ if (!globalVer ||
433
+ !semver.valid(globalVer) ||
434
+ semver.lt(globalVer, highest)) {
435
+ bumped.push({
436
+ pluginId,
437
+ oldVersion: globalVer || "(missing)",
438
+ newVersion: highest,
439
+ });
440
+ }
441
+ }
442
+ // Apply bumps in a single write
443
+ if (bumped.length > 0) {
444
+ const settings = await readGlobalSettings();
445
+ settings.installedPluginVersions =
446
+ settings.installedPluginVersions || {};
447
+ for (const { pluginId, newVersion } of bumped) {
448
+ settings.installedPluginVersions[pluginId] = newVersion;
449
+ }
450
+ await writeGlobalSettings(settings);
451
+ }
452
+ return bumped;
453
+ }
380
454
  export async function removeGlobalInstalledPluginVersion(pluginId) {
381
455
  const settings = await readGlobalSettings();
382
456
  if (settings.installedPluginVersions) {
@@ -1,6 +1,7 @@
1
1
  import fs from "fs-extra";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import semver from "semver";
4
5
  import type {
5
6
  ClaudeSettings,
6
7
  ClaudeLocalSettings,
@@ -544,6 +545,97 @@ export async function saveGlobalInstalledPluginVersion(
544
545
  await updateInstalledPluginsRegistry(pluginId, version, "user");
545
546
  }
546
547
 
548
+ /**
549
+ * Gap-fill: ensure global installedPluginVersions is at least as high as
550
+ * project and local scopes. When a plugin is updated at project scope,
551
+ * global may lag behind, causing Claude Code to resolve stale cache paths.
552
+ *
553
+ * @returns Array of { pluginId, oldVersion, newVersion } for plugins that were bumped
554
+ */
555
+ export async function gapFillInstalledPluginVersions(
556
+ projectPath?: string,
557
+ ): Promise<Array<{ pluginId: string; oldVersion: string; newVersion: string }>> {
558
+ const globalVersions = await getGlobalInstalledPluginVersions();
559
+
560
+ // Collect versions from project and local scopes
561
+ const allVersions: Record<string, string[]> = {};
562
+
563
+ // Start with global versions
564
+ for (const [id, ver] of Object.entries(globalVersions)) {
565
+ allVersions[id] = [ver];
566
+ }
567
+
568
+ // Add project scope versions
569
+ if (projectPath) {
570
+ try {
571
+ const settings = await readSettings(projectPath);
572
+ for (const [id, ver] of Object.entries(
573
+ settings.installedPluginVersions || {},
574
+ )) {
575
+ if (!allVersions[id]) allVersions[id] = [];
576
+ allVersions[id].push(ver);
577
+ }
578
+ } catch {
579
+ /* skip unreadable */
580
+ }
581
+
582
+ // Add local scope versions
583
+ try {
584
+ const localSettings = await readLocalSettings(projectPath);
585
+ for (const [id, ver] of Object.entries(
586
+ localSettings.installedPluginVersions || {},
587
+ )) {
588
+ if (!allVersions[id]) allVersions[id] = [];
589
+ allVersions[id].push(ver);
590
+ }
591
+ } catch {
592
+ /* skip unreadable */
593
+ }
594
+ }
595
+
596
+ // Find plugins where global needs bumping
597
+ const bumped: Array<{
598
+ pluginId: string;
599
+ oldVersion: string;
600
+ newVersion: string;
601
+ }> = [];
602
+
603
+ for (const [pluginId, versions] of Object.entries(allVersions)) {
604
+ // Filter to valid semver versions and sort descending
605
+ const valid = versions.filter((v) => semver.valid(v));
606
+ if (valid.length === 0) continue;
607
+
608
+ const highest = valid.sort((a, b) => semver.rcompare(a, b))[0];
609
+ const globalVer = globalVersions[pluginId];
610
+
611
+ // If global is missing, invalid semver, or lower than the highest, bump it
612
+ if (
613
+ !globalVer ||
614
+ !semver.valid(globalVer) ||
615
+ semver.lt(globalVer, highest)
616
+ ) {
617
+ bumped.push({
618
+ pluginId,
619
+ oldVersion: globalVer || "(missing)",
620
+ newVersion: highest,
621
+ });
622
+ }
623
+ }
624
+
625
+ // Apply bumps in a single write
626
+ if (bumped.length > 0) {
627
+ const settings = await readGlobalSettings();
628
+ settings.installedPluginVersions =
629
+ settings.installedPluginVersions || {};
630
+ for (const { pluginId, newVersion } of bumped) {
631
+ settings.installedPluginVersions[pluginId] = newVersion;
632
+ }
633
+ await writeGlobalSettings(settings);
634
+ }
635
+
636
+ return bumped;
637
+ }
638
+
547
639
  export async function removeGlobalInstalledPluginVersion(
548
640
  pluginId: string,
549
641
  ): Promise<void> {
@@ -5,7 +5,6 @@ import { getConfiguredMarketplaces, getEnabledPlugins, readSettings, writeSettin
5
5
  import { defaultMarketplaces } from "../data/marketplaces.js";
6
6
  import { scanLocalMarketplaces, repairAllMarketplaces, } from "./local-marketplace.js";
7
7
  import { formatMarketplaceName, isValidGitHubRepo, parsePluginId, } from "../utils/string-utils.js";
8
- import { updateMarketplace } from "./claude-cli.js";
9
8
  // Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
10
9
  let localMarketplacesPromise = null;
11
10
  // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
@@ -434,9 +433,14 @@ export async function getLocalMarketplacesInfo() {
434
433
  const autoSyncedMarketplaces = new Set();
435
434
  /**
436
435
  * If the remote manifest lists plugins that aren't in the local cache,
437
- * the local clone is stale. Silently run `claude plugin marketplace update`
438
- * to pull the latest, then invalidate the local cache so the next scan
439
- * picks up the new plugins.
436
+ * the local clone is stale. Log a warning but do NOT trigger a marketplace
437
+ * update from claudeup.
438
+ *
439
+ * Previously this called `updateMarketplace()` which invokes Claude Code's
440
+ * `cacheMarketplaceFromGit()` — a non-atomic delete-then-clone that can
441
+ * permanently delete the marketplace directory on clone failure.
442
+ * Claude Code's own background autoupdate handles marketplace refreshing
443
+ * after session start. See: ai-docs/plugin-marketplace-bug-investigation.md
440
444
  */
441
445
  async function autoSyncIfStale(mpName, remotePluginNames, localMarketplaces) {
442
446
  if (autoSyncedMarketplaces.has(mpName))
@@ -445,20 +449,13 @@ async function autoSyncIfStale(mpName, remotePluginNames, localMarketplaces) {
445
449
  if (!localMp)
446
450
  return localMarketplaces;
447
451
  const localNames = new Set(localMp.plugins.map((p) => p.name));
448
- const hasMissing = remotePluginNames.some((name) => !localNames.has(name));
449
- if (!hasMissing)
452
+ const missingPlugins = remotePluginNames.filter((name) => !localNames.has(name));
453
+ if (missingPlugins.length === 0)
450
454
  return localMarketplaces;
451
455
  autoSyncedMarketplaces.add(mpName);
452
- try {
453
- await updateMarketplace(mpName);
454
- // Invalidate local cache so re-scan picks up new plugins
455
- localMarketplacesPromise = null;
456
- return getLocalMarketplaces();
457
- }
458
- catch {
459
- // Update failed (no network, CLI missing, etc.) — continue with stale data
460
- return localMarketplaces;
461
- }
456
+ // Log stale state but don't trigger update — Claude Code handles refresh
457
+ console.log(`ℹ Marketplace ${mpName} is stale (missing: ${missingPlugins.join(", ")}). Claude Code will auto-update on next session start.`);
458
+ return localMarketplaces;
462
459
  }
463
460
  /**
464
461
  * Refresh claudeup's internal cache
@@ -27,8 +27,6 @@ import {
27
27
  isValidGitHubRepo,
28
28
  parsePluginId,
29
29
  } from "../utils/string-utils.js";
30
- import { updateMarketplace } from "./claude-cli.js";
31
-
32
30
  // Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
33
31
  let localMarketplacesPromise: Promise<Map<string, LocalMarketplace>> | null =
34
32
  null;
@@ -619,9 +617,14 @@ const autoSyncedMarketplaces = new Set<string>();
619
617
 
620
618
  /**
621
619
  * If the remote manifest lists plugins that aren't in the local cache,
622
- * the local clone is stale. Silently run `claude plugin marketplace update`
623
- * to pull the latest, then invalidate the local cache so the next scan
624
- * picks up the new plugins.
620
+ * the local clone is stale. Log a warning but do NOT trigger a marketplace
621
+ * update from claudeup.
622
+ *
623
+ * Previously this called `updateMarketplace()` which invokes Claude Code's
624
+ * `cacheMarketplaceFromGit()` — a non-atomic delete-then-clone that can
625
+ * permanently delete the marketplace directory on clone failure.
626
+ * Claude Code's own background autoupdate handles marketplace refreshing
627
+ * after session start. See: ai-docs/plugin-marketplace-bug-investigation.md
625
628
  */
626
629
  async function autoSyncIfStale(
627
630
  mpName: string,
@@ -634,19 +637,17 @@ async function autoSyncIfStale(
634
637
  if (!localMp) return localMarketplaces;
635
638
 
636
639
  const localNames = new Set(localMp.plugins.map((p) => p.name));
637
- const hasMissing = remotePluginNames.some((name) => !localNames.has(name));
638
- if (!hasMissing) return localMarketplaces;
640
+ const missingPlugins = remotePluginNames.filter(
641
+ (name) => !localNames.has(name),
642
+ );
643
+ if (missingPlugins.length === 0) return localMarketplaces;
639
644
 
640
645
  autoSyncedMarketplaces.add(mpName);
641
- try {
642
- await updateMarketplace(mpName);
643
- // Invalidate local cache so re-scan picks up new plugins
644
- localMarketplacesPromise = null;
645
- return getLocalMarketplaces();
646
- } catch {
647
- // Update failed (no network, CLI missing, etc.) — continue with stale data
648
- return localMarketplaces;
649
- }
646
+ // Log stale state but don't trigger update — Claude Code handles refresh
647
+ console.log(
648
+ `ℹ Marketplace ${mpName} is stale (missing: ${missingPlugins.join(", ")}). Claude Code will auto-update on next session start.`,
649
+ );
650
+ return localMarketplaces;
650
651
  }
651
652
 
652
653
  export interface RefreshAndRepairResult {