magus-cli 7.12.2 → 7.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.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * marketplace-conflicts.ts — keeping a run's work away from a marketplace
3
+ * whose registration on this machine is not the one the profile names.
4
+ *
5
+ * A plugin id names its marketplace only by name. When that name is
6
+ * registered here from another repository, anything read for that plugin
7
+ * comes from the other repository: its dependencies, its tool requirements,
8
+ * its code. `magus install` and `magus update` share this filter, so neither
9
+ * plans an install, a dependency or a tool from such a marketplace.
10
+ */
11
+
12
+ import type { ResolvedClosure } from "../types/index.js";
13
+ import {
14
+ type PluginDependencyReader,
15
+ readPluginDependencies,
16
+ } from "./plugin-dependencies.js";
17
+ import {
18
+ ownedRequirement,
19
+ resolvePluginBinRequirements,
20
+ } from "./plugin-requires.js";
21
+ import type { ResolveOptions } from "./resolver.js";
22
+
23
+ /** True when `pluginId` (`name@marketplace`) comes from a conflicted marketplace. */
24
+ export function fromConflicted(
25
+ pluginId: string,
26
+ conflicted: ReadonlySet<string>,
27
+ ): boolean {
28
+ const at = pluginId.lastIndexOf("@");
29
+ return at > 0 && conflicted.has(pluginId.slice(at + 1));
30
+ }
31
+
32
+ /**
33
+ * Resolver options that read nothing for a conflicted marketplace's plugins:
34
+ * no dependencies and no tool requirements. Resolving with them keeps what
35
+ * only those plugins would bring out of the closure, rather than filtering
36
+ * it afterwards from provenance that records only the first requirer.
37
+ * `readDependencies` replaces how every other plugin's dependencies are read.
38
+ */
39
+ export function conflictFreeOptions(
40
+ conflicted: ReadonlySet<string>,
41
+ readDependencies?: PluginDependencyReader,
42
+ ): ResolveOptions {
43
+ if (conflicted.size === 0) {
44
+ return readDependencies ? { dependencyResolver: readDependencies } : {};
45
+ }
46
+ const read = readDependencies ?? readPluginDependencies;
47
+ const blocked = (id: string) => fromConflicted(id, conflicted);
48
+ return {
49
+ dependencyResolver: async (id, projectPath) =>
50
+ blocked(id) ? [] : read(id, projectPath),
51
+ binResolver: async (id) =>
52
+ blocked(id) ? [] : resolvePluginBinRequirements(id),
53
+ ownedResolver: async (id, name) =>
54
+ blocked(id) ? null : ownedRequirement(id, name),
55
+ };
56
+ }
57
+
58
+ export interface LeftOut {
59
+ marketplace: string;
60
+ plugins: string[];
61
+ }
62
+
63
+ /**
64
+ * The closure without the plugins of each conflicted marketplace, and
65
+ * without the tools only those plugins declare. Pass a closure resolved with
66
+ * {@link conflictFreeOptions}, so their dependencies were never added.
67
+ */
68
+ export function leaveOutConflicted(
69
+ closure: ResolvedClosure,
70
+ conflicted: ReadonlySet<string>,
71
+ ): { closure: ResolvedClosure; leftOut: LeftOut[] } {
72
+ if (conflicted.size === 0) return { closure, leftOut: [] };
73
+ const plugins = { ...closure.plugins };
74
+ const removed = new Set<string>();
75
+ const leftOut: LeftOut[] = [];
76
+ for (const marketplace of conflicted) {
77
+ const ids = Object.keys(plugins).filter((id) =>
78
+ fromConflicted(id, new Set([marketplace])),
79
+ );
80
+ for (const id of ids) {
81
+ delete plugins[id];
82
+ removed.add(id);
83
+ }
84
+ if (ids.length > 0) leftOut.push({ marketplace, plugins: ids });
85
+ }
86
+ const bins = closure.bins.filter((b) =>
87
+ b.sources.some((s) => !removed.has(s.replace(/^plugin:/, ""))),
88
+ );
89
+ return { closure: { ...closure, plugins, bins }, leftOut };
90
+ }
91
+
92
+ /**
93
+ * The profile's marketplaces whose plugins are left out of this machine's
94
+ * updates: those registered or trusted here from another source, and those
95
+ * that could not be compared at all. A plugin from either gets no work.
96
+ */
97
+ export interface MarketplaceCheck {
98
+ conflicted: ReadonlySet<string>;
99
+ /** Not compared, because reading this machine's side failed. */
100
+ unchecked: ReadonlySet<string>;
101
+ }
102
+
103
+ /** Nothing left out: no profile, or no marketplace that differs. */
104
+ export const NOTHING_LEFT_OUT: MarketplaceCheck = {
105
+ conflicted: new Set(),
106
+ unchecked: new Set(),
107
+ };
108
+
109
+ /**
110
+ * Why `pluginId` is left out, in the user's terms, or null when it is not.
111
+ * The words match `magus update`'s line for the same plugin.
112
+ */
113
+ export function leftOutWhy(
114
+ pluginId: string,
115
+ check: MarketplaceCheck,
116
+ ): string | null {
117
+ const at = pluginId.lastIndexOf("@");
118
+ if (at <= 0) return null;
119
+ const marketplace = pluginId.slice(at + 1);
120
+ if (check.conflicted.has(marketplace)) {
121
+ return `${marketplace} on this machine is not the one this project's profile names`;
122
+ }
123
+ if (check.unchecked.has(marketplace)) {
124
+ return `could not check that ${marketplace} on this machine is the one this project's profile names`;
125
+ }
126
+ return null;
127
+ }
@@ -31,6 +31,8 @@ import {
31
31
  getGlobalEnabledPlugins,
32
32
  getMarketplaceAutoUpdate,
33
33
  } from "./claude-settings.js";
