claudeup 4.12.0 → 4.14.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.12.0",
3
+ "version": "4.14.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -4,92 +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, gapFillInstalledPluginVersions, } from "../services/claude-settings.js";
7
+ import { recoverMarketplaceSettings, migrateMarketplaceRename, cleanupExtraKnownMarketplaces, readGlobalSettings, writeGlobalSettings, saveGlobalInstalledPluginVersion, gapFillInstalledPluginVersions, } from "../services/claude-settings.js";
8
8
  import { checkPluginVersionMismatches, formatMismatchWarning, } from "../services/plugin-version-check.js";
9
- import { parsePluginId } from "../utils/string-utils.js";
10
- import { defaultMarketplaces } from "../data/marketplaces.js";
11
- import { updatePlugin, addMarketplace, isClaudeAvailable, } from "../services/claude-cli.js";
12
- const MARKETPLACES_DIR = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
13
- /**
14
- * Collect all unique marketplace names from enabled plugins across all settings scopes.
15
- * Returns a Set of marketplace names (e.g., "magus", "claude-plugins-official").
16
- */
17
- async function getReferencedMarketplaces(projectPath) {
18
- const marketplaceNames = new Set();
19
- // Collect plugin IDs from all scopes
20
- const allPluginIds = new Set();
21
- try {
22
- const global = await getGlobalEnabledPlugins();
23
- for (const id of Object.keys(global))
24
- allPluginIds.add(id);
25
- }
26
- catch {
27
- /* skip if unreadable */
28
- }
29
- if (projectPath) {
30
- try {
31
- const project = await getEnabledPlugins(projectPath);
32
- for (const id of Object.keys(project))
33
- allPluginIds.add(id);
34
- }
35
- catch {
36
- /* skip if unreadable */
37
- }
38
- try {
39
- const local = await getLocalEnabledPlugins(projectPath);
40
- for (const id of Object.keys(local))
41
- allPluginIds.add(id);
42
- }
43
- catch {
44
- /* skip if unreadable */
45
- }
46
- }
47
- // Parse marketplace names from plugin IDs
48
- for (const pluginId of allPluginIds) {
49
- const parsed = parsePluginId(pluginId);
50
- if (parsed) {
51
- marketplaceNames.add(parsed.marketplace);
52
- }
53
- }
54
- return marketplaceNames;
55
- }
56
- /**
57
- * Check which referenced marketplaces are missing locally and auto-add them.
58
- * Only adds marketplaces with known repos (from defaultMarketplaces).
59
- *
60
- * IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
61
- * Claude Code's `marketplace update` calls cacheMarketplaceFromGit() which
62
- * deletes the marketplace directory before re-cloning. If the clone fails
63
- * (network timeout, auth error), the directory stays permanently deleted
64
- * and ALL plugins from that marketplace break. See:
65
- * ai-docs/plugin-marketplace-bug-investigation.md
66
- *
67
- * Claude Code's own background autoupdate handles marketplace refreshing
68
- * after session start — claudeup should only recover genuinely missing
69
- * marketplaces, not trigger additional refresh cycles.
70
- */
71
- async function autoAddMissingMarketplaces(projectPath) {
72
- const referenced = await getReferencedMarketplaces(projectPath);
73
- const added = [];
74
- for (const mpName of referenced) {
75
- // Check if marketplace directory exists locally
76
- const mpDir = path.join(MARKETPLACES_DIR, mpName);
77
- if (await fs.pathExists(mpDir))
78
- continue;
79
- // Look up the repo URL from default marketplaces
80
- const defaultMp = defaultMarketplaces.find((m) => m.name === mpName);
81
- if (!defaultMp?.source.repo)
82
- continue;
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
- }
90
- }
91
- return added;
92
- }
9
+ import { updatePlugin, isClaudeAvailable } from "../services/claude-cli.js";
10
+ import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
93
11
  const CONTINUITY_PLUGIN_SENTINEL = "tmux-claude-continuity";
94
12
  const CONTINUITY_PLUGIN_SCRIPT = path.join(os.homedir(), ".tmux", "plugins", "tmux-claude-continuity", "scripts", "on_session_start.sh");
