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,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
- gitRepo?: string; // Extracted from git remote
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
- const CLAUDE_PLUGINS_DIR = path.join(
34
- os.homedir(),
35
- ".claude",
36
- "plugins",
37
- "marketplaces",
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(CLAUDE_PLUGINS_DIR, marketplaceName);
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(KNOWN_MARKETPLACES_FILE))) return null;
79
- const parsed = await fs.readJson(KNOWN_MARKETPLACES_FILE);
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, because `scanLocalMarketplaces` resolves its paths from
101
- * `os.homedir()` at module load and cannot be pointed at a temp directory. The
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
- * Get git remote URL from a marketplace directory
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(marketplacePath: string): string | undefined {
140
- try {
141
- const gitDir = path.join(marketplacePath, ".git");
142
- if (!fs.existsSync(gitDir)) return undefined;
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
- const gitRepo = getGitRemote(marketplacePath);
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
- ? path.join(marketplacePath, sourceStr.replace("./", ""))
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: plugin.version || "0.0.0",
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(CLAUDE_PLUGINS_DIR)) {
441
+ if (await fs.pathExists(claudePluginsDir())) {
383
442
  try {
384
- const entries = await fs.readdir(CLAUDE_PLUGINS_DIR, {
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(CLAUDE_PLUGINS_DIR, entry.name);
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(CLAUDE_PLUGINS_DIR, name);
510
+ const marketplacePath = path.join(claudePluginsDir(), name);
449
511
  const manifestPath = path.join(
450
512
  marketplacePath,
451
513
  ".claude-plugin",