34
+ import { gitEnv } from "./git-write.js";
35
+ import { trustedRegistrations } from "./market-registry.js";
34
36
 
35
37
  /** Per-git-invocation timeout. A hung pull must never block launching Claude. */
36
38
  const GIT_TIMEOUT_MS = 30_000;
@@ -106,7 +108,14 @@ async function runGit(
106
108
  timeoutMs: number,
107
109
  signal?: AbortSignal,
108
110
  ): Promise<string> {
109
- const r = await runBounded("git", args, { cwd, timeoutMs, signal });
111
+ // No prompt: a pull that needs a sign-in fails at once instead of waiting
112
+ // out the timeout on a prompt nobody sees.
113
+ const r = await runBounded("git", args, {
114
+ cwd,
115
+ timeoutMs,
116
+ signal,
117
+ env: gitEnv(),
118
+ });
110
119
  if (r.spawnError) throw r.spawnError;
111
120
  if (r.code === 0 && !r.aborted) return r.stdout;
112
121
  const how = r.aborted
@@ -276,12 +285,33 @@ async function selectAndRefresh(
276
285
  const configured = await getConfiguredMarketplaces();
277
286
  const skipSet = new Set(skip);
278
287
  const needed = await neededMarketplaces(options.pluginIds ?? []);
288
+ // Trusted company marketplaces, whatever their source type. A teammate's
289
+ // next `magus update` must see a publish made minutes ago, and only the
290
+ // clone carries a company plugin's catalog — so for these neither the
291
+ // GitHub-only rule, nor the `autoUpdate` flag, nor the once-an-hour rule
292
+ // applies: this machine chose to trust them, and a stale clone would hold
293
+ // a teammate on the old version. A `directory` registration has no clone;
294
+ // it is read in place.
295
+ const company = new Map(
296
+ (await trustedRegistrations()).map((r) => [r.name, r.sourceType]),
297
+ );
298
+ const names = new Set([...Object.keys(configured), ...company.keys()]);
279
299
 
280
300
  // Select eligible marketplaces first so the pulls can run concurrently.
281
301
  const eligible: string[] = [];
282
- for (const [name, cfg] of Object.entries(configured)) {
302
+ for (const name of names) {
283
303
  if (skipSet.has(name)) continue;
284
- if (cfg.source?.source !== "github" || !cfg.source?.repo) {
304
+ const cfg = configured[name];
305
+ const companyType = company.get(name);
306
+ if (companyType !== undefined) {
307
+ if (companyType === "directory" || !needed.has(name)) {
308
+ skipped.push(name);
309
+ continue;
310
+ }
311
+ eligible.push(name);
312
+ continue;
313
+ }
314
+ if (cfg?.source?.source !== "github" || !cfg.source?.repo) {
285
315
  skipped.push(name);
286
316
  continue;
287
317
  }
@@ -1,4 +1,3 @@
1
- import os from "node:os";
2
1
  import path from "node:path";
3
2
  /**
4
3
  * Marketplace sync service
@@ -15,23 +14,33 @@ import path from "node:path";
15
14
  * deleted. See ai-docs/plugin-marketplace-bug-investigation.md.
16
15
  */
17
16
  import fs from "fs-extra";
18
- import { defaultMarketplaces } from "../data/marketplaces.js";
17
+ import {
18
+ defaultMarketplaces,
19
+ getMarketplaceByName,
20
+ } from "../data/marketplaces.js";
19
21
  import type { ProfileMarketplaceRef } from "../types/index.js";
22
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
20
23
  import { parsePluginId } from "../utils/string-utils.js";
21
- import { addMarketplace } from "./claude-cli.js";
24
+ import { addMarketplace, marketplaceAddHint } from "./claude-cli.js";
22
25
  import {
23
26
  getEnabledPlugins,
24
27
  getGlobalEnabledPlugins,
25
28
  getLocalEnabledPlugins,
26
29
  isMarketplaceRegistered,
27
30
  } from "./claude-settings.js";
31
+ import {
32
+ isGithubSource,
33
+ readTrust,
34
+ refSource,
35
+ registeredMarker,
36
+ registeredSource,
37
+ sameSource,
38
+ trust,
39
+ } from "./market-registry.js";
28
40
 
29
- const MARKETPLACES_DIR = path.join(
30
- os.homedir(),
31
- ".claude",
32
- "plugins",
33
- "marketplaces",
34
- );
41
+ /** Claude Code's marketplace clones, resolved per call. */
42
+ const marketplacesDir = (): string =>
43
+ path.join(requireClaudeConfigDir(), "plugins", "marketplaces");
35
44
 
36
45
  /**
37
46
  * Collect unique marketplace names from enabled plugins across all settings
@@ -78,9 +87,170 @@ export async function getReferencedMarketplaces(
78
87
  /** Re-exported for convenience — canonical implementation lives in claude-settings. */
79
88
  export { isMarketplaceRegistered };
80
89
 
90
+ /** A marketplace registration that went wrong, with what to do about it. */
91
+ export interface RegistrationProblem {
92
+ name: string;
93
+ message: string;
94
+ fix?: string;
95
+ }
96
+
97
+ /** What {@link registerClosureMarketplaces} did. Nothing in it is printed. */
98
+ export interface RegistrationResult {
99
+ /** Registered at USER scope by this call: `{name, source}`. */
100
+ added: Array<{ name: string; source: string }>;
101
+ /** Put on the trust list by this call, from the profile. */
102
+ trusted: string[];
103
+ /**
104
+ * A company marketplace this machine registers, or trusts, from a source
105
+ * other than the profile's. Nothing was changed, and its plugins must be
106
+ * left out of this run's work.
107
+ */
108
+ conflicts: RegistrationProblem[];
109
+ /** `claude plugin marketplace add` failed. */
110
+ failed: RegistrationProblem[];
111
+ }
112
+
113
+ /** The fix for a source conflict: what to check, never a removal. */
114
+ export function conflictFix(name: string): string {
115
+ return `Check which one is right: \`claude plugin marketplace list\` shows this machine's source, \`.claude/profiles.json\` the profile's. If the profile is wrong, correct \`marketplaces.${name}\` there; if this machine is, ask whoever registered it before changing it.`;
116
+ }
117
+
118
+ /** A failed `marketplace add`, in the user's terms. */
119
+ function addFailure(
120
+ name: string,
121
+ source: string,
122
+ error: unknown,
123
+ ): RegistrationProblem {
124
+ const hint = marketplaceAddHint(error);
125
+ const first =
126
+ (error instanceof Error ? error.message : String(error))
127
+ .split("\n")[0]
128
+ ?.trim() || "could not be added";
129
+ if (hint && isGithubSource(source)) {
130
+ return {
131
+ name,
132
+ message: `Your GitHub account cannot read ${source} — ask its owner for access`,
133
+ fix: hint,
134
+ };
135
+ }
136
+ return { name, message: first, ...(hint ? { fix: hint } : {}) };
137
+ }
138
+
81
139
  /**
82
- * Register every marketplace a resolved profile closure declares, skipping any
83
- * already known.
140
+ * What this machine holds for one marketplace a profile declares, read
141
+ * without changing anything.
142
+ *
143
+ * - `skip`: the ref names no source.
144
+ * - `default`: one of magus's default marketplaces; only its name is checked.
145
+ * - `unwatched`: registered, neither trusted nor carrying the company marker,
146
+ * so its source is not compared.
147
+ * - `conflict`: registered, or trusted, from a source other than the profile's.
148
+ * - `compare`: everything else; the registration may still be added or trusted.
149
+ */
150
+ type MarketplaceState =
151
+ | { kind: "skip" }
152
+ | { kind: "default"; registered: boolean; source: string }
153
+ | { kind: "unwatched" }
154
+ | { kind: "conflict"; problem: RegistrationProblem }
155
+ | {
156
+ kind: "compare";
157
+ registered: boolean;
158
+ marked: boolean;
159
+ machineSource: string | null;
160
+ profileSource: string | null;
161
+ source: string;
162
+ };
163
+
164
+ /**
165
+ * The source comparison {@link registerClosureMarketplaces} and
166
+ * {@link findMarketplaceConflicts} share. Reads Claude Code's registry, the
167
+ * registered clone's manifest and magus's trust list; writes nothing.
168
+ */
169
+ async function inspectMarketplace(
170
+ name: string,
171
+ ref: ProfileMarketplaceRef,
172
+ trustList: Awaited<ReturnType<typeof readTrust>>,
173
+ projectRoot?: string,
174
+ ): Promise<MarketplaceState> {
175
+ const raw = ref.source === "github" ? ref.repo : ref.path;
176
+ if (!raw) return { kind: "skip" };
177
+ // A relative `directory` ref is relative to the project, not to wherever
178
+ // magus was started; Claude Code is handed the resolved path.
179
+ const source =
180
+ ref.source === "directory" && projectRoot
181
+ ? path.resolve(projectRoot, raw)
182
+ : raw;
183
+ const profileSource = refSource(ref, projectRoot);
184
+ const registered = await isMarketplaceRegistered(name);
185
+ if (getMarketplaceByName(name))
186
+ return { kind: "default", registered, source };
187
+
188
+ const trusted = trustList.marketplaces[name];
189
+ const marked = registered && (await registeredMarker(name));
190
+ if (registered && !trusted && !marked) return { kind: "unwatched" };
191
+
192
+ const machineSource = registered ? await registeredSource(name) : null;
193
+ if (
194
+ registered &&
195
+ machineSource &&
196
+ profileSource &&
197
+ !sameSource(machineSource, profileSource)
198
+ ) {
199
+ return {
200
+ kind: "conflict",
201
+ problem: {
202
+ name,
203
+ message: `${name} on this machine comes from ${machineSource}, but this project's profile says ${profileSource}. Nothing was changed.`,
204
+ fix: conflictFix(name),
205
+ },
206
+ };
207
+ }
208
+ if (trusted && profileSource && !sameSource(trusted.source, profileSource)) {
209
+ return {
210
+ kind: "conflict",
211
+ problem: {
212
+ name,
213
+ message: `${name} is a company marketplace on this machine from ${trusted.source}, but this project's profile says ${profileSource}. Nothing was changed.`,
214
+ fix: conflictFix(name),
215
+ },
216
+ };
217
+ }
218
+ return {
219
+ kind: "compare",
220
+ registered,
221
+ marked,
222
+ machineSource,
223
+ profileSource,
224
+ source,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * The marketplaces a profile declares whose registration, or trust record,
230
+ * on this machine comes from another source than the profile's: the same
231
+ * comparison {@link registerClosureMarketplaces} makes, with nothing added,
232
+ * trusted or changed. Read-only callers (`magus update --dry-run` and
233
+ * `--check`, the Plugins screen) leave out the same plugins a real run does.
234
+ *
235
+ * Throws when a read throws; the caller decides what an unchecked answer
236
+ * means. `projectRoot` resolves a repo-relative `directory` ref.
237
+ */
238
+ export async function findMarketplaceConflicts(
239
+ marketplaces: Record<string, ProfileMarketplaceRef>,
240
+ projectRoot?: string,
241
+ ): Promise<RegistrationProblem[]> {
242
+ const trustList = await readTrust();
243
+ const conflicts: RegistrationProblem[] = [];
244
+ for (const [name, ref] of Object.entries(marketplaces)) {
245
+ const state = await inspectMarketplace(name, ref, trustList, projectRoot);
246
+ if (state.kind === "conflict") conflicts.push(state.problem);
247
+ }
248
+ return conflicts;
249
+ }
250
+
251
+ /**
252
+ * Register every marketplace a resolved profile closure declares, and trust
253
+ * the company marketplaces among them.
84
254
  *
85
255
  * Shared by `install` and `update`. It lived inside cli/install.ts until
86
256
  * `update` shipped without it and could not install a plugin from a marketplace
@@ -89,23 +259,84 @@ export { isMarketplaceRegistered };
89
259
  * private or third-party marketplace failed with "not found in marketplace" and
90
260
  * no way forward.
91
261
  *
92
- * Failures are warned about, never thrown — one unreachable marketplace must not
93
- * abort the whole run.
262
+ * The source is compared only where trust is at stake: a name that is not one
263
+ * of magus's default marketplaces and that this machine already trusts, or
264
+ * whose registered clone carries the company marker. There, this machine's
265
+ * registration must come from the profile's source — a mismatch is a conflict,
266
+ * reported with what to check, and nothing is added, trusted or changed.
267
+ * Every other name keeps the name-only check: registered means done. That
268
+ * covers the defaults derived for any `x@magus`, which a developer may have
269
+ * registered from a fork or a local path on purpose. The comparison itself is
270
+ * {@link findMarketplaceConflicts}'s, so a read-only run finds the same
271
+ * conflicts.
272
+ *
273
+ * A registration that carries the marker and matches the profile's source is
274
+ * trusted (`via: "profile"`): the profile is the trust root for teammates.
275
+ *
276
+ * Nothing is printed and nothing is thrown: one unreachable marketplace must
277
+ * not abort the whole run, and the caller renders the result where its output
278
+ * belongs. `projectRoot` resolves a repo-relative `directory` ref.
94
279
  */
95
280
  export async function registerClosureMarketplaces(
96
281
  marketplaces: Record<string, ProfileMarketplaceRef>,
97
- ): Promise<void> {
282
+ projectRoot?: string,
283
+ ): Promise<RegistrationResult> {
284
+ const result: RegistrationResult = {
285
+ added: [],
286
+ trusted: [],
287
+ conflicts: [],
288
+ failed: [],
289
+ };
290
+ const trustList = await readTrust();
98
291
  for (const [name, ref] of Object.entries(marketplaces)) {
292
+ const raw = ref.source === "github" ? ref.repo : ref.path;
293
+ if (!raw) continue;
99
294
  try {
100
- if (await isMarketplaceRegistered(name)) continue;
101
- const source = ref.source === "github" ? ref.repo : ref.path;
102
- if (!source) continue;
103
- console.log(`+ marketplace ${name} (${source})`);
104
- await addMarketplace(source);
295
+ const state = await inspectMarketplace(name, ref, trustList, projectRoot);
296
+ if (state.kind === "skip" || state.kind === "unwatched") continue;
297
+ if (state.kind === "conflict") {
298
+ result.conflicts.push(state.problem);
299
+ continue;
300
+ }
301
+ if (state.kind === "default") {
302
+ if (!state.registered) {
303
+ await addMarketplace(state.source, "user");
304
+ result.added.push({ name, source: state.source });
305
+ }
306
+ continue;
307
+ }
308
+
309
+ let { machineSource, marked } = state;
310
+ const { profileSource } = state;
311
+ if (!state.registered) {
312
+ await addMarketplace(state.source, "user");
313
+ result.added.push({ name, source: state.source });
314
+ machineSource = await registeredSource(name);
315
+ marked = await registeredMarker(name);
316
+ }
317
+
318
+ if (
319
+ !marked ||
320
+ !machineSource ||
321
+ !profileSource ||
322
+ !sameSource(machineSource, profileSource)
323
+ ) {
324
+ continue;
325
+ }
326
+ const outcome = await trust(name, machineSource, "profile");
327
+ if (outcome === "added") result.trusted.push(name);
328
+ else if (outcome === "conflict") {
329
+ result.conflicts.push({
330
+ name,
331
+ message: `${name} is a company marketplace on this machine from another source than this project's profile (${profileSource}). Nothing was changed.`,
332
+ fix: conflictFix(name),
333
+ });
334
+ }
105
335
  } catch (e) {
106
- console.warn(`⚠ marketplace ${name}: ${(e as Error).message}`);
336
+ result.failed.push(addFailure(name, raw, e));
107
337
  }
108
338
  }
339
+ return result;
109
340
  }
110
341
 
111
342
  /**
@@ -124,14 +355,14 @@ export async function autoAddMissingMarketplaces(
124
355
  const added: string[] = [];
125
356
 
126
357
  for (const mpName of referenced) {
127
- const mpDir = path.join(MARKETPLACES_DIR, mpName);
358
+ const mpDir = path.join(marketplacesDir(), mpName);
128
359
  if (await fs.pathExists(mpDir)) continue;
129
360
 
130
361
  const defaultMp = defaultMarketplaces.find((m) => m.name === mpName);
131
362
  if (!defaultMp?.source.repo) continue;
132
363
 
133
364
  try {
134
- await addMarketplace(defaultMp.source.repo);
365
+ await addMarketplace(defaultMp.source.repo, "user");
135
366
  added.push(mpName);
136
367
  } catch (error) {
137
368
  console.warn(
@@ -46,3 +46,20 @@ export async function readPluginDependencies(
46
46
  const { resolvePluginDependencies } = await import("./plugin-requires.js");
47
47
  return resolvePluginDependencies(pluginId, projectPath);
48
48
  }
49
+
50
+ /**
51
+ * Read `pluginId`'s declared dependencies from the copy a run is about to
52
+ * install — its marketplace clone — before the installed one. A newly
53
+ * published version may depend on a plugin the installed version did not,
54
+ * and Claude Code refuses to load it until that one is enabled too.
55
+ */
56
+ export async function readUpdatedPluginDependencies(
57
+ pluginId: string,
58
+ projectPath?: string,
59
+ ): Promise<string[] | null> {
60
+ if (replacement) return replacement(pluginId, projectPath);
61
+ const { resolvePluginDependencies } = await import("./plugin-requires.js");
62
+ return resolvePluginDependencies(pluginId, projectPath, {
63
+ cloneFirst: true,
64
+ });
65
+ }
@@ -1015,7 +1015,7 @@ const autoSyncedMarketplaces = new Set<string>();
1015
1015
  * the local clone is stale. Log a warning but do NOT trigger a marketplace
1016
1016
  * update from magus.
1017
1017
  *
1018
- * Previously this called `updateMarketplace()` which invokes Claude Code's
1018
+ * Previously this ran `claude plugin marketplace update`, which invokes Claude Code's
1019
1019
  * `cacheMarketplaceFromGit()` — a non-atomic delete-then-clone that can
1020
1020
  * permanently delete the marketplace directory on clone failure.
1021
1021
  * Claude Code's own background autoupdate handles marketplace refreshing
@@ -1,13 +1,10 @@
1
- import os from "node:os";
2
1
  import path from "node:path";
3
2
  import fs from "fs-extra";
3
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
4
4
 
5
- const CLAUDE_PLUGINS_DIR = path.join(
6
- os.homedir(),
7
- ".claude",
8
- "plugins",
9
- "marketplaces",
10
- );
5
+ /** Claude Code's marketplace clones, resolved per call. */
6
+ const claudePluginsDir = (): string =>
7
+ path.join(requireClaudeConfigDir(), "plugins", "marketplaces");
11
8
 
12
9
  /**
13
10
  * Represents a required environment variable for a plugin's MCP server
@@ -84,7 +81,7 @@ function getPluginMcpConfigPath(
84
81
  ): string | undefined {
85
82
  if (!pluginSource) return undefined;
86
83
 
87
- const marketplacePath = path.join(CLAUDE_PLUGINS_DIR, marketplaceName);
84
+ const marketplacePath = path.join(claudePluginsDir(), marketplaceName);
88
85
  const pluginPath = path.join(
89
86
  marketplacePath,
90
87
  pluginSource.replace(/^\.\//, ""),
@@ -203,7 +200,7 @@ export async function getPluginSourcePath(
203
200
  marketplaceName: string,
204
201
  pluginName: string,
205
202
  ): Promise<string | undefined> {
206
- const marketplacePath = path.join(CLAUDE_PLUGINS_DIR, marketplaceName);
203
+ const marketplacePath = path.join(claudePluginsDir(), marketplaceName);
207
204
  const manifestPath = path.join(
208
205
  marketplacePath,
209
206
  ".claude-plugin",
@@ -8,7 +8,6 @@
8
8
  * This module only locates the manifest Claude Code loads and hands it over.
9
9
  */
10
10
 
11
- import os from "node:os";
12
11
  import path from "node:path";
13
12
  import fs from "fs-extra";
14
13
  import {
@@ -23,13 +22,15 @@ import {
23
22
  selectOption,
24
23
  } from "../shared/plugin/hooks/lib/requires-probe.js";
25
24
  import type { SelectedBin } from "../types/index.js";
25
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
26
26
  import { parsePluginId } from "../utils/string-utils.js";
27
27
  import { readInstalledPluginsRegistry } from "./claude-settings.js";
28
+ import { pluginDirFor } from "./market-layout.js";
28
29
  import { getLocalMarketplacesInfo } from "./plugin-manager.js";
29
30
  import { pickLoadedEntry } from "./plugin-resolution.js";
30
31
 
31
32
  const MARKETPLACES_DIR = (): string =>
32
- path.join(os.homedir(), ".claude", "plugins", "marketplaces");
33
+ path.join(requireClaudeConfigDir(), "plugins", "marketplaces");
33
34
 
34
35
  /**
35
36
  * The plugin.json at a plugin directory, parsed — null when there is none or
@@ -119,6 +120,12 @@ export interface PluginManifestSources {
119
120
  root?: string;
120
121
  /** The plugin's source in the local marketplace clone. */
121
122
  clone?: (pluginId: string) => Promise<string | null>;
123
+ /**
124
+ * Trust the marketplace clone's copy before the installed one. For a
125
+ * plugin a run is about to move to the clone's version: what that version
126
+ * declares is what has to be in place once it loads.
127
+ */
128
+ cloneFirst?: boolean;
122
129
  }
123
130
 
124
131
  /**
@@ -157,7 +164,10 @@ async function pluginManifestDirs(
157
164
  // No readable registry: the clone alone decides.
158
165
  }
159
166
  const clone = await (sources.clone ?? resolvePluginPath)(pluginId);
160
- if (clone) dirs.push(clone);
167
+ if (clone) {
168
+ if (sources.cloneFirst) dirs.unshift(clone);
169
+ else dirs.push(clone);
170
+ }
161
171
  return [...new Set([...dirs, ...installed])];
162
172
  }
163
173
 
@@ -200,12 +210,14 @@ export async function resolvePluginPath(
200
210
  return null;
201
211
  }
202
212
 
203
- const pluginPath = path.join(
204
- MARKETPLACES_DIR(),
205
- marketplace,
206
- localPlugin.source.replace("./", ""),
213
+ // `metadata.pluginRoot` decides where a bare source lives, exactly as in
214
+ // the scan that listed the plugin.
215
+ const pluginPath = pluginDirFor(
216
+ path.join(MARKETPLACES_DIR(), marketplace),
217
+ localPlugin.source,
218
+ localMp.pluginRoot,
207
219
  );
208
- return (await fs.pathExists(pluginPath)) ? pluginPath : null;
220
+ return pluginPath && (await fs.pathExists(pluginPath)) ? pluginPath : null;
209
221
  }
210
222
 
211
223
  /**