95
13
  /**
@@ -162,9 +80,13 @@ export async function prerunClaude(claudeArgs, options = {}) {
162
80
  }
163
81
  // STEP 0.5: Auto-add missing marketplaces
164
82
  // When plugins reference a marketplace that's not installed locally
165
- // (e.g., settings synced from another machine), add it automatically.
83
+ // (e.g., settings synced from another machine or committed by a
84
+ // teammate in .claude/settings.json), add it automatically. Pass
85
+ // process.cwd() so project and local enabledPlugins are scanned —
86
+ // otherwise a freshly cloned repo whose only @magus references live
87
+ // in project settings looks "clean" from the global-scope view.
166
88
  if (await isClaudeAvailable()) {
167
- const addedMarketplaces = await autoAddMissingMarketplaces();
89
+ const addedMarketplaces = await autoAddMissingMarketplaces(process.cwd());
168
90
  if (addedMarketplaces.length > 0) {
169
91
  console.log(`✓ Auto-added marketplace(s): ${addedMarketplaces.join(", ")}`);
170
92
  }
@@ -11,9 +11,6 @@ import {
11
11
  recoverMarketplaceSettings,
12
12
  migrateMarketplaceRename,
13
13
  cleanupExtraKnownMarketplaces,
14
- getGlobalEnabledPlugins,
15
- getEnabledPlugins,
16
- getLocalEnabledPlugins,
17
14
  readGlobalSettings,
18
15
  writeGlobalSettings,
19
16
  saveGlobalInstalledPluginVersion,
@@ -23,115 +20,13 @@ import {
23
20
  checkPluginVersionMismatches,
24
21
  formatMismatchWarning,
25
22
  } from "../services/plugin-version-check.js";
26
- import { parsePluginId } from "../utils/string-utils.js";
27
- import { defaultMarketplaces } from "../data/marketplaces.js";
28
- import {
29
- updatePlugin,
30
- addMarketplace,
31
- isClaudeAvailable,
32
- } from "../services/claude-cli.js";
33
-
34
- const MARKETPLACES_DIR = path.join(
35
- os.homedir(),
36
- ".claude",
37
- "plugins",
38
- "marketplaces",
39
- );
23
+ import { updatePlugin, isClaudeAvailable } from "../services/claude-cli.js";
24
+ import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
40
25
 
41
26
  export interface PrerunOptions {
42
27
  force?: boolean; // Bypass cache and force update check
43
28
  }
44
29
 
45
- /**
46
- * Collect all unique marketplace names from enabled plugins across all settings scopes.
47
- * Returns a Set of marketplace names (e.g., "magus", "claude-plugins-official").
48
- */
49
- async function getReferencedMarketplaces(
50
- projectPath?: string,
51
- ): Promise<Set<string>> {
52
- const marketplaceNames = new Set<string>();
53
-
54
- // Collect plugin IDs from all scopes
55
- const allPluginIds = new Set<string>();
56
-
57
- try {
58
- const global = await getGlobalEnabledPlugins();
59
- for (const id of Object.keys(global)) allPluginIds.add(id);
60
- } catch {
61
- /* skip if unreadable */
62
- }
63
-
64
- if (projectPath) {
65
- try {
66
- const project = await getEnabledPlugins(projectPath);
67
- for (const id of Object.keys(project)) allPluginIds.add(id);
68
- } catch {
69
- /* skip if unreadable */
70
- }
71
-
72
- try {
73
- const local = await getLocalEnabledPlugins(projectPath);
74
- for (const id of Object.keys(local)) allPluginIds.add(id);
75
- } catch {
76
- /* skip if unreadable */
77
- }
78
- }
79
-
80
- // Parse marketplace names from plugin IDs
81
- for (const pluginId of allPluginIds) {
82
- const parsed = parsePluginId(pluginId);
83
- if (parsed) {
84
- marketplaceNames.add(parsed.marketplace);
85
- }
86
- }
87
-
88
- return marketplaceNames;
89
- }
90
-
91
- /**
92
- * Check which referenced marketplaces are missing locally and auto-add them.
93
- * Only adds marketplaces with known repos (from defaultMarketplaces).
94
- *
95
- * IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
96
- * Claude Code's `marketplace update` calls cacheMarketplaceFromGit() which
97
- * deletes the marketplace directory before re-cloning. If the clone fails
98
- * (network timeout, auth error), the directory stays permanently deleted
99
- * and ALL plugins from that marketplace break. See:
100
- * ai-docs/plugin-marketplace-bug-investigation.md
101
- *
102
- * Claude Code's own background autoupdate handles marketplace refreshing
103
- * after session start — claudeup should only recover genuinely missing
104
- * marketplaces, not trigger additional refresh cycles.
105
- */
106
- async function autoAddMissingMarketplaces(
107
- projectPath?: string,
108
- ): Promise<string[]> {
109
- const referenced = await getReferencedMarketplaces(projectPath);
110
- const added: string[] = [];
111
-
112
- for (const mpName of referenced) {
113
- // Check if marketplace directory exists locally
114
- const mpDir = path.join(MARKETPLACES_DIR, mpName);
115
- if (await fs.pathExists(mpDir)) continue;
116
-
117
- // Look up the repo URL from default marketplaces
118
- const defaultMp = defaultMarketplaces.find((m) => m.name === mpName);
119
- if (!defaultMp?.source.repo) continue;
120
-
121
- try {
122
- await addMarketplace(defaultMp.source.repo);
123
- added.push(mpName);
124
- } catch (error) {
125
- console.warn(
126
- `⚠ Failed to auto-add marketplace ${mpName}:`,
127
- error instanceof Error ? error.message : "Unknown error",
128
- );
129
- }
130
- }
131
-
132
- return added;
133
- }
134
-
135
30
  const CONTINUITY_PLUGIN_SENTINEL = "tmux-claude-continuity";
