claudeup 4.19.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 (180) 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__/cli-router.test.ts +65 -0
  7. package/src/__tests__/conventions-integration.test.ts +745 -0
  8. package/src/__tests__/conventions-manager.test.ts +1172 -0
  9. package/src/__tests__/doctor-bins.test.ts +35 -0
  10. package/src/__tests__/doctor.test.ts +450 -0
  11. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  12. package/src/__tests__/file-locking.test.ts +208 -0
  13. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  14. package/src/__tests__/install-command.test.ts +81 -0
  15. package/src/__tests__/install-plan.test.ts +98 -0
  16. package/src/__tests__/manifest.test.ts +136 -0
  17. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  18. package/src/__tests__/plugin-requires.test.ts +133 -0
  19. package/src/__tests__/plugin-setup.test.ts +7 -0
  20. package/src/__tests__/profile-command.test.ts +116 -0
  21. package/src/__tests__/profile-materializer.test.ts +82 -0
  22. package/src/__tests__/profile-sync.test.ts +136 -0
  23. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  24. package/src/__tests__/resolve-executable.test.ts +42 -0
  25. package/src/__tests__/resolver.test.ts +218 -0
  26. package/src/__tests__/symlink-manager.test.ts +99 -0
  27. package/src/__tests__/toolchain.test.ts +56 -0
  28. package/src/cli/claude.ts +21 -0
  29. package/src/cli/doctor.ts +132 -0
  30. package/src/cli/install.ts +360 -0
  31. package/src/cli/profile.ts +166 -0
  32. package/src/cli/router.ts +107 -0
  33. package/src/cli/update.ts +85 -0
  34. package/src/data/cli-tools.ts +7 -7
  35. package/src/data/gitignore-defaults.ts +4 -0
  36. package/src/data/marketplaces.ts +12 -0
  37. package/src/data/predefined-profiles.ts +3 -4
  38. package/src/main.tsx +11 -154
  39. package/src/prerunner/index.ts +62 -27
  40. package/src/services/claude-cli.ts +19 -22
  41. package/src/services/claude-settings.ts +99 -26
  42. package/src/services/conventions-manager.ts +865 -0
  43. package/src/services/doctor-bins.ts +49 -0
  44. package/src/services/doctor.ts +509 -0
  45. package/src/services/install-plan.ts +107 -0
  46. package/src/services/manifest.ts +166 -0
  47. package/src/services/marketplace-refresh.ts +173 -0
  48. package/src/services/plugin-manager.ts +7 -1
  49. package/src/services/plugin-requires.ts +215 -0
  50. package/src/services/plugin-version-check.ts +9 -4
  51. package/src/services/profile-materializer.ts +61 -0
  52. package/src/services/profile-sync.ts +150 -0
  53. package/src/services/resolver.ts +293 -0
  54. package/src/services/symlink-manager.ts +146 -0
  55. package/src/services/toolchain.ts +97 -0
  56. package/src/services/version-check.ts +10 -11
  57. package/src/types/index.ts +138 -31
  58. package/src/ui/App.tsx +0 -4
  59. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  60. package/src/ui/screens/index.ts +0 -1
  61. package/src/ui/state/reducer.ts +0 -101
  62. package/src/ui/state/types.ts +0 -35
  63. package/src/utils/command-utils.ts +23 -0
  64. package/src/utils/file-locking.ts +144 -0
  65. package/src/data/alias-flags.js +0 -205
  66. package/src/data/cli-tools.js +0 -123
  67. package/src/data/gitignore-defaults.js +0 -24
  68. package/src/data/gitignore-reasons.js +0 -97
  69. package/src/data/gitignore-templates.js +0 -21
  70. package/src/data/marketplaces.js +0 -138
  71. package/src/data/mcp-servers.js +0 -509
  72. package/src/data/predefined-profiles.js +0 -248
  73. package/src/data/settings-catalog.js +0 -625
  74. package/src/data/skill-repos.js +0 -160
  75. package/src/data/statuslines.js +0 -159
  76. package/src/data/statuslines.ts +0 -188
  77. package/src/index.js +0 -4
  78. package/src/index.ts +0 -5
  79. package/src/main.js +0 -170
  80. package/src/prerunner/index.js +0 -252
  81. package/src/services/alias-settings.js +0 -51
  82. package/src/services/alias-shell-writer.js +0 -1018
  83. package/src/services/alias-store.js +0 -129
  84. package/src/services/claude-cli.js +0 -189
  85. package/src/services/claude-runner.js +0 -28
  86. package/src/services/claude-settings.js +0 -1223
  87. package/src/services/gitignore-detector.js +0 -155
  88. package/src/services/gitignore-fixer.js +0 -231
  89. package/src/services/gitignore-prerun.js +0 -46
  90. package/src/services/gitignore-resolver.js +0 -143
  91. package/src/services/gitignore-service.js +0 -117
  92. package/src/services/local-marketplace.js +0 -339
  93. package/src/services/marketplace-fetcher.js +0 -96
  94. package/src/services/marketplace-sync.js +0 -94
  95. package/src/services/mcp-registry.js +0 -87
  96. package/src/services/plugin-manager.js +0 -473
  97. package/src/services/plugin-mcp-config.js +0 -177
  98. package/src/services/plugin-setup.js +0 -499
  99. package/src/services/plugin-version-check.js +0 -262
  100. package/src/services/profiles.js +0 -161
  101. package/src/services/settings-manager.js +0 -243
  102. package/src/services/skills-manager.js +0 -393
  103. package/src/services/skillsmp-client.js +0 -87
  104. package/src/services/update-cache.js +0 -52
  105. package/src/services/version-check.js +0 -99
  106. package/src/types/gitignore.js +0 -6
  107. package/src/types/index.js +0 -1
  108. package/src/ui/App.js +0 -355
  109. package/src/ui/adapters/pluginsAdapter.js +0 -139
  110. package/src/ui/adapters/settingsAdapter.js +0 -111
  111. package/src/ui/adapters/skillsAdapter.js +0 -154
  112. package/src/ui/components/CategoryHeader.js +0 -9
  113. package/src/ui/components/EmptyFilterState.js +0 -4
  114. package/src/ui/components/FlagDetailEditor.js +0 -0
  115. package/src/ui/components/ScopeIndicator.js +0 -30
  116. package/src/ui/components/ScrollableList.js +0 -36
  117. package/src/ui/components/SearchInput.js +0 -19
  118. package/src/ui/components/StyledText.js +0 -39
  119. package/src/ui/components/TabBar.js +0 -19
  120. package/src/ui/components/layout/FooterHints.js +0 -29
  121. package/src/ui/components/layout/Panel.js +0 -6
  122. package/src/ui/components/layout/ProgressBar.js +0 -14
  123. package/src/ui/components/layout/ScopeTabs.js +0 -6
  124. package/src/ui/components/layout/ScreenLayout.js +0 -16
  125. package/src/ui/components/layout/index.js +0 -5
  126. package/src/ui/components/modals/ConfirmModal.js +0 -14
  127. package/src/ui/components/modals/InputModal.js +0 -5
  128. package/src/ui/components/modals/LoadingModal.js +0 -17
  129. package/src/ui/components/modals/MessageModal.js +0 -16
  130. package/src/ui/components/modals/ModalContainer.js +0 -137
  131. package/src/ui/components/modals/SelectModal.js +0 -18
  132. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  133. package/src/ui/components/modals/index.js +0 -6
  134. package/src/ui/components/primitives/ActionHints.js +0 -13
  135. package/src/ui/components/primitives/DetailSection.js +0 -7
  136. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  137. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  138. package/src/ui/components/primitives/MetaText.js +0 -8
  139. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  140. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  141. package/src/ui/components/primitives/SelectableRow.js +0 -5
  142. package/src/ui/components/primitives/index.js +0 -8
  143. package/src/ui/hooks/index.js +0 -4
  144. package/src/ui/hooks/useAsyncData.js +0 -77
  145. package/src/ui/hooks/useGitignoreModal.js +0 -74
  146. package/src/ui/hooks/useKeyboard.js +0 -13
  147. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  148. package/src/ui/hooks/useMismatchModal.js +0 -77
  149. package/src/ui/registry.js +0 -1
  150. package/src/ui/renderers/cliToolRenderers.js +0 -54
  151. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  152. package/src/ui/renderers/mcpRenderers.js +0 -26
  153. package/src/ui/renderers/pluginRenderers.js +0 -124
  154. package/src/ui/renderers/profileRenderers.js +0 -177
  155. package/src/ui/renderers/settingsRenderers.js +0 -73
  156. package/src/ui/renderers/skillRenderers.js +0 -138
  157. package/src/ui/screens/AliasScreen.js +0 -1111
  158. package/src/ui/screens/CliToolsScreen.js +0 -338
  159. package/src/ui/screens/EnvVarsScreen.js +0 -152
  160. package/src/ui/screens/GitignoreScreen.js +0 -328
  161. package/src/ui/screens/McpRegistryScreen.js +0 -238
  162. package/src/ui/screens/McpScreen.js +0 -176
  163. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  164. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  165. package/src/ui/screens/PluginsScreen.js +0 -769
  166. package/src/ui/screens/ProfilesScreen.js +0 -298
  167. package/src/ui/screens/SkillsScreen.js +0 -549
  168. package/src/ui/screens/StatusLineScreen.js +0 -206
  169. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  170. package/src/ui/screens/index.js +0 -10
  171. package/src/ui/state/AnimationContext.js +0 -34
  172. package/src/ui/state/AppContext.js +0 -162
  173. package/src/ui/state/DimensionsContext.js +0 -71
  174. package/src/ui/state/reducer.js +0 -547
  175. package/src/ui/state/types.js +0 -1
  176. package/src/ui/theme.js +0 -47
  177. package/src/utils/clipboard.js +0 -56
  178. package/src/utils/command-utils.js +0 -19
  179. package/src/utils/fuzzy-search.js +0 -101
  180. 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
