claudeup 4.18.0 → 4.22.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 (191) hide show
  1. package/bin/claudeup.js +43 -19
  2. package/package.json +14 -19
  3. package/scripts/build-binaries.ts +77 -0
  4. package/scripts/check-optional-deps.ts +49 -0
  5. package/scripts/test-isolated.ts +43 -0
  6. package/src/__tests__/alias-adopt.test.ts +364 -0
  7. package/src/__tests__/alias-parser.test.ts +92 -0
  8. package/src/__tests__/alias-shell-writer.test.ts +7 -0
  9. package/src/__tests__/alias-store.test.ts +77 -0
  10. package/src/__tests__/cli-router.test.ts +65 -0
  11. package/src/__tests__/conventions-integration.test.ts +745 -0
  12. package/src/__tests__/conventions-manager.test.ts +1172 -0
  13. package/src/__tests__/doctor-bins.test.ts +35 -0
  14. package/src/__tests__/doctor.test.ts +450 -0
  15. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  16. package/src/__tests__/file-locking.test.ts +208 -0
  17. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  18. package/src/__tests__/install-command.test.ts +81 -0
  19. package/src/__tests__/install-plan.test.ts +98 -0
  20. package/src/__tests__/manifest.test.ts +136 -0
  21. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  22. package/src/__tests__/plugin-requires.test.ts +133 -0
  23. package/src/__tests__/plugin-setup.test.ts +118 -0
  24. package/src/__tests__/profile-command.test.ts +116 -0
  25. package/src/__tests__/profile-materializer.test.ts +82 -0
  26. package/src/__tests__/profile-sync.test.ts +136 -0
  27. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  28. package/src/__tests__/resolve-executable.test.ts +42 -0
  29. package/src/__tests__/resolver.test.ts +218 -0
  30. package/src/__tests__/symlink-manager.test.ts +99 -0
  31. package/src/__tests__/toolchain.test.ts +56 -0
  32. package/src/cli/claude.ts +21 -0
  33. package/src/cli/doctor.ts +132 -0
  34. package/src/cli/install.ts +360 -0
  35. package/src/cli/profile.ts +166 -0
  36. package/src/cli/router.ts +107 -0
  37. package/src/cli/update.ts +85 -0
  38. package/src/data/alias-flags.ts +11 -1
  39. package/src/data/cli-tools.ts +7 -7
  40. package/src/data/gitignore-defaults.ts +4 -0
  41. package/src/data/marketplaces.ts +12 -0
  42. package/src/data/predefined-profiles.ts +3 -4
  43. package/src/main.tsx +11 -154
  44. package/src/prerunner/index.ts +62 -27
  45. package/src/services/alias-shell-writer.ts +382 -8
  46. package/src/services/alias-store.ts +60 -0
  47. package/src/services/claude-cli.ts +19 -22
  48. package/src/services/claude-settings.ts +99 -26
  49. package/src/services/conventions-manager.ts +865 -0
  50. package/src/services/doctor-bins.ts +49 -0
  51. package/src/services/doctor.ts +509 -0
  52. package/src/services/install-plan.ts +107 -0
  53. package/src/services/manifest.ts +166 -0
  54. package/src/services/marketplace-refresh.ts +173 -0
  55. package/src/services/plugin-manager.ts +7 -1
  56. package/src/services/plugin-requires.ts +215 -0
  57. package/src/services/plugin-setup.ts +61 -4
  58. package/src/services/plugin-version-check.ts +9 -4
  59. package/src/services/profile-materializer.ts +61 -0
  60. package/src/services/profile-sync.ts +150 -0
  61. package/src/services/resolver.ts +293 -0
  62. package/src/services/symlink-manager.ts +146 -0
  63. package/src/services/toolchain.ts +97 -0
  64. package/src/services/version-check.ts +10 -11
  65. package/src/types/index.ts +138 -31
  66. package/src/ui/App.tsx +16 -11
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/modals/ConfirmModal.tsx +1 -1
  69. package/src/ui/screens/AliasScreen.tsx +491 -359
  70. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  71. package/src/ui/screens/PluginsScreen.tsx +3 -1
  72. package/src/ui/screens/index.ts +0 -1
  73. package/src/ui/state/reducer.ts +6 -102
  74. package/src/ui/state/types.ts +8 -35
  75. package/src/utils/command-utils.ts +23 -0
  76. package/src/utils/file-locking.ts +144 -0
  77. package/src/data/alias-flags.js +0 -196
  78. package/src/data/cli-tools.js +0 -123
  79. package/src/data/gitignore-defaults.js +0 -24
  80. package/src/data/gitignore-reasons.js +0 -97
  81. package/src/data/gitignore-templates.js +0 -21
  82. package/src/data/marketplaces.js +0 -138
  83. package/src/data/mcp-servers.js +0 -509
  84. package/src/data/predefined-profiles.js +0 -248
  85. package/src/data/settings-catalog.js +0 -625
  86. package/src/data/skill-repos.js +0 -160
  87. package/src/data/statuslines.js +0 -159
  88. package/src/data/statuslines.ts +0 -188
  89. package/src/index.js +0 -4
  90. package/src/index.ts +0 -5
  91. package/src/main.js +0 -170
  92. package/src/prerunner/index.js +0 -252
  93. package/src/services/alias-settings.js +0 -51
  94. package/src/services/alias-shell-writer.js +0 -764
  95. package/src/services/alias-store.js +0 -77
  96. package/src/services/claude-cli.js +0 -189
  97. package/src/services/claude-runner.js +0 -28
  98. package/src/services/claude-settings.js +0 -1223
  99. package/src/services/gitignore-detector.js +0 -155
  100. package/src/services/gitignore-fixer.js +0 -231
  101. package/src/services/gitignore-prerun.js +0 -46
  102. package/src/services/gitignore-resolver.js +0 -143
  103. package/src/services/gitignore-service.js +0 -117
  104. package/src/services/local-marketplace.js +0 -339
  105. package/src/services/marketplace-fetcher.js +0 -96
  106. package/src/services/marketplace-sync.js +0 -94
  107. package/src/services/mcp-registry.js +0 -87
  108. package/src/services/plugin-manager.js +0 -473
  109. package/src/services/plugin-mcp-config.js +0 -177
  110. package/src/services/plugin-setup.js +0 -444
  111. package/src/services/plugin-version-check.js +0 -262
  112. package/src/services/profiles.js +0 -161
  113. package/src/services/settings-manager.js +0 -243
  114. package/src/services/skills-manager.js +0 -393
  115. package/src/services/skillsmp-client.js +0 -87
  116. package/src/services/update-cache.js +0 -52
  117. package/src/services/version-check.js +0 -99
  118. package/src/types/gitignore.js +0 -6
  119. package/src/types/index.js +0 -1
  120. package/src/ui/App.js +0 -346
  121. package/src/ui/adapters/pluginsAdapter.js +0 -139
  122. package/src/ui/adapters/settingsAdapter.js +0 -111
  123. package/src/ui/adapters/skillsAdapter.js +0 -154
  124. package/src/ui/components/CategoryHeader.js +0 -9
  125. package/src/ui/components/EmptyFilterState.js +0 -4
  126. package/src/ui/components/ScopeIndicator.js +0 -30
  127. package/src/ui/components/ScrollableList.js +0 -36
  128. package/src/ui/components/SearchInput.js +0 -19
  129. package/src/ui/components/StyledText.js +0 -39
  130. package/src/ui/components/TabBar.js +0 -19
  131. package/src/ui/components/layout/FooterHints.js +0 -29
  132. package/src/ui/components/layout/Panel.js +0 -6
  133. package/src/ui/components/layout/ProgressBar.js +0 -14
  134. package/src/ui/components/layout/ScopeTabs.js +0 -6
  135. package/src/ui/components/layout/ScreenLayout.js +0 -16
  136. package/src/ui/components/layout/index.js +0 -5
  137. package/src/ui/components/modals/ConfirmModal.js +0 -14
  138. package/src/ui/components/modals/InputModal.js +0 -5
  139. package/src/ui/components/modals/LoadingModal.js +0 -17
  140. package/src/ui/components/modals/MessageModal.js +0 -16
  141. package/src/ui/components/modals/ModalContainer.js +0 -137
  142. package/src/ui/components/modals/SelectModal.js +0 -18
  143. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  144. package/src/ui/components/modals/index.js +0 -6
  145. package/src/ui/components/primitives/ActionHints.js +0 -13
  146. package/src/ui/components/primitives/DetailSection.js +0 -7
  147. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  148. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  149. package/src/ui/components/primitives/MetaText.js +0 -8
  150. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  151. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  152. package/src/ui/components/primitives/SelectableRow.js +0 -5
  153. package/src/ui/components/primitives/index.js +0 -8
  154. package/src/ui/hooks/index.js +0 -4
  155. package/src/ui/hooks/useAsyncData.js +0 -77
  156. package/src/ui/hooks/useGitignoreModal.js +0 -74
  157. package/src/ui/hooks/useKeyboard.js +0 -13
  158. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  159. package/src/ui/hooks/useMismatchModal.js +0 -77
  160. package/src/ui/registry.js +0 -1
  161. package/src/ui/renderers/cliToolRenderers.js +0 -54
  162. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  163. package/src/ui/renderers/mcpRenderers.js +0 -26
  164. package/src/ui/renderers/pluginRenderers.js +0 -124
  165. package/src/ui/renderers/profileRenderers.js +0 -177
  166. package/src/ui/renderers/settingsRenderers.js +0 -73
  167. package/src/ui/renderers/skillRenderers.js +0 -138
  168. package/src/ui/screens/AliasScreen.js +0 -1008
  169. package/src/ui/screens/CliToolsScreen.js +0 -338
  170. package/src/ui/screens/EnvVarsScreen.js +0 -152
  171. package/src/ui/screens/GitignoreScreen.js +0 -328
  172. package/src/ui/screens/McpRegistryScreen.js +0 -238
  173. package/src/ui/screens/McpScreen.js +0 -176
  174. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  175. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  176. package/src/ui/screens/PluginsScreen.js +0 -766
  177. package/src/ui/screens/ProfilesScreen.js +0 -298
  178. package/src/ui/screens/SkillsScreen.js +0 -549
  179. package/src/ui/screens/StatusLineScreen.js +0 -206
  180. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  181. package/src/ui/screens/index.js +0 -10
  182. package/src/ui/state/AnimationContext.js +0 -34
  183. package/src/ui/state/AppContext.js +0 -162
  184. package/src/ui/state/DimensionsContext.js +0 -71
  185. package/src/ui/state/reducer.js +0 -543
  186. package/src/ui/state/types.js +0 -1
  187. package/src/ui/theme.js +0 -47
  188. package/src/utils/clipboard.js +0 -56
  189. package/src/utils/command-utils.js +0 -19
  190. package/src/utils/fuzzy-search.js +0 -101
  191. package/src/utils/string-utils.js +0 -62
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Team profile manifest (.claude/profiles.json) — read, migrate, validate.
3
+ *
4
+ * This is the v2 view over the same project-scoped profiles.json that the TUI's
5
+ * v1 saved-profiles code (services/profiles.ts) reads. v1 files migrate forward
6
+ * transparently: a v1 profile (`{name, plugins: Record<string,boolean>}`) is a
7
+ * structural subset of a v2 manifest entry, so migration is a version bump.
8
+ */
9
+
10
+ import fs from "fs-extra";
11
+ import path from "node:path";
12
+ import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
13
+
14
+ const PROFILES_FILE = "profiles.json";
15
+
16
+ /** Project-scoped manifest path: <projectPath|cwd>/.claude/profiles.json */
17
+ export function getManifestPath(projectPath?: string): string {
18
+ const base = projectPath ?? process.cwd();
19
+ return path.join(base, ".claude", PROFILES_FILE);
20
+ }
21
+
22
+ /** An empty, valid v2 manifest. */
23
+ export function emptyManifest(): ProfileManifest {
24
+ return { version: 2, profiles: {} };
25
+ }
26
+
27
+ /**
28
+ * Normalize any parsed profiles.json (v1 or v2) into a v2 manifest.
29
+ * Unknown/missing shapes degrade to an empty manifest rather than throwing —
30
+ * validation is the caller's job via {@link validateManifest}.
31
+ */
32
+ export function migrateToV2(raw: unknown): ProfileManifest {
33
+ if (!raw || typeof raw !== "object") return emptyManifest();
34
+ const obj = raw as Record<string, unknown>;
35
+
36
+ const profilesRaw =
37
+ obj.profiles && typeof obj.profiles === "object"
38
+ ? (obj.profiles as Record<string, unknown>)
39
+ : {};
40
+
41
+ const profiles: Record<string, ProfileManifestEntry> = {};
42
+ for (const [id, entry] of Object.entries(profilesRaw)) {
43
+ if (entry && typeof entry === "object") {
44
+ // v1 and v2 entries are both accepted as-is; v1's plugins map
45
+ // (Record<string, boolean>) is valid under v2's widened type.
46
+ profiles[id] = entry as ProfileManifestEntry;
47
+ }
48
+ }
49
+
50
+ const manifest: ProfileManifest = { version: 2, profiles };
51
+ // Only carry strictVersions when enabled, so a committed manifest that
52
+ // doesn't opt in stays minimal (no spurious `"strictVersions": false`).
53
+ if (obj.strictVersions === true) manifest.strictVersions = true;
54
+ return manifest;
55
+ }
56
+
57
+ /**
58
+ * Read and migrate the project manifest. Returns an empty v2 manifest if the
59
+ * file is absent or unreadable.
60
+ */
61
+ export async function readManifest(
62
+ projectPath?: string,
63
+ ): Promise<ProfileManifest> {
64
+ const filePath = getManifestPath(projectPath);
65
+ try {
66
+ if (await fs.pathExists(filePath)) {
67
+ return migrateToV2(await fs.readJson(filePath));
68
+ }
69
+ } catch {
70
+ // Corrupt/unreadable — fall through to empty.
71
+ }
72
+ return emptyManifest();
73
+ }
74
+
75
+ /** Write the manifest back to disk (used by `profile sync`, tests). */
76
+ export async function writeManifest(
77
+ manifest: ProfileManifest,
78
+ projectPath?: string,
79
+ ): Promise<void> {
80
+ const filePath = getManifestPath(projectPath);
81
+ await fs.ensureDir(path.dirname(filePath));
82
+ await fs.writeJson(filePath, manifest, { spaces: 2 });
83
+ }
84
+
85
+ export interface ManifestValidationError {
86
+ /** Dotted path to the offending field, e.g. "profiles.frontend.plugins". */
87
+ path: string;
88
+ message: string;
89
+ }
90
+
91
+ /**
92
+ * Validate a manifest structurally. Returns an empty array when valid.
93
+ * Strict enough to back `install --check`; does not touch the network or FS.
94
+ */
95
+ export function validateManifest(
96
+ manifest: ProfileManifest,
97
+ ): ManifestValidationError[] {
98
+ const errors: ManifestValidationError[] = [];
99
+
100
+ if (manifest.version !== 2) {
101
+ errors.push({
102
+ path: "version",
103
+ message: `expected version 2, got ${JSON.stringify(manifest.version)}`,
104
+ });
105
+ }
106
+
107
+ const ids = Object.keys(manifest.profiles ?? {});
108
+ if (ids.length === 0) {
109
+ errors.push({ path: "profiles", message: "no profiles defined" });
110
+ }
111
+
112
+ for (const [id, entry] of Object.entries(manifest.profiles ?? {})) {
113
+ const base = `profiles.${id}`;
114
+ if (!entry || typeof entry !== "object") {
115
+ errors.push({ path: base, message: "profile must be an object" });
116
+ continue;
117
+ }
118
+ if (!entry.name || typeof entry.name !== "string") {
119
+ errors.push({ path: `${base}.name`, message: "missing name" });
120
+ }
121
+ if (entry.plugins !== undefined) {
122
+ for (const [pluginId, ver] of Object.entries(entry.plugins)) {
123
+ if (typeof ver !== "string" && typeof ver !== "boolean") {
124
+ errors.push({
125
+ path: `${base}.plugins.${pluginId}`,
126
+ message: "version must be a string or boolean",
127
+ });
128
+ }
129
+ if (!pluginId.includes("@")) {
130
+ errors.push({
131
+ path: `${base}.plugins.${pluginId}`,
132
+ message: "plugin id must be in the form name@marketplace",
133
+ });
134
+ }
135
+ }
136
+ }
137
+ if (entry.marketplaces !== undefined) {
138
+ for (const [mpName, ref] of Object.entries(entry.marketplaces)) {
139
+ if (ref.source === "github" && !ref.repo) {
140
+ errors.push({
141
+ path: `${base}.marketplaces.${mpName}`,
142
+ message: "github marketplace requires `repo`",
143
+ });
144
+ }
145
+ if (ref.source === "directory" && !ref.path) {
146
+ errors.push({
147
+ path: `${base}.marketplaces.${mpName}`,
148
+ message: "directory marketplace requires `path`",
149
+ });
150
+ }
151
+ }
152
+ }
153
+ if (entry.skills !== undefined) {
154
+ entry.skills.forEach((s, i) => {
155
+ if (!s.name || !s.repo || !s.path) {
156
+ errors.push({
157
+ path: `${base}.skills[${i}]`,
158
+ message: "skill requires name, repo, and path",
159
+ });
160
+ }
161
+ });
162
+ }
163
+ }
164
+
165
+ return errors;
166
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Keep on-disk marketplace clones current — safely.
3
+ *
4
+ * The plugin *list* claudeup shows is fetched fresh from GitHub, but a plugin's
5
+ * `setup` block and Claude Code's own installer read the on-disk marketplace
6
+ * clone (~/.claude/plugins/marketplaces/<name>). Nothing else keeps that clone
7
+ * current — recoverMarketplaceSettings only pulls entries it had to re-register —
8
+ * so a healthy marketplace's clone goes stale and its plugin metadata (e.g. a
9
+ * pinned dependency version in a plugin's setup) is never seen, even while the
10
+ * displayed version looks up to date.
11
+ *
12
+ * IMPORTANT: this refreshes the clone with a NON-DESTRUCTIVE `git pull --ff-only`,
13
+ * never `claude plugin marketplace update`. The latter (cacheMarketplaceFromGit)
14
+ * deletes the clone directory before re-cloning, so a failed clone leaves the
15
+ * marketplace permanently deleted — a documented hazard this codebase deliberately
16
+ * avoids (see marketplace-sync.ts and ai-docs/plugin-marketplace-bug-investigation.md).
17
+ * A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import {
24
+ getConfiguredMarketplaces,
25
+ getGlobalClaudeDir,
26
+ getMarketplaceAutoUpdate,
27
+ } from "./claude-settings.js";
28
+
29
+ /** Per-git-invocation timeout. A hung pull must never block launching Claude. */
30
+ const GIT_TIMEOUT_MS = 30_000;
31
+
32
+ export interface RefreshResult {
33
+ /** Clones fast-forwarded to a newer commit (or already current). */
34
+ refreshed: string[];
35
+ /** Pull errored (network, diverged, timeout) — clone left intact and stale. */
36
+ failed: string[];
37
+ /** Nothing to do: no clone on disk, not a git repo, dirty tree, or opted out. */
38
+ skipped: string[];
39
+ }
40
+
41
+ function cloneDir(name: string): string {
42
+ return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
43
+ }
44
+
45
+ /** Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`. */
46
+ function runGit(
47
+ cwd: string,
48
+ args: string[],
49
+ timeoutMs: number,
50
+ ): Promise<string> {
51
+ return new Promise((resolve, reject) => {
52
+ const proc = spawn("git", args, {
53
+ cwd,
54
+ timeout: timeoutMs,
55
+ killSignal: "SIGKILL",
56
+ });
57
+ let stdout = "";
58
+ let stderr = "";
59
+ proc.stdout?.on("data", (b) => {
60
+ stdout += b.toString();
61
+ });
62
+ proc.stderr?.on("data", (b) => {
63
+ stderr += b.toString();
64
+ });
65
+ proc.on("error", reject);
66
+ proc.on("close", (code, signal) => {
67
+ if (code === 0) {
68
+ resolve(stdout);
69
+ } else {
70
+ reject(
71
+ new Error(
72
+ `git ${args.join(" ")} ${
73
+ signal ? `killed (${signal})` : `exited ${code}`
74
+ }: ${stderr.trim()}`,
75
+ ),
76
+ );
77
+ }
78
+ });
79
+ });
80
+ }
81
+
82
+ /** True if the clone has uncommitted changes we must not clobber. */
83
+ async function isDirty(dir: string): Promise<boolean> {
84
+ const out = await runGit(dir, ["status", "--porcelain"], GIT_TIMEOUT_MS);
85
+ return out.trim().length > 0;
86
+ }
87
+
88
+ /**
89
+ * Outcome of fast-forwarding one clone:
90
+ * - `absent` no clone on disk (caller may create it via `marketplace add`)
91
+ * - `skipped` dirty tree — left untouched so local edits survive
92
+ * - `refreshed` fast-forwarded, or already current
93
+ * - `failed` pull errored (network, diverged, timeout) — clone left intact
94
+ */
95
+ export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
96
+
97
+ /**
98
+ * Non-destructively fast-forward ONE marketplace clone. Never throws, never
99
+ * deletes: the worst case is `failed` (stale clone left intact). Shared by the
100
+ * batch refresh below and by claude-cli's "plugin not found" recovery, so both
101
+ * paths get the same safe semantics instead of the old `marketplace update`.
102
+ */
103
+ export async function fastForwardCloneIfPresent(
104
+ name: string,
105
+ ): Promise<CloneRefresh> {
106
+ const dir = cloneDir(name);
107
+ // No clone on disk (claude never installed it) or not a git repo.
108
+ if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
109
+ try {
110
+ // Never clobber a user's local edits to the clone.
111
+ if (await isDirty(dir)) return "skipped";
112
+ // --ff-only is the safety guarantee: it fetches and fast-forwards, and
113
+ // aborts without mutating the tree if it can't. It never deletes the dir,
114
+ // so a failure degrades to "stale clone", never "deleted marketplace".
115
+ await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
116
+ return "refreshed";
117
+ } catch {
118
+ return "failed";
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Non-destructively fast-forward the on-disk clone of every registered,
124
+ * auto-update-enabled, github-sourced marketplace.
125
+ *
126
+ * @param skip marketplaces already refreshed this run (e.g. re-registered ones),
127
+ * so they aren't pulled twice.
128
+ * @returns which marketplaces were refreshed, which failed (clone left intact),
129
+ * and which were skipped. Failures and skips are collected rather than thrown —
130
+ * one unreachable marketplace must not block launching Claude. Pulls run
131
+ * concurrently, so a slow one doesn't serialize the rest onto the launch path.
132
+ */
133
+ export async function refreshRegisteredMarketplaces(
134
+ skip: Iterable<string> = [],
135
+ ): Promise<RefreshResult> {
136
+ const refreshed: string[] = [];
137
+ const failed: string[] = [];
138
+ const skipped: string[] = [];
139
+
140
+ const configured = await getConfiguredMarketplaces();
141
+ const skipSet = new Set(skip);
142
+
143
+ // Select eligible marketplaces first so the pulls can run concurrently.
144
+ const eligible: string[] = [];
145
+ for (const [name, cfg] of Object.entries(configured)) {
146
+ if (skipSet.has(name)) continue;
147
+ if (cfg.source?.source !== "github" || !cfg.source?.repo) {
148
+ skipped.push(name);
149
+ continue;
150
+ }
151
+ if ((await getMarketplaceAutoUpdate(name)) === false) {
152
+ skipped.push(name);
153
+ continue;
154
+ }
155
+ eligible.push(name);
156
+ }
157
+
158
+ // Fast-forward every eligible clone in parallel so a slow one doesn't
159
+ // serialize the rest onto the launch path. fastForwardCloneIfPresent never
160
+ // throws, so the batch always resolves.
161
+ const statuses = await Promise.all(
162
+ eligible.map((name) => fastForwardCloneIfPresent(name)),
163
+ );
164
+
165
+ statuses.forEach((status, i) => {
166
+ const name = eligible[i];
167
+ if (status === "refreshed") refreshed.push(name);
168
+ else if (status === "failed") failed.push(name);
169
+ else skipped.push(name); // absent | skipped
170
+ });
171
+
172
+ return { refreshed, failed, skipped };
173
+ }
@@ -11,6 +11,7 @@ import {
11
11
  getGlobalInstalledPluginVersions,
12
12
  getLocalEnabledPlugins,
13
13
  getLocalInstalledPluginVersions,
14
+ getProjectInstalledPluginVersions,
14
15
  updateInstalledPluginsRegistry,
15
16
  removeFromInstalledPluginsRegistry,
16
17
  } from "./claude-settings.js";
@@ -470,7 +471,12 @@ async function getInstalledPluginVersions(
470
471
  projectPath?: string,
471
472
  ): Promise<Record<string, string>> {
472
473
  const settings = await readSettings(projectPath);
473
- return settings.installedPluginVersions || {};
474
+ // Settings alone under-report: Claude Code's own `plugin install` updates the
475
+ // registry without touching our settings key, so overlay the registry.
476
+ return getProjectInstalledPluginVersions(
477
+ settings.installedPluginVersions || {},
478
+ projectPath,
479
+ );
474
480
  }
475
481
 
476
482
  // Save installed plugin version to settings.json
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Read plugin-declared binary dependencies from plugin.json `requires.bin`.
3
+ *
4
+ * A plugin declares the binaries it needs on PATH (e.g. terminal@magus needs
5
+ * `tmux` and `tmux-mcp`). `claudeup install` resolves these transitively so a
6
+ * teammate who enables a plugin also gets its binaries — closing the class of
7
+ * bug where a plugin's MCP server silently fails because its binary is missing.
8
+ */
9
+
10
+ import fs from "fs-extra";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import type {
14
+ BinInstaller,
15
+ BinRequirement,
16
+ PluginRequires,
17
+ } from "../types/index.js";
18
+ import { parsePluginId } from "../utils/string-utils.js";
19
+ import { getLocalMarketplacesInfo } from "./plugin-manager.js";
20
+
21
+ const MARKETPLACES_DIR = (): string =>
22
+ path.join(os.homedir(), ".claude", "plugins", "marketplaces");
23
+
24
+ const VALID_INSTALLERS: readonly BinInstaller[] = [
25
+ "bun",
26
+ "brew",
27
+ "npm",
28
+ "pip",
29
+ "go",
30
+ ];
31
+
32
+ /**
33
+ * Normalize a raw `requires.bin` entry into a complete BinRequirement,
34
+ * filling in defaults. Returns null for entries that can't be made valid
35
+ * (missing name, or an unrecognized installer).
36
+ */
37
+ function normalizeBin(raw: unknown): BinRequirement | null {
38
+ if (!raw || typeof raw !== "object") return null;
39
+ const o = raw as Record<string, unknown>;
40
+ const name = typeof o.name === "string" ? o.name.trim() : "";
41
+ if (!name) return null;
42
+
43
+ const via = o.via as BinInstaller;
44
+ if (!VALID_INSTALLERS.includes(via)) return null;
45
+
46
+ const bin: BinRequirement = {
47
+ name,
48
+ via,
49
+ check:
50
+ typeof o.check === "string" && o.check.trim()
51
+ ? o.check.trim()
52
+ : `${name} --version`,
53
+ };
54
+ if (typeof o.package === "string") bin.package = o.package;
55
+ if (typeof o.formula === "string") bin.formula = o.formula;
56
+ if (typeof o.module === "string") bin.module = o.module;
57
+ if (typeof o.version === "string") bin.version = o.version;
58
+ return bin;
59
+ }
60
+
61
+ /** Split a package/module spec into its base and optional version. Handles
62
+ * scoped npm (@scope/pkg@1.2.0) and go modules (host/path@latest). */
63
+ function splitVersion(spec: string): { base: string; version?: string } {
64
+ const at = spec.lastIndexOf("@");
65
+ // at > 0 avoids treating a leading npm scope "@scope/..." as a version.
66
+ if (at > 0) return { base: spec.slice(0, at), version: spec.slice(at + 1) };
67
+ return { base: spec };
68
+ }
69
+
70
+ const basename = (p: string): string => p.split("/").pop() ?? p;
71
+
72
+ /**
73
+ * Map a plugin.json `setup` block (the established convention: browser-use uses
74
+ * `pip`, terminal uses `go` + `required`) to BinRequirements.
75
+ * setup: { bun|npm|pip: string[], go: string[], brew: string[], required: string[] }
76
+ */
77
+ function parseSetup(setup: unknown): BinRequirement[] {
78
+ if (!setup || typeof setup !== "object") return [];
79
+ const s = setup as Record<string, unknown>;
80
+ const bins: BinRequirement[] = [];
81
+
82
+ const pkgManager = (
83
+ key: "bun" | "npm" | "pip",
84
+ ): void => {
85
+ for (const spec of (s[key] as string[] | undefined) ?? []) {
86
+ if (typeof spec !== "string") continue;
87
+ const { base, version } = splitVersion(spec);
88
+ bins.push({
89
+ name: basename(base),
90
+ via: key,
91
+ package: base,
92
+ version,
93
+ check: `${basename(base)} --version`,
94
+ });
95
+ }
96
+ };
97
+ pkgManager("bun");
98
+ pkgManager("npm");
99
+ pkgManager("pip");
100
+
101
+ for (const spec of (s.go as string[] | undefined) ?? []) {
102
+ if (typeof spec !== "string") continue;
103
+ const { base, version } = splitVersion(spec);
104
+ bins.push({
105
+ name: basename(base),
106
+ via: "go",
107
+ module: base,
108
+ version,
109
+ check: `${basename(base)} --version`,
110
+ });
111
+ }
112
+ for (const spec of (s.brew as string[] | undefined) ?? []) {
113
+ if (typeof spec !== "string") continue;
114
+ bins.push({ name: spec, via: "brew", formula: spec, check: `${spec} --version` });
115
+ }
116
+ // `required` names a system tool that must be present; brew is the assumed
117
+ // installer (matches the "system tools via brew" decision).
118
+ for (const name of (s.required as string[] | undefined) ?? []) {
119
+ if (typeof name !== "string") continue;
120
+ bins.push({ name, via: "brew", formula: name, check: `${name} --version` });
121
+ }
122
+
123
+ return bins;
124
+ }
125
+
126
+ /**
127
+ * Parse a plugin.json's binary requirements from BOTH the established `setup`
128
+ * block and the richer explicit `requires.bin` form. On a name collision the
129
+ * explicit `requires.bin` entry wins (it can override installer/check/version).
130
+ */
131
+ export function parsePluginRequires(pluginJson: unknown): PluginRequires {
132
+ if (!pluginJson || typeof pluginJson !== "object") return {};
133
+ const obj = pluginJson as Record<string, unknown>;
134
+
135
+ const fromSetup = parseSetup(obj.setup);
136
+
137
+ const requires = obj.requires;
138
+ const binRaw =
139
+ requires && typeof requires === "object"
140
+ ? (requires as Record<string, unknown>).bin
141
+ : undefined;
142
+ const fromRequires = Array.isArray(binRaw)
143
+ ? binRaw.map(normalizeBin).filter((b): b is BinRequirement => b !== null)
144
+ : [];
145
+
146
+ // Merge, requires.bin winning by name.
147
+ const byName = new Map<string, BinRequirement>();
148
+ for (const b of fromSetup) byName.set(b.name, b);
149
+ for (const b of fromRequires) byName.set(b.name, b);
150
+
151
+ const bin = [...byName.values()];
152
+ return bin.length > 0 ? { bin } : {};
153
+ }
154
+
155
+ /** Read `requires` from a plugin.json at a known plugin directory. */
156
+ export async function readPluginRequires(
157
+ pluginPath: string,
158
+ ): Promise<PluginRequires> {
159
+ const manifestPath = path.join(pluginPath, "plugin.json");
160
+ try {
161
+ if (await fs.pathExists(manifestPath)) {
162
+ return parsePluginRequires(await fs.readJson(manifestPath));
163
+ }
164
+ // Some plugins nest the manifest under .claude-plugin/plugin.json.
165
+ const nested = path.join(pluginPath, ".claude-plugin", "plugin.json");
166
+ if (await fs.pathExists(nested)) {
167
+ return parsePluginRequires(await fs.readJson(nested));
168
+ }
169
+ } catch {
170
+ // Unreadable/malformed — treat as no requirements.
171
+ }
172
+ return {};
173
+ }
174
+
175
+ /**
176
+ * Resolve a plugin id (name@marketplace) to its on-disk source directory in
177
+ * the local marketplace cache. Returns null if the marketplace/plugin/source
178
+ * can't be located. Mirrors doctor.ts's resolvePluginPath.
179
+ */
180
+ export async function resolvePluginPath(
181
+ pluginId: string,
182
+ ): Promise<string | null> {
183
+ const parsed = parsePluginId(pluginId);
184
+ if (!parsed) return null;
185
+ const { pluginName, marketplace } = parsed;
186
+
187
+ const localMarketplaces = await getLocalMarketplacesInfo();
188
+ const localMp = localMarketplaces.get(marketplace);
189
+ if (!localMp) return null;
190
+
191
+ const localPlugin = localMp.plugins.find((p) => p.name === pluginName);
192
+ if (!localPlugin?.source || typeof localPlugin.source !== "string") {
193
+ return null;
194
+ }
195
+
196
+ const pluginPath = path.join(
197
+ MARKETPLACES_DIR(),
198
+ marketplace,
199
+ localPlugin.source.replace("./", ""),
200
+ );
201
+ return (await fs.pathExists(pluginPath)) ? pluginPath : null;
202
+ }
203
+
204
+ /**
205
+ * Resolve the binary requirements a plugin declares. Returns an empty array
206
+ * when the plugin can't be located or declares none.
207
+ */
208
+ export async function resolvePluginBinRequirements(
209
+ pluginId: string,
210
+ ): Promise<BinRequirement[]> {
211
+ const pluginPath = await resolvePluginPath(pluginId);
212
+ if (!pluginPath) return [];
213
+ const requires = await readPluginRequires(pluginPath);
214
+ return requires.bin ?? [];
215
+ }
@@ -295,6 +295,65 @@ export function extractGoBinaryName(pkg: string): string {
295
295
  return withoutVersion.split("/").pop() || pkg;
296
296
  }
297
297
 
298
+ /**
299
+ * Extract the pinned version from a Go module path, or null when unpinned.
300
+ * e.g., "github.com/MadAppGang/tmux-mcp@v1.6.2" → "v1.6.2"
301
+ * "github.com/MadAppGang/tmux-mcp@latest" → null (a moving target)
302
+ * "github.com/user/tool" → null
303
+ */
304
+ export function extractGoVersion(pkg: string): string | null {
305
+ const at = pkg.lastIndexOf("@");
306
+ if (at === -1) return null;
307
+ const version = pkg.slice(at + 1);
308
+ if (version === "" || version === "latest") return null;
309
+ return version;
310
+ }
311
+
312
+ /** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
313
+ function normalizeVersion(v: string): string {
314
+ return v.trim().replace(/^v/, "");
315
+ }
316
+
317
+ /**
318
+ * Read an installed binary's version via `<binary> --version`, normalized, or
319
+ * null if it produced no usable version string. Used to tell whether a pinned Go
320
+ * dependency is already at the right version.
321
+ */
322
+ async function getInstalledBinaryVersion(
323
+ binaryName: string,
324
+ ): Promise<string | null> {
325
+ const { ok, stdout } = await run(binaryName, ["--version"], 10000);
326
+ if (!ok || !stdout) return null;
327
+ const match = stdout.split("\n")[0].match(/v?\d+\.\d+\.\d+[^\s]*/);
328
+ return match ? normalizeVersion(match[0]) : null;
329
+ }
330
+
331
+ /**
332
+ * Decide whether a Go dependency needs (re)installing. This is what makes a
333
+ * plugin *update* actually update its binary: without a version check an
334
+ * already-present binary is skipped forever, so a bumped plugin never pulls the
335
+ * new binary.
336
+ *
337
+ * - binary absent → yes
338
+ * - pinned version, installed reports the same one → no
339
+ * - pinned version, installed reports a different one → yes
340
+ * - pinned version, installed version can't be read → yes (can't confirm the
341
+ * pin, so reinstall to be safe)
342
+ * - unpinned (@latest) and present → no (a moving target
343
+ * can't be verified without a network round-trip)
344
+ */
345
+ export async function goDepNeedsInstall(pkg: string): Promise<boolean> {
346
+ const binaryName = extractGoBinaryName(pkg);
347
+ if (!(await isBinaryAvailable(binaryName))) return true;
348
+
349
+ const pinned = extractGoVersion(pkg);
350
+ if (!pinned) return false;
351
+
352
+ const installed = await getInstalledBinaryVersion(binaryName);
353
+ if (installed === null) return true;
354
+ return normalizeVersion(pinned) !== installed;
355
+ }
356
+
298
357
  /**
299
358
  * Install Go packages via `go install`
300
359
  */
@@ -311,8 +370,7 @@ async function installGoPackages(
311
370
  }
312
371
 
313
372
  for (const pkg of packages) {
314
- const binaryName = extractGoBinaryName(pkg);
315
- if (await isBinaryAvailable(binaryName)) {
373
+ if (!(await goDepNeedsInstall(pkg))) {
316
374
  result.skipped.push(`go:${pkg}`);
317
375
  continue;
318
376
  }
@@ -515,8 +573,7 @@ export async function checkMissingDeps(
515
573
  if (setup.go?.length) {
516
574
  const missingGo: string[] = [];
517
575
  for (const pkg of setup.go) {
518
- const bin = extractGoBinaryName(pkg);
519
- if (!(await isBinaryAvailable(bin))) {
576
+ if (await goDepNeedsInstall(pkg)) {
520
577
  missingGo.push(pkg);
521
578
  }
522
579
  }
@@ -1,14 +1,19 @@
1
1
  /**
2
2
  * Plugin version mismatch detection and fix.
3
3
  *
4
- * Claude Code's plugin loader (pluginLoader.ts:2051) takes the FIRST entry
4
+ * HISTORICAL (pre-2.1.209): Claude Code's plugin loader took the FIRST entry
5
5
  * from installed_plugins.json for each plugin ID:
6
6
  *
7
7
  * const installEntry = installedPluginsData.plugins[pluginId]?.[0]
8
8
  *
9
- * This means the first project to install a plugin determines which version
10
- * loads for ALL projects. When different projects install different versions,
11
- * only the one at index [0] takes effect.
9
+ * so the first project to install a plugin determined the version loaded for
10
+ * ALL projects (bug #45997).
11
+ *
12
+ * FIXED in Claude Code 2.1.209: the loader is now project-aware — it filters
13
+ * entries to the current project/scope (same git root or user/managed scope)
14
+ * BEFORE selecting, so cross-project [0] contention no longer applies. This
15
+ * detection therefore mostly warns on older Claude Code; it is retained for
16
+ * those versions but should not be treated as a current-version defect.
12
17
  *
13
18
  * Bug report: https://github.com/anthropics/claude-code/issues/45997
14
19
  */