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.
- package/README.md +21 -0
- package/package.json +4 -4
- package/src/__tests__/market-bases.test.ts +112 -0
- package/src/__tests__/market-config.test.ts +84 -0
- package/src/__tests__/market-conflicts.test.ts +175 -0
- package/src/__tests__/market-consent.test.ts +185 -0
- package/src/__tests__/market-content.test.ts +183 -0
- package/src/__tests__/market-delivery.test.ts +308 -0
- package/src/__tests__/market-fixture.ts +1167 -0
- package/src/__tests__/market-git.test.ts +130 -0
- package/src/__tests__/market-identity.test.ts +127 -0
- package/src/__tests__/market-install.test.ts +143 -0
- package/src/__tests__/market-layout.test.ts +206 -0
- package/src/__tests__/market-publish.test.ts +263 -0
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-readonly.test.ts +162 -0
- package/src/__tests__/market-repositories.test.ts +263 -0
- package/src/__tests__/market-results.test.ts +198 -0
- package/src/__tests__/market-router.test.ts +65 -0
- package/src/__tests__/market-safety.test.ts +357 -0
- package/src/__tests__/market-teammates.test.ts +304 -0
- package/src/cli/install.ts +64 -5
- package/src/cli/market-view.ts +329 -0
- package/src/cli/market.ts +664 -0
- package/src/cli/router.ts +69 -3
- package/src/cli/update-view.ts +56 -0
- package/src/cli/update.ts +111 -9
- package/src/data/marketplaces.ts +8 -2
- package/src/main.tsx +20 -2
- package/src/market-tui.tsx +21 -0
- package/src/services/active-closure.ts +71 -9
- package/src/services/claude-cli.ts +140 -10
- package/src/services/claude-settings.ts +19 -25
- package/src/services/doctor.ts +5 -5
- package/src/services/git-write.ts +615 -0
- package/src/services/github-cli.ts +309 -0
- package/src/services/local-marketplace.ts +112 -50
- package/src/services/market-bases.ts +191 -0
- package/src/services/market-change.ts +242 -0
- package/src/services/market-clone.ts +572 -0
- package/src/services/market-fs.ts +138 -0
- package/src/services/market-install.ts +172 -0
- package/src/services/market-layout.ts +311 -0
- package/src/services/market-local.ts +93 -0
- package/src/services/market-marker.ts +49 -0
- package/src/services/market-ops-content.ts +277 -0
- package/src/services/market-ops-plugin.ts +655 -0
- package/src/services/market-ops-publish.ts +1227 -0
- package/src/services/market-ops-repo.ts +916 -0
- package/src/services/market-ops-skill.ts +788 -0
- package/src/services/market-ops.ts +686 -0
- package/src/services/market-publish.ts +468 -0
- package/src/services/market-registry.ts +382 -0
- package/src/services/market-types.ts +268 -0
- package/src/services/marketplace-conflicts.ts +127 -0
- package/src/services/marketplace-refresh.ts +33 -3
- package/src/services/marketplace-sync.ts +253 -22
- package/src/services/plugin-dependencies.ts +17 -0
- package/src/services/plugin-manager.ts +1 -1
- package/src/services/plugin-mcp-config.ts +6 -9
- package/src/services/plugin-requires.ts +20 -8
- package/src/services/profile-absorb.ts +88 -3
- package/src/services/profile-adopt.ts +10 -1
- package/src/services/profiles.ts +64 -1
- package/src/services/resolver.ts +17 -5
- package/src/services/settings-manager.ts +2 -2
- package/src/services/skills-manager.ts +2 -2
- package/src/services/update-plan.ts +67 -8
- package/src/tui-runtime.tsx +142 -0
- package/src/tui.tsx +7 -124
- package/src/types/index.ts +7 -0
- package/src/ui/adapters/marketAdapter.ts +366 -0
- package/src/ui/adapters/pluginsAdapter.ts +45 -3
- package/src/ui/components/layout/ScreenLayout.tsx +43 -17
- package/src/ui/components/modals/ConfirmModal.tsx +145 -8
- package/src/ui/components/modals/HelpOverlay.tsx +10 -3
- package/src/ui/components/modals/ModalContainer.tsx +47 -5
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
- package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
- package/src/ui/components/primitives/SelectableRow.tsx +14 -1
- package/src/ui/components/primitives/ToneLine.tsx +25 -0
- package/src/ui/components/primitives/index.ts +5 -0
- package/src/ui/keymap.ts +21 -5
- package/src/ui/market/MarketApp.tsx +59 -0
- package/src/ui/market/MarketplaceScreen.tsx +280 -0
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
- package/src/ui/market/MarketplacesScreen.tsx +264 -0
- package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
- package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
- package/src/ui/market/marketKeymap.ts +243 -0
- package/src/ui/market/useMarketState.ts +694 -0
- package/src/ui/renderers/pluginRenderers.tsx +31 -3
- package/src/ui/renderers/pluginStatusText.ts +6 -0
- package/src/ui/screens/PluginsScreen.tsx +49 -5
- package/src/ui/screens/SkillsScreen.tsx +2 -2
- package/src/ui/state/AppContext.tsx +14 -2
- package/src/ui/state/types.ts +9 -2
- package/src/utils/config-dir.ts +29 -9
- 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
|
-
|
|
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
|
|
302
|
+
for (const name of names) {
|
|
283
303
|
if (skipSet.has(name)) continue;
|
|
284
|
-
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
/**
|