claudeup 4.6.1 → 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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/gap-fill-versions.test.ts +382 -0
  3. package/src/data/settings-catalog.js +2 -7
  4. package/src/data/settings-catalog.ts +2 -7
  5. package/src/opentui.d.ts +7 -2
  6. package/src/prerunner/index.js +31 -17
  7. package/src/prerunner/index.ts +35 -18
  8. package/src/services/claude-settings.js +74 -0
  9. package/src/services/claude-settings.ts +92 -0
  10. package/src/services/plugin-manager.js +13 -16
  11. package/src/services/plugin-manager.ts +17 -16
  12. package/src/services/settings-manager.js +84 -5
  13. package/src/services/settings-manager.ts +86 -5
  14. package/src/ui/adapters/settingsAdapter.js +8 -8
  15. package/src/ui/adapters/settingsAdapter.ts +8 -8
  16. package/src/ui/components/TabBar.js +1 -23
  17. package/src/ui/components/TabBar.tsx +1 -26
  18. package/src/ui/components/modals/ConfirmModal.js +1 -1
  19. package/src/ui/components/modals/ConfirmModal.tsx +17 -16
  20. package/src/ui/components/modals/InputModal.js +2 -13
  21. package/src/ui/components/modals/InputModal.tsx +21 -24
  22. package/src/ui/components/modals/LoadingModal.js +1 -1
  23. package/src/ui/components/modals/LoadingModal.tsx +6 -6
  24. package/src/ui/components/modals/MessageModal.js +4 -4
  25. package/src/ui/components/modals/MessageModal.tsx +13 -13
  26. package/src/ui/components/modals/ModalContainer.js +25 -2
  27. package/src/ui/components/modals/ModalContainer.tsx +25 -2
  28. package/src/ui/components/modals/SelectModal.js +3 -4
  29. package/src/ui/components/modals/SelectModal.tsx +18 -15
  30. package/src/ui/renderers/settingsRenderers.js +1 -1
  31. package/src/ui/renderers/settingsRenderers.tsx +5 -3
  32. package/src/ui/screens/CliToolsScreen.js +2 -2
  33. package/src/ui/screens/CliToolsScreen.tsx +3 -1
  34. package/src/ui/screens/EnvVarsScreen.js +27 -10
  35. package/src/ui/screens/EnvVarsScreen.tsx +33 -16
  36. package/src/ui/screens/McpRegistryScreen.js +2 -2
  37. package/src/ui/screens/McpRegistryScreen.tsx +3 -1
  38. package/src/ui/screens/McpScreen.js +1 -1
  39. package/src/ui/screens/McpScreen.tsx +2 -1
  40. package/src/ui/screens/ModelSelectorScreen.js +2 -2
  41. package/src/ui/screens/ModelSelectorScreen.tsx +3 -2
  42. package/src/ui/screens/ProfilesScreen.js +1 -1
  43. package/src/ui/screens/ProfilesScreen.tsx +2 -1
  44. package/src/ui/screens/StatusLineScreen.js +1 -1
  45. package/src/ui/screens/StatusLineScreen.tsx +2 -1
  46. package/src/ui/state/DimensionsContext.js +2 -2
  47. package/src/ui/state/DimensionsContext.tsx +3 -3
  48. package/src/ui/components/ScrollableDetail.js +0 -23
  49. package/src/ui/components/ScrollableDetail.tsx +0 -55
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.6.1",
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
+ });
@@ -186,13 +186,8 @@ export const SETTINGS_CATALOG = [
186
186
  description: "Adjust Claude's response style",
187
187
  category: "workflow",
188
188
  type: "select",
189
- options: [
190
- { label: "Default", value: "" },
191
- { label: "Concise", value: "concise" },
192
- { label: "Explanatory", value: "explanatory" },
193
- { label: "Formal", value: "formal" },
194
- { label: "Minimal", value: "minimal" },
195
- ],
189
+ // Options populated dynamically from installed output-style plugins
190
+ options: [],
196
191
  storage: { type: "setting", key: "outputStyle" },
197
192
  },
198
193
  {
@@ -233,13 +233,8 @@ export const SETTINGS_CATALOG: SettingDefinition[] = [
233
233
  description: "Adjust Claude's response style",
234
234
  category: "workflow",
235
235
  type: "select",
236
- options: [
237
- { label: "Default", value: "" },
238
- { label: "Concise", value: "concise" },
239
- { label: "Explanatory", value: "explanatory" },
240
- { label: "Formal", value: "formal" },
241
- { label: "Minimal", value: "minimal" },
242
- ],
236
+ // Options populated dynamically from installed output-style plugins
237
+ options: [],
243
238
  storage: { type: "setting", key: "outputStyle" },
244
239
  },
245
240
  {
package/src/opentui.d.ts CHANGED
@@ -101,8 +101,10 @@ interface TextProps {
101
101
  }
102
102
 
103
103
  interface InputProps {
104
- value: string;
105
- onChange: (value: string) => void;
104
+ value?: string;
105
+ onChange?: (value: string) => void;
106
+ onInput?: (value: string) => void;
107
+ onSubmit?: ((value: string) => void) | undefined;
106
108
  placeholder?: string;
107
109
  focused?: boolean;
108
110
  width?: number;
@@ -137,6 +139,9 @@ interface TabSelectProps {
137
139
 
138
140
  interface ScrollboxProps {
139
141
  focused?: boolean;
142
+ height?: number | `${number}%` | "auto";
143
+ scrollY?: boolean;
144
+ scrollX?: boolean;
140
145
  style?: {
141
146
  rootOptions?: Record<string, unknown>;
142
147
  wrapperOptions?: Record<string, unknown>;
@@ -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