+ }
@@ -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
  */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Materialize a resolved profile into `.claude/_profiles/<name>/`.
3
+ *
4
+ * Writes the shared config the symlink manager later points at:
5
+ * _profiles/<name>/settings.json — enabledPlugins + the profile's settings
6
+ * _profiles/<name>/mcp.json — the profile's MCP servers
7
+ * _profiles/<name>/skills/ — created empty; skills install into it
8
+ * (through the activated symlink)
9
+ *
10
+ * This is pure output derived from a ResolvedClosure — it performs no installs
11
+ * and touches no global state, so it is fully testable with a temp dir.
12
+ */
13
+
14
+ import fs from "fs-extra";
15
+ import path from "node:path";
16
+ import type { ResolvedClosure } from "../types/index.js";
17
+ import { profileDir } from "./symlink-manager.js";
18
+
19
+ /**
20
+ * Build the settings.json contents for a profile: every resolved plugin
21
+ * enabled, merged with the profile's declared settings. The profile's settings
22
+ * win over the derived enabledPlugins only if it explicitly sets that key
23
+ * (it normally won't).
24
+ */
25
+ export function buildProfileSettings(
26
+ closure: ResolvedClosure,
27
+ ): Record<string, unknown> {
28
+ const enabledPlugins: Record<string, boolean> = {};
29
+ for (const pluginId of Object.keys(closure.plugins)) {
30
+ enabledPlugins[pluginId] = true;
31
+ }
32
+ return { enabledPlugins, ...closure.settings };
33
+ }
34
+
35
+ /** Build the .mcp.json contents for a profile. */
36
+ export function buildProfileMcp(
37
+ closure: ResolvedClosure,
38
+ ): { mcpServers: Record<string, unknown> } {
39
+ return { mcpServers: { ...closure.mcpServers } };
40
+ }
41
+
42
+ /**
43
+ * Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
44
+ * settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
45
+ */
46
+ export async function materializeProfile(
47
+ name: string,
48
+ closure: ResolvedClosure,
49
+ projectPath?: string,
50
+ ): Promise<string> {
51
+ const dir = profileDir(name, projectPath);
52
+ await fs.ensureDir(dir);
53
+ await fs.writeJson(path.join(dir, "settings.json"), buildProfileSettings(closure), {
54
+ spaces: 2,
55
+ });
56
+ await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
57
+ spaces: 2,
58
+ });
59
+ await fs.ensureDir(path.join(dir, "skills"));
60
+ return dir;
61
+ }