136
31
  const CONTINUITY_PLUGIN_SCRIPT = path.join(
137
32
  os.homedir(),
@@ -228,9 +123,15 @@ export async function prerunClaude(
228
123
 
229
124
  // STEP 0.5: Auto-add missing marketplaces
230
125
  // When plugins reference a marketplace that's not installed locally
231
- // (e.g., settings synced from another machine), add it automatically.
126
+ // (e.g., settings synced from another machine or committed by a
127
+ // teammate in .claude/settings.json), add it automatically. Pass
128
+ // process.cwd() so project and local enabledPlugins are scanned —
129
+ // otherwise a freshly cloned repo whose only @magus references live
130
+ // in project settings looks "clean" from the global-scope view.
232
131
  if (await isClaudeAvailable()) {
233
- const addedMarketplaces = await autoAddMissingMarketplaces();
132
+ const addedMarketplaces = await autoAddMissingMarketplaces(
133
+ process.cwd(),
134
+ );
234
135
  if (addedMarketplaces.length > 0) {
235
136
  console.log(
236
137
  `✓ Auto-added marketplace(s): ${addedMarketplaces.join(", ")}`,
@@ -11,8 +11,9 @@
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { which } from "../utils/command-utils.js";
14
- import { removeGlobalInstalledPluginVersion, removeLocalInstalledPluginVersion, } from "./claude-settings.js";
14
+ import { isMarketplaceRegistered, removeGlobalInstalledPluginVersion, removeLocalInstalledPluginVersion, } from "./claude-settings.js";
15
15
  import { removeInstalledPluginVersion } from "./plugin-manager.js";
16
+ import { defaultMarketplaces } from "../data/marketplaces.js";
16
17
  const execFileAsync = promisify(execFile);
17
18
  /**
18
19
  * Get the path to the claude CLI binary
@@ -48,13 +49,40 @@ async function execClaude(args, timeoutMs = 30000) {
48
49
  throw new Error(msg);
49
50
  }
50
51
  }
52
+ /**
53
+ * Recover from a "not found in marketplace" error.
54
+ *
55
+ * Two distinct failure modes land in the same error string:
56
+ * 1. Marketplace is registered locally but stale (or uses the old
57
+ * "directory" source) — fix the registry and refresh the clone.
58
+ * 2. Marketplace is not registered at all — look up its repo in
59
+ * defaultMarketplaces and add it. This is the "new employee clones
60
+ * a project whose .claude/settings.json enables @magus plugins but
61
+ * magus was never registered on this machine" case.
62
+ *
63
+ * Returns true if recovery was attempted and a retry is worth trying.
64
+ */
65
+ async function recoverMissingMarketplace(marketplace) {
66
+ if (await isMarketplaceRegistered(marketplace)) {
67
+ // Registered but stale — existing recovery path.
68
+ const { recoverMarketplaceSettings } = await import("./claude-settings.js");
69
+ await recoverMarketplaceSettings();
70
+ await updateMarketplace(marketplace);
71
+ return true;
72
+ }
73
+ const defaultMp = defaultMarketplaces.find((m) => m.name === marketplace);
74
+ if (!defaultMp?.source.repo)
75
+ return false;
76
+ await addMarketplace(defaultMp.source.repo);
77
+ return true;
78
+ }
51
79
  /**
52
80
  * Install a plugin using claude CLI
53
81
  * Handles enabling + version tracking + cache copy in one shot.
54
82
  *
55
83
  * If the install fails because the plugin is "not found in marketplace",
56
- * recovers the marketplace registry (fixes stale "directory" sources),
57
- * triggers a marketplace update, and retries once.
84
+ * attempts to recover (add the marketplace if missing, or refresh it if
85
+ * stale) and retries once.
58
86
  */
59
87
  export async function installPlugin(pluginId, scope = "user") {
60
88
  try {
@@ -63,14 +91,8 @@ export async function installPlugin(pluginId, scope = "user") {
63
91
  catch (error) {
64
92
  const msg = error instanceof Error ? error.message : String(error);
65
93
  if (msg.includes("not found in marketplace")) {
66
- const parts = pluginId.split("@");
67
- const marketplace = parts[1];
68
- if (marketplace) {
69
- // Fix known_marketplaces.json first (stale "directory" → "github"),
70
- // then update, then retry
71
- const { recoverMarketplaceSettings } = await import("./claude-settings.js");
72
- await recoverMarketplaceSettings();
73
- await updateMarketplace(marketplace);
94
+ const marketplace = pluginId.split("@")[1];
95
+ if (marketplace && (await recoverMissingMarketplace(marketplace))) {
74
96
  await execClaude([
75
97
  "plugin",
76
98
  "install",
@@ -135,12 +157,8 @@ export async function updatePlugin(pluginId, scope = "user") {
135
157
  catch (error) {
136
158
  const msg = error instanceof Error ? error.message : String(error);
137
159
  if (msg.includes("not found in marketplace")) {
138
- const parts = pluginId.split("@");
139
- const marketplace = parts[1];
140
- if (marketplace) {
141
- const { recoverMarketplaceSettings } = await import("./claude-settings.js");
142
- await recoverMarketplaceSettings();
143
- await updateMarketplace(marketplace);
160
+ const marketplace = pluginId.split("@")[1];
161
+ if (marketplace && (await recoverMissingMarketplace(marketplace))) {
144
162
  await execClaude(["plugin", "install", pluginId, "--scope", scope], 60000);
145
163
  return;
146
164
  }
@@ -13,10 +13,12 @@ import { execFile } from "node:child_process";
13
13
  import { promisify } from "node:util";
14
14
  import { which } from "../utils/command-utils.js";
15
15
  import {
16
+ isMarketplaceRegistered,
16
17
  removeGlobalInstalledPluginVersion,
17
18
  removeLocalInstalledPluginVersion,
18
19
  } from "./claude-settings.js";
19
20
  import { removeInstalledPluginVersion } from "./plugin-manager.js";
21
+ import { defaultMarketplaces } from "../data/marketplaces.js";
20
22
 
21
23
  const execFileAsync = promisify(execFile);
22
24
 
@@ -65,13 +67,46 @@ async function execClaude(args: string[], timeoutMs = 30000): Promise<string> {
65
67
  }
66
68
  }
67
69
 
70
+ /**
71
+ * Recover from a "not found in marketplace" error.
72
+ *
73
+ * Two distinct failure modes land in the same error string:
74
+ * 1. Marketplace is registered locally but stale (or uses the old
75
+ * "directory" source) — fix the registry and refresh the clone.
76
+ * 2. Marketplace is not registered at all — look up its repo in
77
+ * defaultMarketplaces and add it. This is the "new employee clones
78
+ * a project whose .claude/settings.json enables @magus plugins but
79
+ * magus was never registered on this machine" case.
80
+ *
81
+ * Returns true if recovery was attempted and a retry is worth trying.
82
+ */
83
+ async function recoverMissingMarketplace(
84
+ marketplace: string,
85
+ ): Promise<boolean> {
86
+ if (await isMarketplaceRegistered(marketplace)) {
87
+ // Registered but stale — existing recovery path.
88
+ const { recoverMarketplaceSettings } = await import(
89
+ "./claude-settings.js"
90
+ );
91
+ await recoverMarketplaceSettings();
92
+ await updateMarketplace(marketplace);
93
+ return true;
94
+ }
95
+
96
+ const defaultMp = defaultMarketplaces.find((m) => m.name === marketplace);
97
+ if (!defaultMp?.source.repo) return false;
98
+
99
+ await addMarketplace(defaultMp.source.repo);
100
+ return true;
101
+ }
102
+
68
103
  /**
69
104
  * Install a plugin using claude CLI
70
105
  * Handles enabling + version tracking + cache copy in one shot.
71
106
  *
72
107
  * If the install fails because the plugin is "not found in marketplace",
73
- * recovers the marketplace registry (fixes stale "directory" sources),
74
- * triggers a marketplace update, and retries once.
108
+ * attempts to recover (add the marketplace if missing, or refresh it if
109
+ * stale) and retries once.
75
110
  */
76
111
  export async function installPlugin(
77
112
  pluginId: string,
@@ -83,16 +118,8 @@ export async function installPlugin(
83
118
  const msg =
84
119
  error instanceof Error ? error.message : String(error);
85
120
  if (msg.includes("not found in marketplace")) {
86
- const parts = pluginId.split("@");
87
- const marketplace = parts[1];
88
- if (marketplace) {
89
- // Fix known_marketplaces.json first (stale "directory" → "github"),
90
- // then update, then retry
91
- const { recoverMarketplaceSettings } = await import(
92
- "./claude-settings.js"
93
- );
94
- await recoverMarketplaceSettings();
95
- await updateMarketplace(marketplace);
121
+ const marketplace = pluginId.split("@")[1];
122
+ if (marketplace && (await recoverMissingMarketplace(marketplace))) {
96
123
  await execClaude([
97
124
  "plugin",
98
125
  "install",
@@ -174,14 +201,8 @@ export async function updatePlugin(
174
201
  const msg =
175
202
  error instanceof Error ? error.message : String(error);
176
203
  if (msg.includes("not found in marketplace")) {
177
- const parts = pluginId.split("@");
178
- const marketplace = parts[1];
179
- if (marketplace) {
180
- const { recoverMarketplaceSettings } = await import(
181
- "./claude-settings.js"
182
- );
183
- await recoverMarketplaceSettings();
184
- await updateMarketplace(marketplace);
204
+ const marketplace = pluginId.split("@")[1];
205
+ if (marketplace && (await recoverMissingMarketplace(marketplace))) {
185
206
  await execClaude(
186
207
  ["plugin", "install", pluginId, "--scope", scope],
187
208
  60000,
@@ -1014,6 +1014,20 @@ async function readKnownMarketplaces() {
1014
1014
  }
1015
1015
  return {};
1016
1016
  }
1017
+ /**
1018
+ * Check whether a marketplace is registered locally. A marketplace is
1019
+ * considered registered when it has an entry in known_marketplaces.json
1020
+ * AND its clone directory exists on disk. Either condition alone is not
1021
+ * enough: an entry with a missing directory means the clone was lost; a
1022
+ * directory without an entry means Claude Code doesn't know about it.
1023
+ */
1024
+ export async function isMarketplaceRegistered(name) {
1025
+ const known = await readKnownMarketplaces();
1026
+ if (!known[name])
1027
+ return false;
1028
+ const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
1029
+ return fs.pathExists(path.join(marketplacesDir, name));
1030
+ }
1017
1031
  /**
1018
1032
  * Get the source path for a plugin from its marketplace
1019
1033
  * For directory-based marketplaces, returns the local directory path
@@ -1325,6 +1325,27 @@ async function readKnownMarketplaces(): Promise<KnownMarketplaces> {
1325
1325
  return {};
1326
1326
  }
1327
1327
 
1328
+ /**
1329
+ * Check whether a marketplace is registered locally. A marketplace is
1330
+ * considered registered when it has an entry in known_marketplaces.json
1331
+ * AND its clone directory exists on disk. Either condition alone is not
1332
+ * enough: an entry with a missing directory means the clone was lost; a
1333
+ * directory without an entry means Claude Code doesn't know about it.
1334
+ */
1335
+ export async function isMarketplaceRegistered(
1336
+ name: string,
1337
+ ): Promise<boolean> {
1338
+ const known = await readKnownMarketplaces();
1339
+ if (!known[name]) return false;
1340
+ const marketplacesDir = path.join(
1341
+ os.homedir(),
1342
+ ".claude",
1343
+ "plugins",
1344
+ "marketplaces",
1345
+ );
1346
+ return fs.pathExists(path.join(marketplacesDir, name));
1347
+ }
1348
+
1328
1349
  /**
1329
1350
  * Get the source path for a plugin from its marketplace
1330
1351
  * For directory-based marketplaces, returns the local directory path
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Marketplace sync service
3
+ *
4
+ * Auto-registers marketplaces that are referenced by `enabledPlugins`
5
+ * in any settings scope (global, project, local) but are not installed
6
+ * locally. Fixes the "new employee clones repo with committed .claude/
7
+ * settings but has never run `claude plugin marketplace add`" scenario.
8
+ *
9
+ * IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
10
+ * Claude Code's `marketplace update` calls cacheMarketplaceFromGit()
11
+ * which deletes the marketplace directory before re-cloning. If the
12
+ * clone fails (network, auth, timeout), the directory stays permanently
13
+ * deleted. See ai-docs/plugin-marketplace-bug-investigation.md.
14
+ */
15
+ import fs from "fs-extra";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, isMarketplaceRegistered, } from "./claude-settings.js";
19
+ import { parsePluginId } from "../utils/string-utils.js";
20
+ import { defaultMarketplaces } from "../data/marketplaces.js";
21
+ import { addMarketplace } from "./claude-cli.js";
22
+ const MARKETPLACES_DIR = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
23
+ /**
24
+ * Collect unique marketplace names from enabled plugins across all settings
25
+ * scopes. When `projectPath` is provided, project and local scopes are
26
+ * included; otherwise only the global scope is scanned.
27
+ */
28
+ export async function getReferencedMarketplaces(projectPath) {
29
+ const marketplaceNames = new Set();
30
+ const allPluginIds = new Set();
31
+ try {
32
+ const global = await getGlobalEnabledPlugins();
33
+ for (const id of Object.keys(global))
34
+ allPluginIds.add(id);
35
+ }
36
+ catch {
37
+ /* skip if unreadable */
38
+ }
39
+ if (projectPath) {
40
+ try {
41
+ const project = await getEnabledPlugins(projectPath);
42
+ for (const id of Object.keys(project))
43
+ allPluginIds.add(id);
44
+ }
45
+ catch {
46
+ /* skip if unreadable */
47
+ }
48
+ try {
49
+ const local = await getLocalEnabledPlugins(projectPath);
50
+ for (const id of Object.keys(local))
51
+ allPluginIds.add(id);
52
+ }
53
+ catch {
54
+ /* skip if unreadable */
55
+ }
56
+ }
57
+ for (const pluginId of allPluginIds) {
58
+ const parsed = parsePluginId(pluginId);
59
+ if (parsed)
60
+ marketplaceNames.add(parsed.marketplace);
61
+ }
62
+ return marketplaceNames;
63
+ }
64
+ /** Re-exported for convenience — canonical implementation lives in claude-settings. */
65
+ export { isMarketplaceRegistered };
66
+ /**
67
+ * Register any referenced marketplaces that are missing locally.
68
+ * Looks up the GitHub repo in `defaultMarketplaces` and calls
69
+ * `claude plugin marketplace add` for each. Always writes to the global
70
+ * registry (`~/.claude/plugins/known_marketplaces.json`) — scope only
71
+ * controls which settings are scanned to build the reference list.
72
+ *
73
+ * Returns the names of marketplaces that were successfully added.
74
+ */
75
+ export async function autoAddMissingMarketplaces(projectPath) {
76
+ const referenced = await getReferencedMarketplaces(projectPath);
77
+ const added = [];
78
+ for (const mpName of referenced) {
79
+ const mpDir = path.join(MARKETPLACES_DIR, mpName);
80
+ if (await fs.pathExists(mpDir))
81
+ continue;
82
+ const defaultMp = defaultMarketplaces.find((m) => m.name === mpName);
83
+ if (!defaultMp?.source.repo)
84
+ continue;
85
+ try {
86
+ await addMarketplace(defaultMp.source.repo);
87
+ added.push(mpName);
88
+ }
89
+ catch (error) {
90
+ console.warn(`⚠ Failed to auto-add marketplace ${mpName}:`, error instanceof Error ? error.message : "Unknown error");
91
+ }
92
+ }
93
+ return added;
94
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Marketplace sync service
3
+ *
4
+ * Auto-registers marketplaces that are referenced by `enabledPlugins`
5
+ * in any settings scope (global, project, local) but are not installed
6
+ * locally. Fixes the "new employee clones repo with committed .claude/
7
+ * settings but has never run `claude plugin marketplace add`" scenario.
8
+ *
9
+ * IMPORTANT: Only uses `marketplace add` (never `marketplace update`).
10
+ * Claude Code's `marketplace update` calls cacheMarketplaceFromGit()
11
+ * which deletes the marketplace directory before re-cloning. If the
12
+ * clone fails (network, auth, timeout), the directory stays permanently
13
+ * deleted. See ai-docs/plugin-marketplace-bug-investigation.md.
14
+ */
15
+ import fs from "fs-extra";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import {
19
+ getGlobalEnabledPlugins,
20
+ getEnabledPlugins,
21
+ getLocalEnabledPlugins,
22
+ isMarketplaceRegistered,
23
+ } from "./claude-settings.js";
24
+ import { parsePluginId } from "../utils/string-utils.js";
25
+ import { defaultMarketplaces } from "../data/marketplaces.js";
26
+ import { addMarketplace } from "./claude-cli.js";
27
+
28
+ const MARKETPLACES_DIR = path.join(
29
+ os.homedir(),
30
+ ".claude",
31
+ "plugins",
32
+ "marketplaces",
33
+ );
34
+
35
+ /**
36
+ * Collect unique marketplace names from enabled plugins across all settings
37
+ * scopes. When `projectPath` is provided, project and local scopes are
38
+ * included; otherwise only the global scope is scanned.
39
+ */
40
+ export async function getReferencedMarketplaces(
41
+ projectPath?: string,
42
+ ): Promise<Set<string>> {
43
+ const marketplaceNames = new Set<string>();
44
+ const allPluginIds = new Set<string>();
45
+
46
+ try {
47
+ const global = await getGlobalEnabledPlugins();
48
+ for (const id of Object.keys(global)) allPluginIds.add(id);
49
+ } catch {
50
+ /* skip if unreadable */
51
+ }
52
+
53
+ if (projectPath) {
54
+ try {
55
+ const project = await getEnabledPlugins(projectPath);
56
+ for (const id of Object.keys(project)) allPluginIds.add(id);
57
+ } catch {
58
+ /* skip if unreadable */
59
+ }
60
+
61
+ try {
62
+ const local = await getLocalEnabledPlugins(projectPath);
63
+ for (const id of Object.keys(local)) allPluginIds.add(id);
64
+ } catch {
65
+ /* skip if unreadable */
66
+ }
67
+ }
68
+
69
+ for (const pluginId of allPluginIds) {
70
+ const parsed = parsePluginId(pluginId);
71
+ if (parsed) marketplaceNames.add(parsed.marketplace);
72
+ }
73
+
74
+ return marketplaceNames;
75
+ }
76
+
77
+ /** Re-exported for convenience — canonical implementation lives in claude-settings. */
78
+ export { isMarketplaceRegistered };
79
+
80
+ /**
81
+ * Register any referenced marketplaces that are missing locally.
82
+ * Looks up the GitHub repo in `defaultMarketplaces` and calls
83
+ * `claude plugin marketplace add` for each. Always writes to the global
84
+ * registry (`~/.claude/plugins/known_marketplaces.json`) — scope only
85
+ * controls which settings are scanned to build the reference list.
86
+ *
87
+ * Returns the names of marketplaces that were successfully added.
88
+ */
89
+ export async function autoAddMissingMarketplaces(
90
+ projectPath?: string,
91
+ ): Promise<string[]> {
92
+ const referenced = await getReferencedMarketplaces(projectPath);
93
+ const added: string[] = [];
94
+
95
+ for (const mpName of referenced) {
96
+ const mpDir = path.join(MARKETPLACES_DIR, mpName);
97
+ if (await fs.pathExists(mpDir)) continue;
98
+
99
+ const defaultMp = defaultMarketplaces.find((m) => m.name === mpName);
100
+ if (!defaultMp?.source.repo) continue;
101
+
102
+ try {
103
+ await addMarketplace(defaultMp.source.repo);
104
+ added.push(mpName);
105
+ } catch (error) {
106
+ console.warn(
107
+ `⚠ Failed to auto-add marketplace ${mpName}:`,
108
+ error instanceof Error ? error.message : "Unknown error",
109
+ );
110
+ }
111
+ }
112
+
113
+ return added;
114
+ }
package/src/ui/App.js CHANGED
@@ -8,6 +8,8 @@ import { ModalContainer } from "./components/modals/index.js";
8
8
  import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, } from "./screens/index.js";
9
9
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
10
10
  import { migrateMarketplaceRename, recoverMarketplaceSettings, } from "../services/claude-settings.js";
11
+ import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
12
+ import { isClaudeAvailable } from "../services/claude-cli.js";
11
13
  import { checkPluginVersionMismatches, } from "../services/plugin-version-check.js";
12
14
  import { useMismatchModal } from "./hooks/useMismatchModal.js";
13
15
  import { checkForUpdates, getCurrentVersion, } from "../services/version-check.js";
@@ -222,6 +224,23 @@ function AppContent({ onExit }) {
222
224
  });
223
225
  // Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
224
226
  migrateMarketplaceRename().catch(() => { }); // non-blocking, best-effort
227
+ // Auto-add marketplaces referenced by enabledPlugins but missing from
228
+ // the local registry. Fixes the "fresh clone of a project whose
229
+ // .claude/settings.json enables @magus plugins but this machine has
230
+ // never run `claude plugin marketplace add`" case.
231
+ (async () => {
232
+ try {
233
+ if (!(await isClaudeAvailable()))
234
+ return;
235
+ const added = await autoAddMissingMarketplaces(process.cwd());
236
+ if (added.length > 0) {
237
+ setRecoveryReport(`added marketplace(s): ${added.join(", ")}`);
238
+ }
239
+ }
240
+ catch {
241
+ // non-fatal: user can still install marketplaces manually
242
+ }
243
+ })();
225
244
  // Recover stale marketplace registry entries (e.g. "directory" -> "github")
226
245
  recoverMarketplaceSettings()
227
246
  .then(async (recovery) => {
package/src/ui/App.tsx CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  migrateMarketplaceRename,
29
29
  recoverMarketplaceSettings,
30
30
  } from "../services/claude-settings.js";
31
+ import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
32
+ import { isClaudeAvailable } from "../services/claude-cli.js";
31
33
  import {
32
34
  checkPluginVersionMismatches,
33
35
  type VersionMismatchInfo,
@@ -352,6 +354,22 @@ function AppContent({ onExit }: AppContentProps) {
352
354
  // Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
353
355
  migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
354
356
 
357
+ // Auto-add marketplaces referenced by enabledPlugins but missing from
358
+ // the local registry. Fixes the "fresh clone of a project whose
359
+ // .claude/settings.json enables @magus plugins but this machine has
360
+ // never run `claude plugin marketplace add`" case.
361
+ (async () => {
362
+ try {
363
+ if (!(await isClaudeAvailable())) return;
364
+ const added = await autoAddMissingMarketplaces(process.cwd());
365
+ if (added.length > 0) {
366
+ setRecoveryReport(`added marketplace(s): ${added.join(", ")}`);
367
+ }
368
+ } catch {
369
+ // non-fatal: user can still install marketplaces manually
370
+ }
371
+ })();
372
+
355
373
  // Recover stale marketplace registry entries (e.g. "directory" -> "github")
356
374
  recoverMarketplaceSettings()
357
375
  .then(async (recovery) => {
@@ -26,7 +26,7 @@ export function VersionMismatchModal({ mismatches, defaultIndex, }) {
26
26
  // Column widths chosen to fit a 64-wide modal with padding
27
27
  const NAME_W = 22;
28
28
  const VER_W = 12;
29
- return (_jsxs("box", { flexDirection: "column", border: true, borderStyle: "rounded", borderColor: COLOR_BORDER, backgroundColor: COLOR_BG, paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, width: 64, children: [_jsxs("box", { marginBottom: 1, children: [_jsx("text", { fg: COLOR_WARN, children: "\u26A0 " }), _jsx("text", { fg: COLOR_TITLE, children: _jsx("strong", { children: "Plugin Version Mismatch" }) })] }), _jsxs("box", { flexDirection: "column", marginBottom: 1, children: [_jsxs("text", { fg: COLOR_MUTED, children: ["Plugins in this project will load ", _jsx("strong", { children: "wrong versions" })] }), _jsx("text", { fg: COLOR_MUTED, children: "due to a Claude Code bug (#45997)." })] }), _jsxs("box", { flexDirection: "column", marginBottom: 1, children: [_jsx("text", { fg: COLOR_DIM, children: _jsxs("strong", { children: ["Plugin".padEnd(NAME_W), "Loaded".padEnd(VER_W), "Expected".padEnd(VER_W)] }) }), _jsxs("text", { fg: COLOR_BORDER, children: ["─".repeat(NAME_W - 1), " ", "─".repeat(VER_W - 1), " ", "─".repeat(VER_W - 1)] }), mismatches.map((m) => {
29
+ return (_jsxs("box", { flexDirection: "column", border: true, borderStyle: "rounded", borderColor: COLOR_BORDER, backgroundColor: COLOR_BG, paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, width: 64, children: [_jsx("box", { marginBottom: 1, children: _jsxs("text", { fg: COLOR_TITLE, children: [_jsx("span", { fg: COLOR_WARN, children: "\u26A0 " }), _jsx("strong", { children: "Plugin Version Mismatch" })] }) }), _jsxs("box", { flexDirection: "column", marginBottom: 1, children: [_jsxs("text", { fg: COLOR_MUTED, children: ["Plugins in this project will load ", _jsx("strong", { children: "wrong versions" })] }), _jsx("text", { fg: COLOR_MUTED, children: "due to a Claude Code bug (#45997)." })] }), _jsxs("box", { flexDirection: "column", marginBottom: 1, children: [_jsx("text", { fg: COLOR_DIM, children: _jsxs("strong", { children: ["Plugin".padEnd(NAME_W), "Loaded".padEnd(VER_W), "Expected".padEnd(VER_W)] }) }), _jsxs("text", { fg: COLOR_BORDER, children: ["─".repeat(NAME_W - 1), " ", "─".repeat(VER_W - 1), " ", "─".repeat(VER_W - 1)] }), mismatches.map((m) => {
30
30
  const name = shortName(m.pluginId, NAME_W - 1);
31
31
  const loaded = `v${shortVersion(m.firstEntryVersion, VER_W - 2)}`;
32
32
  const expected = `v${shortVersion(m.currentProjectVersion, VER_W - 2)}`;
@@ -56,8 +56,8 @@ export function VersionMismatchModal({
56
56
  >
57
57
  {/* Title */}
58
58
  <box marginBottom={1}>
59
- <text fg={COLOR_WARN}>⚠ </text>
60
59
  <text fg={COLOR_TITLE}>
60
+ <span fg={COLOR_WARN}>⚠ </span>
61
61
  <strong>Plugin Version Mismatch</strong>
62
62
  </text>
63
63
  </box>
@@ -14,7 +14,7 @@ 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";
16
16
  import { getPluginSetupFromSource, checkMissingDeps, installPluginDeps, } from "../../services/plugin-setup.js";
17
- import { checkSinglePluginMismatch, } from "../../services/plugin-version-check.js";
17
+ import { checkPluginVersionMismatches, checkSinglePluginMismatch, } from "../../services/plugin-version-check.js";
18
18
  import { useMismatchModal } from "../hooks/useMismatchModal.js";
19
19
  import { buildPluginBrowserItems, } from "../adapters/pluginsAdapter.js";
20
20
  import { renderPluginRow, renderPluginDetail, } from "../renderers/pluginRenderers.js";
@@ -215,6 +215,8 @@ export function PluginsScreen() {
215
215
  handleUpdate();
216
216
  else if (event.name === "a")
217
217
  handleUpdateAll();
218
+ else if (event.name === "m")
219
+ handleCheckMismatches();
218
220
  else if (event.name === "s")
219
221
  handleSaveAsProfile();
220
222
  });
@@ -589,6 +591,25 @@ export function PluginsScreen() {
589
591
  await modal.message("Error", `Failed to update: ${error}`, "error");
590
592
  }
591
593
  };
594
+ /**
595
+ * Manual on-demand check for plugin version mismatches across projects.
596
+ * Bound to the `m` keybind. Shows a success message if everything is
597
+ * aligned, or the interactive fix modal if any mismatches are found.
598
+ */
599
+ const handleCheckMismatches = async () => {
600
+ try {
601
+ const projectPath = state.projectPath || process.cwd();
602
+ const mismatches = await checkPluginVersionMismatches(projectPath);
603
+ if (mismatches.length === 0) {
604
+ await modal.message("No Mismatches", "All enabled plugins in this project will load their expected versions. No action needed.", "success");
605
+ return;
606
+ }
607
+ mismatchModal.show(mismatches);
608
+ }
609
+ catch (error) {
610
+ await modal.message("Error", `Failed to check mismatches: ${error}`, "error");
611
+ }
612
+ };
592
613
  const handleScopeToggle = async (scope) => {
593
614
  const item = selectableItems[pluginsState.selectedIndex];
594
615
  if (!item || item.kind !== "plugin")
@@ -715,7 +736,7 @@ export function PluginsScreen() {
715
736
  const selectedItem = selectableItems[pluginsState.selectedIndex];
716
737
  const footerHints = isSearchActive
717
738
  ? "type to filter │ Enter:done │ Esc:clear"
718
- : "u/p/l:toggle │ U:update │ a:all │ s:profile │ /:search";
739
+ : "u/p/l:toggle │ U:update │ a:all │ m:mismatches │ s:profile │ /:search";
719
740
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
720
741
  const plugins = pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
721
742
  const installedCount = plugins.filter((p) => p.enabled).length;
@@ -39,6 +39,7 @@ import {
39
39
  installPluginDeps,
40
40
  } from "../../services/plugin-setup.js";
41
41
  import {
42
+ checkPluginVersionMismatches,
42
43
  checkSinglePluginMismatch,
43
44
  type VersionMismatchInfo,
44
45
  } from "../../services/plugin-version-check.js";
@@ -266,6 +267,7 @@ export function PluginsScreen() {
266
267
  else if (event.name === "d") handleRemoveDeprecated();
267
268
  else if (event.name === "U") handleUpdate();
268
269
  else if (event.name === "a") handleUpdateAll();
270
+ else if (event.name === "m") handleCheckMismatches();
269
271
  else if (event.name === "s") handleSaveAsProfile();
270
272
  });
271
273
 
@@ -741,6 +743,33 @@ export function PluginsScreen() {
741
743
  }
742
744
  };
743
745
 
746
+ /**
747
+ * Manual on-demand check for plugin version mismatches across projects.
748
+ * Bound to the `m` keybind. Shows a success message if everything is
749
+ * aligned, or the interactive fix modal if any mismatches are found.
750
+ */
751
+ const handleCheckMismatches = async () => {
752
+ try {
753
+ const projectPath = state.projectPath || process.cwd();
754
+ const mismatches = await checkPluginVersionMismatches(projectPath);
755
+ if (mismatches.length === 0) {
756
+ await modal.message(
757
+ "No Mismatches",
758
+ "All enabled plugins in this project will load their expected versions. No action needed.",
759
+ "success",
760
+ );
761
+ return;
762
+ }
763
+ mismatchModal.show(mismatches);
764
+ } catch (error) {
765
+ await modal.message(
766
+ "Error",
767
+ `Failed to check mismatches: ${error}`,
768
+ "error",
769
+ );
770
+ }
771
+ };
772
+
744
773
  const handleScopeToggle = async (scope: "user" | "project" | "local") => {
745
774
  const item = selectableItems[pluginsState.selectedIndex];
746
775
  if (!item || item.kind !== "plugin") return;
@@ -909,7 +938,7 @@ export function PluginsScreen() {
909
938
 
910
939
  const footerHints = isSearchActive
911
940
  ? "type to filter │ Enter:done │ Esc:clear"
912
- : "u/p/l:toggle │ U:update │ a:all │ s:profile │ /:search";
941
+ : "u/p/l:toggle │ U:update │ a:all │ m:mismatches │ s:profile │ /:search";
913
942
 
914
943
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
915
944
  const plugins: PluginInfo[] =