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,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* github-cli.ts — the `gh` calls `magus market` makes.
|
|
3
|
+
*
|
|
4
|
+
* Every call goes through {@link runBounded} with an argv, never a shell, and
|
|
5
|
+
* with prompting off (`GH_PROMPT_DISABLED`, `GIT_TERMINAL_PROMPT=0`), so a
|
|
6
|
+
* missing sign-in fails at once instead of waiting on a prompt nobody sees.
|
|
7
|
+
* No token is read, stored or passed: gh holds its own credentials.
|
|
8
|
+
*
|
|
9
|
+
* Every function answers a {@link GhResult}; a failure carries a class the
|
|
10
|
+
* caller maps to its own error code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { which } from "../utils/command-utils.js";
|
|
14
|
+
import { runBounded } from "../utils/spawn-bounded.js";
|
|
15
|
+
|
|
16
|
+
export type GhFailure =
|
|
17
|
+
| "gh-missing"
|
|
18
|
+
| "gh-auth"
|
|
19
|
+
| "not-found"
|
|
20
|
+
| "no-network"
|
|
21
|
+
| "timeout"
|
|
22
|
+
| "other";
|
|
23
|
+
|
|
24
|
+
export type GhResult<T> =
|
|
25
|
+
| { ok: true; value: T }
|
|
26
|
+
| { ok: false; failure: GhFailure; message: string };
|
|
27
|
+
|
|
28
|
+
const GH_TIMEOUT_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/** `gh auth status` exits 4 when no account is signed in. */
|
|
31
|
+
const GH_EXIT_AUTH = 4;
|
|
32
|
+
|
|
33
|
+
function ghEnv(): NodeJS.ProcessEnv {
|
|
34
|
+
return {
|
|
35
|
+
...process.env,
|
|
36
|
+
GH_PROMPT_DISABLED: "1",
|
|
37
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
38
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
39
|
+
GCM_INTERACTIVE: "never",
|
|
40
|
+
NO_COLOR: "1",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Classify a failed gh run from its exit code and words. */
|
|
45
|
+
export function classifyGhFailure(
|
|
46
|
+
code: number | null,
|
|
47
|
+
text: string,
|
|
48
|
+
timedOut: boolean,
|
|
49
|
+
): GhFailure {
|
|
50
|
+
if (timedOut) return "timeout";
|
|
51
|
+
if (
|
|
52
|
+
code === GH_EXIT_AUTH ||
|
|
53
|
+
/not logged in|gh auth login|HTTP 401|Bad credentials|authentication required/i.test(
|
|
54
|
+
text,
|
|
55
|
+
)
|
|
56
|
+
) {
|
|
57
|
+
return "gh-auth";
|
|
58
|
+
}
|
|
59
|
+
if (/HTTP 404|Not Found|Could not resolve to a Repository/i.test(text)) {
|
|
60
|
+
return "not-found";
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
/error connecting to|dial tcp|could not resolve host|no such host|network is unreachable|connection refused|i\/o timeout|TLS handshake timeout/i.test(
|
|
64
|
+
text,
|
|
65
|
+
)
|
|
66
|
+
) {
|
|
67
|
+
return "no-network";
|
|
68
|
+
}
|
|
69
|
+
return "other";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function gh(args: string[]): Promise<GhResult<string>> {
|
|
73
|
+
const r = await runBounded("gh", args, {
|
|
74
|
+
env: ghEnv(),
|
|
75
|
+
timeoutMs: GH_TIMEOUT_MS,
|
|
76
|
+
});
|
|
77
|
+
if (r.spawnError) {
|
|
78
|
+
const missing = /ENOENT|not found/i.test(r.spawnError.message);
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
failure: missing ? "gh-missing" : "other",
|
|
82
|
+
message: missing
|
|
83
|
+
? "gh is not installed"
|
|
84
|
+
: `gh could not be started: ${r.spawnError.message}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (r.code === 0 && !r.timedOut) return { ok: true, value: r.stdout };
|
|
88
|
+
const text = `${r.stderr}\n${r.stdout}`;
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
failure: classifyGhFailure(r.code, text, r.timedOut),
|
|
92
|
+
message:
|
|
93
|
+
r.stderr.trim() ||
|
|
94
|
+
r.stdout.trim() ||
|
|
95
|
+
(r.timedOut
|
|
96
|
+
? `gh ${args[0]} did not finish in time`
|
|
97
|
+
: `gh ${args[0]} failed`),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function map<T, U>(r: GhResult<T>, f: (v: T) => U): GhResult<U> {
|
|
102
|
+
return r.ok ? { ok: true, value: f(r.value) } : r;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when a `gh` binary is on PATH. */
|
|
106
|
+
export async function ghAvailable(): Promise<boolean> {
|
|
107
|
+
return (await which("gh")) !== null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether gh has a signed-in account; `gh-auth` when it has none. */
|
|
111
|
+
export async function authStatus(): Promise<GhResult<void>> {
|
|
112
|
+
if (!(await ghAvailable())) {
|
|
113
|
+
return { ok: false, failure: "gh-missing", message: "gh is not installed" };
|
|
114
|
+
}
|
|
115
|
+
return map(await gh(["auth", "status"]), () => undefined);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The signed-in account's login. */
|
|
119
|
+
export async function viewerLogin(): Promise<GhResult<string>> {
|
|
120
|
+
return map(await gh(["api", "user", "--jq", ".login"]), (s) => s.trim());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The organisations the signed-in account belongs to. */
|
|
124
|
+
export async function viewerOrgs(): Promise<GhResult<string[]>> {
|
|
125
|
+
return map(
|
|
126
|
+
await gh(["api", "--paginate", "user/orgs", "--jq", ".[].login"]),
|
|
127
|
+
(s) =>
|
|
128
|
+
s
|
|
129
|
+
.split("\n")
|
|
130
|
+
.map((l) => l.trim())
|
|
131
|
+
.filter(Boolean),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Validate `owner/repo` before it reaches an argv or an API path. */
|
|
136
|
+
export function isRepoSlug(repo: string): boolean {
|
|
137
|
+
return (
|
|
138
|
+
/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/.test(repo) &&
|
|
139
|
+
!repo.endsWith("/.") &&
|
|
140
|
+
!repo.endsWith("/..")
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function badSlug<T>(repo: string): GhResult<T> {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
failure: "other",
|
|
148
|
+
message: `not a GitHub owner/repo: ${repo}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether `owner/repo` exists and this account can see it. */
|
|
153
|
+
export async function repoExists(repo: string): Promise<GhResult<boolean>> {
|
|
154
|
+
if (!isRepoSlug(repo)) return badSlug(repo);
|
|
155
|
+
const r = await gh(["api", `repos/${repo}`, "--jq", ".full_name"]);
|
|
156
|
+
if (r.ok) return { ok: true, value: true };
|
|
157
|
+
if (r.failure === "not-found") return { ok: true, value: false };
|
|
158
|
+
return r;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `gh repo create <owner/repo> --private|--public [--description <text>]`. */
|
|
162
|
+
export async function repoCreate(
|
|
163
|
+
repo: string,
|
|
164
|
+
visibility: "private" | "public",
|
|
165
|
+
description?: string,
|
|
166
|
+
): Promise<GhResult<void>> {
|
|
167
|
+
if (!isRepoSlug(repo)) return badSlug(repo);
|
|
168
|
+
const args = ["repo", "create", repo, `--${visibility}`];
|
|
169
|
+
if (description) args.push("--description", description);
|
|
170
|
+
return map(await gh(args), () => undefined);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether this account can push to `owner/repo` (`.permissions.push`).
|
|
175
|
+
* A repository it cannot see answers `not-found`, which callers report as
|
|
176
|
+
* `no-access`.
|
|
177
|
+
*/
|
|
178
|
+
export async function repoPermissions(
|
|
179
|
+
repo: string,
|
|
180
|
+
): Promise<GhResult<boolean>> {
|
|
181
|
+
if (!isRepoSlug(repo)) return badSlug(repo);
|
|
182
|
+
return map(
|
|
183
|
+
await gh(["api", `repos/${repo}`, "--jq", ".permissions.push"]),
|
|
184
|
+
(s) => s.trim() === "true",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether `owner/repo` allows forks (`.allow_forking`). */
|
|
189
|
+
export async function repoForkAllowed(
|
|
190
|
+
repo: string,
|
|
191
|
+
): Promise<GhResult<boolean>> {
|
|
192
|
+
if (!isRepoSlug(repo)) return badSlug(repo);
|
|
193
|
+
return map(
|
|
194
|
+
await gh(["api", `repos/${repo}`, "--jq", ".allow_forking"]),
|
|
195
|
+
(s) => s.trim() === "true",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Fork `owner/repo` to the signed-in account, without cloning it, answering
|
|
201
|
+
* the fork's own `owner/repo`. GitHub returns the account's existing fork when
|
|
202
|
+
* there is one and renames a new fork whose name is taken, so the answer is
|
|
203
|
+
* read from GitHub, never derived from the source's name.
|
|
204
|
+
*/
|
|
205
|
+
export async function repoFork(repo: string): Promise<GhResult<string>> {
|
|
206
|
+
if (!isRepoSlug(repo)) return badSlug(repo);
|
|
207
|
+
const r = map(
|
|
208
|
+
await gh([
|
|
209
|
+
"api",
|
|
210
|
+
"--method",
|
|
211
|
+
"POST",
|
|
212
|
+
`repos/${repo}/forks`,
|
|
213
|
+
"--jq",
|
|
214
|
+
".full_name",
|
|
215
|
+
]),
|
|
216
|
+
(s) => s.trim(),
|
|
217
|
+
);
|
|
218
|
+
if (r.ok && !isRepoSlug(r.value)) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
failure: "other",
|
|
222
|
+
message: `GitHub answered no fork name for ${repo}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return r;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The repository `owner/repo` is a fork of, as `owner/repo`, or null when it
|
|
230
|
+
* is not a fork.
|
|
231
|
+
*/
|
|
232
|
+
export async function forkParent(
|
|
233
|
+
repo: string,
|
|
234
|
+
): Promise<GhResult<string | null>> {
|
|
235
|
+
if (!isRepoSlug(repo)) return badSlug(repo);
|
|
236
|
+
return map(
|
|
237
|
+
await gh([
|
|
238
|
+
"api",
|
|
239
|
+
`repos/${repo}`,
|
|
240
|
+
"--jq",
|
|
241
|
+
'if .fork then .parent.full_name else "" end',
|
|
242
|
+
]),
|
|
243
|
+
(s) => s.trim() || null,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* One file of `owner/repo` at its default branch (or `ref`), raw. Null when
|
|
249
|
+
* the repository has no such file. For reading a marketplace manifest without
|
|
250
|
+
* a clone.
|
|
251
|
+
*/
|
|
252
|
+
export async function repoFileRaw(
|
|
253
|
+
repo: string,
|
|
254
|
+
filePath: string,
|
|
255
|
+
ref?: string,
|
|
256
|
+
): Promise<GhResult<string | null>> {
|
|
257
|
+
if (!isRepoSlug(repo)) return badSlug(repo);
|
|
258
|
+
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
|
259
|
+
const encoded = filePath.split("/").map(encodeURIComponent).join("/");
|
|
260
|
+
const r = await gh([
|
|
261
|
+
"api",
|
|
262
|
+
"-H",
|
|
263
|
+
"Accept: application/vnd.github.raw",
|
|
264
|
+
`repos/${repo}/contents/${encoded}${query}`,
|
|
265
|
+
]);
|
|
266
|
+
if (r.ok) return r;
|
|
267
|
+
if (r.failure === "not-found") return { ok: true, value: null };
|
|
268
|
+
return r;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface PrCreateOptions {
|
|
272
|
+
/** The repository the pull request is opened against, `owner/repo`. */
|
|
273
|
+
repo: string;
|
|
274
|
+
/** The branch with the change: `branch`, or `login:branch` from a fork. */
|
|
275
|
+
head: string;
|
|
276
|
+
/** The branch to merge into. */
|
|
277
|
+
base: string;
|
|
278
|
+
title: string;
|
|
279
|
+
/** A file holding the body, so no text passes through an argv unquoted. */
|
|
280
|
+
bodyFile: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** `gh pr create`, answering the pull request's URL. */
|
|
284
|
+
export async function prCreate(
|
|
285
|
+
opts: PrCreateOptions,
|
|
286
|
+
): Promise<GhResult<string>> {
|
|
287
|
+
if (!isRepoSlug(opts.repo)) return badSlug(opts.repo);
|
|
288
|
+
const r = await gh([
|
|
289
|
+
"pr",
|
|
290
|
+
"create",
|
|
291
|
+
"--repo",
|
|
292
|
+
opts.repo,
|
|
293
|
+
"--head",
|
|
294
|
+
opts.head,
|
|
295
|
+
"--base",
|
|
296
|
+
opts.base,
|
|
297
|
+
"--title",
|
|
298
|
+
opts.title,
|
|
299
|
+
"--body-file",
|
|
300
|
+
opts.bodyFile,
|
|
301
|
+
]);
|
|
302
|
+
return map(r, (s) => {
|
|
303
|
+
const urls = s
|
|
304
|
+
.split("\n")
|
|
305
|
+
.map((l) => l.trim())
|
|
306
|
+
.filter((l) => /^https?:\/\//.test(l));
|
|
307
|
+
return urls[urls.length - 1] ?? s.trim();
|
|
308
|
+
});
|
|
309
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
import os from "node:os";
|
|
3
1
|
import path from "node:path";
|
|
4
2
|
import fs from "fs-extra";
|
|
5
3
|
import type { PluginRelease } from "../types/index.js";
|
|
4
|
+
import { requireClaudeConfigDir } from "../utils/config-dir.js";
|
|
5
|
+
import { remoteUrl } from "./git-write.js";
|
|
6
|
+
import { pluginDirFor } from "./market-layout.js";
|
|
7
|
+
import { readMarker } from "./market-marker.js";
|
|
8
|
+
import {
|
|
9
|
+
type TrustFile,
|
|
10
|
+
isGithubSource,
|
|
11
|
+
isTrustedIn,
|
|
12
|
+
normaliseSource,
|
|
13
|
+
readTrust,
|
|
14
|
+
} from "./market-registry.js";
|
|
6
15
|
import { normalizeReleases } from "./plugin-releases.js";
|
|
7
16
|
|
|
8
17
|
export interface LocalMarketplacePlugin {
|
|
@@ -27,21 +36,35 @@ export interface LocalMarketplace {
|
|
|
27
36
|
name: string;
|
|
28
37
|
description: string;
|
|
29
38
|
plugins: LocalMarketplacePlugin[];
|
|
30
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The GitHub `owner/repo` the clone comes from, or undefined for any other
|
|
41
|
+
* remote. Everything that reads it assumes GitHub: it is printed as a
|
|
42
|
+
* `github.com/` link and the plugin contents are fetched from GitHub.
|
|
43
|
+
*/
|
|
44
|
+
gitRepo?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Where the marketplace comes from, normalised: `owner/repo` for GitHub,
|
|
47
|
+
* else the remote URL as configured, else the directory. Read by the trust
|
|
48
|
+
* check only.
|
|
49
|
+
*/
|
|
50
|
+
remoteSource?: string;
|
|
51
|
+
/** `metadata.pluginRoot` from the manifest: where bare plugin sources live. */
|
|
52
|
+
pluginRoot?: string;
|
|
53
|
+
/**
|
|
54
|
+
* A company marketplace on this machine: the manifest carries the marker
|
|
55
|
+
* and the name and source are on this machine's trust list.
|
|
56
|
+
*/
|
|
57
|
+
company?: boolean;
|
|
58
|
+
/** `owner.name` from the manifest, trimmed; absent when empty or missing. */
|
|
59
|
+
ownerName?: string;
|
|
31
60
|
}
|
|
32
61
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
const KNOWN_MARKETPLACES_FILE = path.join(
|
|
40
|
-
os.homedir(),
|
|
41
|
-
".claude",
|
|
42
|
-
"plugins",
|
|
43
|
-
"known_marketplaces.json",
|
|
44
|
-
);
|
|
62
|
+
/** Claude Code's marketplace clones, resolved per call. */
|
|
63
|
+
const claudePluginsDir = (): string =>
|
|
64
|
+
path.join(requireClaudeConfigDir(), "plugins", "marketplaces");
|
|
65
|
+
/** Claude Code's marketplace registry, resolved per call. */
|
|
66
|
+
const knownMarketplacesFile = (): string =>
|
|
67
|
+
path.join(requireClaudeConfigDir(), "plugins", "known_marketplaces.json");
|
|
45
68
|
|
|
46
69
|
/**
|
|
47
70
|
* Where Claude Code keeps a marketplace's clone, whether or not it exists.
|
|
@@ -52,11 +75,11 @@ const KNOWN_MARKETPLACES_FILE = path.join(
|
|
|
52
75
|
* screen combined.
|
|
53
76
|
*/
|
|
54
77
|
export function getMarketplaceClonePath(marketplaceName: string): string {
|
|
55
|
-
return path.join(
|
|
78
|
+
return path.join(claudePluginsDir(), marketplaceName);
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
interface KnownMarketplaceEntry {
|
|
59
|
-
source: { source: string; url?: string; repo?: string };
|
|
82
|
+
source: { source: string; url?: string; repo?: string; path?: string };
|
|
60
83
|
installLocation: string;
|
|
61
84
|
lastUpdated: string;
|
|
62
85
|
}
|
|
@@ -75,8 +98,8 @@ type KnownMarketplaces = Record<string, KnownMarketplaceEntry>;
|
|
|
75
98
|
*/
|
|
76
99
|
async function readKnownMarketplacesOrNull(): Promise<KnownMarketplaces | null> {
|
|
77
100
|
try {
|
|
78
|
-
if (!(await fs.pathExists(
|
|
79
|
-
const parsed = await fs.readJson(
|
|
101
|
+
if (!(await fs.pathExists(knownMarketplacesFile()))) return null;
|
|
102
|
+
const parsed = await fs.readJson(knownMarketplacesFile());
|
|
80
103
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
81
104
|
return null;
|
|
82
105
|
}
|
|
@@ -97,9 +120,8 @@ async function readKnownMarketplaces(): Promise<KnownMarketplaces> {
|
|
|
97
120
|
* Directory names a parsed `known_marketplaces.json` registers, or null when it
|
|
98
121
|
* says nothing usable.
|
|
99
122
|
*
|
|
100
|
-
* Pure, and exported,
|
|
101
|
-
*
|
|
102
|
-
* decision this encodes is the part worth testing; the IO around it is not.
|
|
123
|
+
* Pure, and exported, so it can be tested without a registry file on disk:
|
|
124
|
+
* the decision this encodes is the part worth testing; the IO around it is not.
|
|
103
125
|
*
|
|
104
126
|
* Keyed by BOTH the registry key and the basename of `installLocation`: the two
|
|
105
127
|
* are normally equal and nothing enforces it, and dropping a real marketplace
|
|
@@ -134,29 +156,39 @@ async function registeredDirNames(): Promise<Set<string> | null> {
|
|
|
134
156
|
}
|
|
135
157
|
|
|
136
158
|
/**
|
|
137
|
-
*
|
|
159
|
+
* The clone's `origin` URL as configured, normalised — read before any
|
|
160
|
+
* `url.<base>.insteadOf` rewrite, so a clone of `owner/repo` reads as
|
|
161
|
+
* `owner/repo` whatever URL git actually fetches from. Undefined when the
|
|
162
|
+
* directory is not a git clone or has no `origin`.
|
|
138
163
|
*/
|
|
139
|
-
function getGitRemote(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const result = execSync("git remote get-url origin", {
|
|
145
|
-
cwd: marketplacePath,
|
|
146
|
-
encoding: "utf-8",
|
|
147
|
-
timeout: 5000,
|
|
148
|
-
}).trim();
|
|
149
|
-
|
|
150
|
-
// Convert SSH URL to HTTPS format: git@github.com:user/repo.git -> user/repo
|
|
151
|
-
if (result.startsWith("git@github.com:")) {
|
|
152
|
-
return result.replace("git@github.com:", "").replace(/\.git$/, "");
|
|
153
|
-
}
|
|
154
|
-
// HTTPS URL: https://github.com/user/repo -> user/repo
|
|
155
|
-
if (result.includes("github.com")) {
|
|
156
|
-
const match = result.match(/github\.com[/:]([^/]+\/[^/\s.]+)/);
|
|
157
|
-
if (match) return match[1].replace(/\.git$/, "");
|
|
158
|
-
}
|
|
164
|
+
async function getGitRemote(
|
|
165
|
+
marketplacePath: string,
|
|
166
|
+
): Promise<string | undefined> {
|
|
167
|
+
if (!(await fs.pathExists(path.join(marketplacePath, ".git")))) {
|
|
159
168
|
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const url = await remoteUrl(marketplacePath);
|
|
171
|
+
if (!url.ok || !url.value) return undefined;
|
|
172
|
+
return normaliseSource(url.value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The `version` in a plugin's own `.claude-plugin/plugin.json`, or undefined.
|
|
177
|
+
* The fallback for a marketplace entry that carries none: a plugin versioned
|
|
178
|
+
* only in its own manifest otherwise reads as 0.0.0.
|
|
179
|
+
*/
|
|
180
|
+
async function pluginJsonVersion(
|
|
181
|
+
pluginPath: string | null,
|
|
182
|
+
): Promise<string | undefined> {
|
|
183
|
+
if (!pluginPath) return undefined;
|
|
184
|
+
try {
|
|
185
|
+
const manifest: unknown = await fs.readJson(
|
|
186
|
+
path.join(pluginPath, ".claude-plugin", "plugin.json"),
|
|
187
|
+
);
|
|
188
|
+
const version = (manifest as { version?: unknown } | null)?.version;
|
|
189
|
+
return typeof version === "string" && version.trim() !== ""
|
|
190
|
+
? version
|
|
191
|
+
: undefined;
|
|
160
192
|
} catch {
|
|
161
193
|
return undefined;
|
|
162
194
|
}
|
|
@@ -265,6 +297,8 @@ export async function scanMcpServers(pluginPath: string): Promise<string[]> {
|
|
|
265
297
|
async function scanSingleMarketplace(
|
|
266
298
|
marketplacePath: string,
|
|
267
299
|
marketplaceName: string,
|
|
300
|
+
trusted: TrustFile,
|
|
301
|
+
directorySource?: string,
|
|
268
302
|
): Promise<LocalMarketplace | null> {
|
|
269
303
|
const manifestPath = path.join(
|
|
270
304
|
marketplacePath,
|
|
@@ -276,7 +310,24 @@ async function scanSingleMarketplace(
|
|
|
276
310
|
|
|
277
311
|
try {
|
|
278
312
|
const manifest = await fs.readJson(manifestPath);
|
|
279
|
-
|
|
313
|
+
// A `directory` registration is read in place, so its source is the
|
|
314
|
+
// directory the registry names, not whatever remote that folder has.
|
|
315
|
+
const remoteSource = directorySource
|
|
316
|
+
? normaliseSource(directorySource)
|
|
317
|
+
: await getGitRemote(marketplacePath);
|
|
318
|
+
const gitRepo =
|
|
319
|
+
!directorySource && remoteSource && isGithubSource(remoteSource)
|
|
320
|
+
? remoteSource
|
|
321
|
+
: undefined;
|
|
322
|
+
const pluginRoot: string | undefined =
|
|
323
|
+
typeof manifest.metadata?.pluginRoot === "string"
|
|
324
|
+
? manifest.metadata.pluginRoot
|
|
325
|
+
: undefined;
|
|
326
|
+
|
|
327
|
+
const ownerName: string | undefined =
|
|
328
|
+
typeof manifest.owner?.name === "string" && manifest.owner.name.trim()
|
|
329
|
+
? manifest.owner.name.trim()
|
|
330
|
+
: undefined;
|
|
280
331
|
|
|
281
332
|
const plugins: LocalMarketplacePlugin[] = [];
|
|
282
333
|
|
|
@@ -296,7 +347,7 @@ async function scanSingleMarketplace(
|
|
|
296
347
|
// manifest is Claude Code's, and a scan reads it, never rewrites it.
|
|
297
348
|
// Its component lists stay empty because there is nothing to count.
|
|
298
349
|
const pluginPath = sourceStr
|
|
299
|
-
?
|
|
350
|
+
? pluginDirFor(marketplacePath, sourceStr, pluginRoot)
|
|
300
351
|
: null;
|
|
301
352
|
if (pluginPath && (await fs.pathExists(pluginPath))) {
|
|
302
353
|
try {
|
|
@@ -327,7 +378,8 @@ async function scanSingleMarketplace(
|
|
|
327
378
|
|
|
328
379
|
plugins.push({
|
|
329
380
|
name: plugin.name,
|
|
330
|
-
version:
|
|
381
|
+
version:
|
|
382
|
+
plugin.version || (await pluginJsonVersion(pluginPath)) || "0.0.0",
|
|
331
383
|
description: plugin.description || "",
|
|
332
384
|
source: plugin.source,
|
|
333
385
|
category: plugin.category,
|
|
@@ -348,6 +400,12 @@ async function scanSingleMarketplace(
|
|
|
348
400
|
description: manifest.description || manifest.metadata?.description || "",
|
|
349
401
|
plugins,
|
|
350
402
|
gitRepo,
|
|
403
|
+
remoteSource,
|
|
404
|
+
pluginRoot,
|
|
405
|
+
company:
|
|
406
|
+
readMarker(manifest) &&
|
|
407
|
+
isTrustedIn(trusted, marketplaceName, remoteSource),
|
|
408
|
+
...(ownerName ? { ownerName } : {}),
|
|
351
409
|
};
|
|
352
410
|
} catch {
|
|
353
411
|
return null;
|
|
@@ -377,11 +435,12 @@ export async function scanLocalMarketplaces(): Promise<
|
|
|
377
435
|
// of a nonsense label — `kanban@magus … moved → magus.bak`. `claude plugin
|
|
378
436
|
// marketplace list` never showed it, because Claude Code reads the registry.
|
|
379
437
|
const registered = await registeredDirNames();
|
|
438
|
+
const trusted = await readTrust();
|
|
380
439
|
|
|
381
440
|
// 1. Scan marketplaces from the standard directory
|
|
382
|
-
if (await fs.pathExists(
|
|
441
|
+
if (await fs.pathExists(claudePluginsDir())) {
|
|
383
442
|
try {
|
|
384
|
-
const entries = await fs.readdir(
|
|
443
|
+
const entries = await fs.readdir(claudePluginsDir(), {
|
|
385
444
|
withFileTypes: true,
|
|
386
445
|
});
|
|
387
446
|
|
|
@@ -389,10 +448,11 @@ export async function scanLocalMarketplaces(): Promise<
|
|
|
389
448
|
if (!entry.isDirectory()) continue;
|
|
390
449
|
if (registered && !registered.has(entry.name)) continue;
|
|
391
450
|
|
|
392
|
-
const marketplacePath = path.join(
|
|
451
|
+
const marketplacePath = path.join(claudePluginsDir(), entry.name);
|
|
393
452
|
const marketplace = await scanSingleMarketplace(
|
|
394
453
|
marketplacePath,
|
|
395
454
|
entry.name,
|
|
455
|
+
trusted,
|
|
396
456
|
);
|
|
397
457
|
if (marketplace) {
|
|
398
458
|
marketplaces.set(entry.name, marketplace);
|
|
@@ -417,6 +477,8 @@ export async function scanLocalMarketplaces(): Promise<
|
|
|
417
477
|
const marketplace = await scanSingleMarketplace(
|
|
418
478
|
entry.installLocation,
|
|
419
479
|
name,
|
|
480
|
+
trusted,
|
|
481
|
+
entry.source.path ?? entry.installLocation,
|
|
420
482
|
);
|
|
421
483
|
if (marketplace) {
|
|
422
484
|
marketplaces.set(name, marketplace);
|
|
@@ -445,7 +507,7 @@ export async function getLocalMarketplace(
|
|
|
445
507
|
* Check if a marketplace exists in local cache
|
|
446
508
|
*/
|
|
447
509
|
export async function hasLocalMarketplace(name: string): Promise<boolean> {
|
|
448
|
-
const marketplacePath = path.join(
|
|
510
|
+
const marketplacePath = path.join(claudePluginsDir(), name);
|
|
449
511
|
const manifestPath = path.join(
|
|
450
512
|
marketplacePath,
|
|
451
513
|
".claude-plugin",
|