@oh-my-pi/pi-coding-agent 13.16.5 → 13.17.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 (40) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +7 -0
  4. package/src/cli/classify-install-target.ts +50 -0
  5. package/src/cli/plugin-cli.ts +245 -31
  6. package/src/commands/plugin.ts +3 -0
  7. package/src/config/settings-schema.ts +12 -13
  8. package/src/cursor.ts +66 -1
  9. package/src/discovery/claude-plugins.ts +95 -5
  10. package/src/discovery/helpers.ts +168 -41
  11. package/src/discovery/plugin-dir-roots.ts +28 -0
  12. package/src/discovery/substitute-plugin-root.ts +29 -0
  13. package/src/extensibility/plugins/index.ts +1 -0
  14. package/src/extensibility/plugins/marketplace/cache.ts +136 -0
  15. package/src/extensibility/plugins/marketplace/fetcher.ts +354 -0
  16. package/src/extensibility/plugins/marketplace/index.ts +6 -0
  17. package/src/extensibility/plugins/marketplace/manager.ts +528 -0
  18. package/src/extensibility/plugins/marketplace/registry.ts +181 -0
  19. package/src/extensibility/plugins/marketplace/source-resolver.ts +147 -0
  20. package/src/extensibility/plugins/marketplace/types.ts +177 -0
  21. package/src/internal-urls/index.ts +1 -0
  22. package/src/internal-urls/local-protocol.ts +2 -19
  23. package/src/internal-urls/parse.ts +72 -0
  24. package/src/internal-urls/router.ts +2 -18
  25. package/src/lsp/config.ts +9 -0
  26. package/src/main.ts +50 -1
  27. package/src/modes/components/plugin-selector.ts +86 -0
  28. package/src/modes/components/settings-defs.ts +0 -4
  29. package/src/modes/controllers/mcp-command-controller.ts +14 -0
  30. package/src/modes/controllers/selector-controller.ts +104 -13
  31. package/src/modes/interactive-mode.ts +4 -0
  32. package/src/modes/types.ts +1 -0
  33. package/src/prompts/agents/reviewer.md +3 -4
  34. package/src/sdk.ts +0 -7
  35. package/src/slash-commands/builtin-registry.ts +273 -0
  36. package/src/tools/bash-skill-urls.ts +48 -5
  37. package/src/tools/read.ts +15 -9
  38. package/src/web/search/code-search.ts +2 -179
  39. package/src/web/search/index.ts +2 -3
  40. package/src/web/search/types.ts +1 -5
@@ -0,0 +1,528 @@
1
+ /**
2
+ * MarketplaceManager — orchestrates registry, fetcher, resolver, and cache.
3
+ *
4
+ * Constructor takes explicit paths for testability (same pattern as registry.ts).
5
+ * The `clearPluginRootsCache` dependency is injected so callers can provide
6
+ * the real `clearClaudePluginRootsCache` while tests supply a counter stub.
7
+ */
8
+
9
+ import * as fs from "node:fs/promises";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
14
+
15
+ import { cachePlugin } from "./cache";
16
+ import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
17
+ import {
18
+ addInstalledPlugin,
19
+ addMarketplaceEntry,
20
+ getInstalledPlugin,
21
+ getMarketplaceEntry,
22
+ readInstalledPluginsRegistry,
23
+ readMarketplacesRegistry,
24
+ removeInstalledPlugin,
25
+ removeMarketplaceEntry,
26
+ writeInstalledPluginsRegistry,
27
+ writeMarketplacesRegistry,
28
+ } from "./registry";
29
+ import { resolvePluginSource } from "./source-resolver";
30
+ import type {
31
+ InstalledPluginEntry,
32
+ MarketplaceCatalog,
33
+ MarketplacePluginEntry,
34
+ MarketplaceRegistryEntry,
35
+ } from "./types";
36
+ import { buildPluginId, parsePluginId } from "./types";
37
+
38
+ // ── Options ──────────────────────────────────────────────────────────────────
39
+
40
+ export interface MarketplaceManagerOptions {
41
+ marketplacesRegistryPath: string;
42
+ installedRegistryPath: string;
43
+ marketplacesCacheDir: string;
44
+ pluginsCacheDir: string;
45
+ /** Injected for testing; production callers pass clearClaudePluginRootsCache. */
46
+ clearPluginRootsCache?: () => void;
47
+ }
48
+
49
+ // ── Manager ──────────────────────────────────────────────────────────────────
50
+
51
+ export class MarketplaceManager {
52
+ #opts: MarketplaceManagerOptions;
53
+
54
+ constructor(options: MarketplaceManagerOptions) {
55
+ this.#opts = options;
56
+ }
57
+
58
+ // ── Marketplace lifecycle ─────────────────────────────────────────────────
59
+
60
+ async addMarketplace(source: string): Promise<MarketplaceRegistryEntry> {
61
+ const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
62
+ const existingNames = new Set(reg.marketplaces.map(m => m.name));
63
+
64
+ const { catalog, clonePath } = await fetchMarketplace(source, this.#opts.marketplacesCacheDir);
65
+
66
+ if (existingNames.has(catalog.name)) {
67
+ if (clonePath) {
68
+ await fs.rm(clonePath, { recursive: true, force: true }).catch(() => {});
69
+ }
70
+ throw new Error(`Marketplace "${catalog.name}" already exists`);
71
+ }
72
+
73
+ // Promote the temp clone to its final cache location now that we know it's not a duplicate.
74
+ if (clonePath) {
75
+ await promoteCloneToCache(clonePath, this.#opts.marketplacesCacheDir, catalog.name);
76
+ }
77
+
78
+ const sourceType = classifySource(source);
79
+ const normalizedSource =
80
+ sourceType === "local"
81
+ ? path.resolve(source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source)
82
+ : source;
83
+
84
+ const catalogPath = path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json");
85
+
86
+ // Persist the fetched catalog so subsequent reads don't require re-fetching.
87
+ await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
88
+
89
+ const now = new Date().toISOString();
90
+ const entry: MarketplaceRegistryEntry = {
91
+ name: catalog.name,
92
+ sourceType,
93
+ sourceUri: normalizedSource,
94
+ catalogPath,
95
+ addedAt: now,
96
+ updatedAt: now,
97
+ };
98
+
99
+ const updated = addMarketplaceEntry(reg, entry);
100
+ await writeMarketplacesRegistry(this.#opts.marketplacesRegistryPath, updated);
101
+
102
+ logger.debug("Marketplace added", { name: catalog.name, sourceType });
103
+ return entry;
104
+ }
105
+
106
+ async removeMarketplace(name: string): Promise<void> {
107
+ const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
108
+ // removeMarketplaceEntry throws if not found — propagate to caller.
109
+ const updated = removeMarketplaceEntry(reg, name);
110
+ await writeMarketplacesRegistry(this.#opts.marketplacesRegistryPath, updated);
111
+
112
+ await fs.rm(path.join(this.#opts.marketplacesCacheDir, name), {
113
+ recursive: true,
114
+ force: true,
115
+ });
116
+
117
+ logger.debug("Marketplace removed", { name });
118
+ }
119
+
120
+ async updateMarketplace(name: string): Promise<MarketplaceRegistryEntry> {
121
+ const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
122
+ const existing = getMarketplaceEntry(reg, name);
123
+ if (!existing) {
124
+ throw new Error(`Marketplace "${name}" not found`);
125
+ }
126
+
127
+ const { catalog, clonePath } = await fetchMarketplace(existing.sourceUri, this.#opts.marketplacesCacheDir);
128
+
129
+ // Guard against upstream catalog silently renaming itself — the registry
130
+ // entry is keyed by name, so a drift would corrupt the entry on next read.
131
+ if (catalog.name !== name) {
132
+ if (clonePath) {
133
+ await fs.rm(clonePath, { recursive: true, force: true }).catch(() => {});
134
+ }
135
+ throw new Error(
136
+ `Marketplace catalog name changed from "${name}" to "${catalog.name}". ` +
137
+ `Remove and re-add the marketplace to update.`,
138
+ );
139
+ }
140
+
141
+ // Promote the temp clone to its final cache location now that drift check passed.
142
+ if (clonePath) {
143
+ await promoteCloneToCache(clonePath, this.#opts.marketplacesCacheDir, catalog.name);
144
+ }
145
+
146
+ // Overwrite cached catalog
147
+ await Bun.write(existing.catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
148
+
149
+ const updatedEntry: MarketplaceRegistryEntry = {
150
+ ...existing,
151
+ updatedAt: new Date().toISOString(),
152
+ };
153
+
154
+ const updatedReg = {
155
+ ...reg,
156
+ marketplaces: reg.marketplaces.map(m => (m.name === name ? updatedEntry : m)),
157
+ };
158
+ await writeMarketplacesRegistry(this.#opts.marketplacesRegistryPath, updatedReg);
159
+
160
+ logger.debug("Marketplace updated", { name });
161
+ return updatedEntry;
162
+ }
163
+
164
+ async updateAllMarketplaces(): Promise<MarketplaceRegistryEntry[]> {
165
+ const marketplaces = await this.listMarketplaces();
166
+ const results: MarketplaceRegistryEntry[] = [];
167
+ for (const m of marketplaces) {
168
+ const updated = await this.updateMarketplace(m.name);
169
+ results.push(updated);
170
+ }
171
+ return results;
172
+ }
173
+
174
+ async listMarketplaces(): Promise<MarketplaceRegistryEntry[]> {
175
+ const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
176
+ return reg.marketplaces;
177
+ }
178
+
179
+ // ── Plugin discovery ──────────────────────────────────────────────────────
180
+
181
+ async listAvailablePlugins(marketplace?: string): Promise<MarketplacePluginEntry[]> {
182
+ const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
183
+
184
+ if (marketplace !== undefined) {
185
+ const entry = reg.marketplaces.find(m => m.name === marketplace);
186
+ if (!entry) {
187
+ throw new Error(`Marketplace "${marketplace}" not found`);
188
+ }
189
+ const catalog = await this.#readCatalog(entry);
190
+ return catalog.plugins;
191
+ }
192
+
193
+ const all: MarketplacePluginEntry[] = [];
194
+ for (const entry of reg.marketplaces) {
195
+ const catalog = await this.#readCatalog(entry);
196
+ all.push(...catalog.plugins);
197
+ }
198
+ return all;
199
+ }
200
+
201
+ async getPluginInfo(name: string, marketplace: string): Promise<MarketplacePluginEntry | null> {
202
+ const plugins = await this.listAvailablePlugins(marketplace);
203
+ return plugins.find(p => p.name === name) ?? null;
204
+ }
205
+
206
+ // ── Install / uninstall ───────────────────────────────────────────────────
207
+
208
+ async installPlugin(
209
+ name: string,
210
+ marketplace: string,
211
+ options?: { force?: boolean },
212
+ ): Promise<InstalledPluginEntry> {
213
+ const force = options?.force ?? false;
214
+
215
+ // 1. Find marketplace entry
216
+ const mktReg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
217
+ const mktEntry = getMarketplaceEntry(mktReg, marketplace);
218
+ if (!mktEntry) {
219
+ throw new Error(`Marketplace "${marketplace}" not found`);
220
+ }
221
+
222
+ // 2. Find plugin in catalog
223
+ const catalog = await this.#readCatalog(mktEntry);
224
+ const pluginEntry = catalog.plugins.find(p => p.name === name);
225
+ if (!pluginEntry) {
226
+ throw new Error(`Plugin "${name}" not found in marketplace "${marketplace}"`);
227
+ }
228
+
229
+ const pluginId = buildPluginId(name, marketplace);
230
+
231
+ // 3. Check if already installed
232
+ const instReg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath);
233
+ const existing = getInstalledPlugin(instReg, pluginId);
234
+ if (existing && existing.length > 0 && !force) {
235
+ throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`);
236
+ }
237
+
238
+ // 4. Resolve source path.
239
+ // marketplaceClonePath is the marketplace root — the directory containing .claude-plugin/
240
+ // catalogPath is <marketplacesCacheDir>/<name>/marketplace.json, so the root is two levels up.
241
+ // For local sources the content was fetched from a local path; the stored catalog is a copy
242
+ // under marketplacesCacheDir. We need the original source root for resolving relative paths.
243
+ // Use: path.dirname(catalogPath) is <cacheDir>/<name>/, and that IS the stored copy root,
244
+ // so `path.resolve(mktEntry.catalogPath, "../..")` = parent of <name>/ inside cacheDir
245
+ // which is wrong for local sources. Instead, derive from the stored catalog directory:
246
+ // stored at: <marketplacesCacheDir>/<catalogName>/marketplace.json
247
+ // The marketplace root for local sources should be the actual local path, but we only have
248
+ // sourceUri. For local sources, use path.resolve of sourceUri; for others use the cache dir.
249
+ const marketplaceClonePath = this.#resolveMarketplaceRoot(mktEntry);
250
+
251
+ // URL-sourced marketplaces only cache marketplace.json, not the full plugin tree.
252
+ // Relative string sources ("./plugins/foo") cannot be resolved against the cache dir.
253
+ if (mktEntry.sourceType === "url" && typeof pluginEntry.source === "string") {
254
+ throw new Error(
255
+ `Plugin "${name}" uses a relative source path but marketplace "${marketplace}" was added via URL. ` +
256
+ `Relative sources require a git or local marketplace. Re-add the marketplace using its git URL.`,
257
+ );
258
+ }
259
+
260
+ const { dir: sourcePath, tempCloneRoot } = await resolvePluginSource(pluginEntry, {
261
+ marketplaceClonePath,
262
+ catalogMetadata: catalog.metadata,
263
+ tmpDir: os.tmpdir(),
264
+ });
265
+
266
+ // 5. Determine version: catalog entry > plugin manifest > git SHA > fallback
267
+ let version!: string;
268
+ let cachePath!: string;
269
+ try {
270
+ version = await this.#resolvePluginVersion(pluginEntry, sourcePath);
271
+ cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version);
272
+ } finally {
273
+ // Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone
274
+ if (tempCloneRoot) {
275
+ await fs.rm(tempCloneRoot, { recursive: true, force: true }).catch(() => {});
276
+ }
277
+ }
278
+
279
+ // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
280
+ if (existing && existing.length > 0) {
281
+ for (const entry of existing) {
282
+ // Skip if the new cache resolved to the same path (same version reinstall).
283
+ if (entry.installPath !== cachePath) {
284
+ await fs.rm(entry.installPath, { recursive: true, force: true });
285
+ }
286
+ }
287
+ const prunedReg = removeInstalledPlugin(
288
+ await readInstalledPluginsRegistry(this.#opts.installedRegistryPath),
289
+ pluginId,
290
+ );
291
+ await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, prunedReg);
292
+ }
293
+
294
+ // 6. Build and register the entry, preserving enabled state from previous install
295
+ const now = new Date().toISOString();
296
+ // Carry over enabled flag from existing entry — a disabled plugin must stay disabled after upgrade
297
+ const wasDisabled = existing?.some(e => e.enabled === false);
298
+ const installedEntry: InstalledPluginEntry = {
299
+ scope: "user",
300
+ installPath: cachePath,
301
+ version,
302
+ installedAt: now,
303
+ lastUpdated: now,
304
+ ...(wasDisabled ? { enabled: false } : {}),
305
+ };
306
+
307
+ const freshInstReg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath);
308
+ const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
309
+ await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, newInstReg);
310
+
311
+ this.#opts.clearPluginRootsCache?.();
312
+
313
+ logger.debug("Plugin installed", { pluginId, version, cachePath });
314
+ return installedEntry;
315
+ }
316
+
317
+ /**
318
+ * Resolve plugin version from multiple sources:
319
+ * 1. Catalog entry version (if set)
320
+ * 2. Plugin manifest (.claude-plugin/plugin.json or package.json)
321
+ * 3. Git SHA from source (truncated to 7 chars)
322
+ * 4. Fallback "0.0.0"
323
+ */
324
+ async #resolvePluginVersion(entry: MarketplacePluginEntry, sourcePath: string): Promise<string> {
325
+ // 1. Catalog entry version
326
+ if (entry.version) return entry.version;
327
+
328
+ // 2. Plugin manifest
329
+ for (const manifestPath of [
330
+ path.join(sourcePath, ".claude-plugin", "plugin.json"),
331
+ path.join(sourcePath, "package.json"),
332
+ ]) {
333
+ try {
334
+ const content = await Bun.file(manifestPath).json();
335
+ if (typeof content?.version === "string" && content.version) {
336
+ return content.version;
337
+ }
338
+ } catch {
339
+ // Missing or invalid — try next
340
+ }
341
+ }
342
+
343
+ // 3. Git SHA from source definition
344
+ if (typeof entry.source === "object" && "sha" in entry.source && entry.source.sha) {
345
+ return entry.source.sha.slice(0, 7);
346
+ }
347
+
348
+ return "0.0.0";
349
+ }
350
+
351
+ async uninstallPlugin(pluginId: string): Promise<void> {
352
+ const parsed = parsePluginId(pluginId);
353
+ if (!parsed) {
354
+ throw new Error(`Invalid plugin ID format: "${pluginId}". Expected "name@marketplace".`);
355
+ }
356
+
357
+ const reg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath);
358
+ const entries = getInstalledPlugin(reg, pluginId);
359
+ if (!entries || entries.length === 0) {
360
+ throw new Error(`Plugin "${pluginId}" is not installed`);
361
+ }
362
+
363
+ // Remove all install paths from disk
364
+ for (const entry of entries) {
365
+ await fs.rm(entry.installPath, { recursive: true, force: true });
366
+ }
367
+
368
+ const updated = removeInstalledPlugin(reg, pluginId);
369
+ await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, updated);
370
+
371
+ this.#opts.clearPluginRootsCache?.();
372
+
373
+ logger.debug("Plugin uninstalled", { pluginId });
374
+ }
375
+
376
+ // ── Plugin state ──────────────────────────────────────────────────────────
377
+
378
+ async listInstalledPlugins(): Promise<Array<{ id: string; entries: InstalledPluginEntry[] }>> {
379
+ const reg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath);
380
+ return Object.entries(reg.plugins).map(([id, entries]) => ({ id, entries }));
381
+ }
382
+
383
+ async setPluginEnabled(pluginId: string, enabled: boolean): Promise<void> {
384
+ const reg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath);
385
+ const entries = getInstalledPlugin(reg, pluginId);
386
+ if (!entries || entries.length === 0) {
387
+ throw new Error(`Plugin "${pluginId}" is not installed`);
388
+ }
389
+
390
+ const updated = {
391
+ ...reg,
392
+ plugins: {
393
+ ...reg.plugins,
394
+ [pluginId]: entries.map(e => ({ ...e, enabled })),
395
+ },
396
+ };
397
+ await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, updated);
398
+
399
+ this.#opts.clearPluginRootsCache?.();
400
+
401
+ logger.debug("Plugin enabled state changed", { pluginId, enabled });
402
+ }
403
+
404
+ // ── Update / upgrade ─────────────────────────────────────────────────────
405
+
406
+ // Refresh marketplace catalogs that haven't been updated in more than 24 h.
407
+ // Per-marketplace failures are silently swallowed — offline is fine.
408
+ async refreshStaleMarketplaces(): Promise<void> {
409
+ const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
410
+ const staleMs = 24 * 60 * 60 * 1000;
411
+ for (const entry of reg.marketplaces) {
412
+ if (Date.now() - Date.parse(entry.updatedAt) >= staleMs) {
413
+ try {
414
+ await this.updateMarketplace(entry.name);
415
+ } catch {
416
+ // Network or parse failure — leave stale, try next time.
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ // Compare installed plugin versions against their catalog entries.
423
+ // Returns only plugins where the catalog declares a newer semver version.
424
+ // Catalog entries without a version field are skipped.
425
+ async checkForUpdates(): Promise<Array<{ pluginId: string; from: string; to: string }>> {
426
+ const instReg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath);
427
+ const mktReg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
428
+ const updates: Array<{ pluginId: string; from: string; to: string }> = [];
429
+
430
+ for (const [pluginId, entries] of Object.entries(instReg.plugins)) {
431
+ const parsed = parsePluginId(pluginId);
432
+ if (!parsed) continue;
433
+ const installed = entries[0];
434
+ if (!installed) continue;
435
+
436
+ const mktEntry = mktReg.marketplaces.find(m => m.name === parsed.marketplace);
437
+ if (!mktEntry) continue;
438
+
439
+ let catalogVersion: string | undefined;
440
+ try {
441
+ const catalog = await this.#readCatalog(mktEntry);
442
+ catalogVersion = catalog.plugins.find(p => p.name === parsed.name)?.version;
443
+ } catch {
444
+ continue;
445
+ }
446
+
447
+ if (!catalogVersion || catalogVersion === installed.version) continue;
448
+
449
+ // Treat newer semver as an update; fall back to inequality for non-semver tags.
450
+ let isNewer: boolean;
451
+ try {
452
+ isNewer = Bun.semver.order(catalogVersion, installed.version) > 0;
453
+ } catch {
454
+ isNewer = catalogVersion !== installed.version;
455
+ }
456
+
457
+ if (isNewer) {
458
+ updates.push({ pluginId, from: installed.version, to: catalogVersion });
459
+ }
460
+ }
461
+
462
+ return updates;
463
+ }
464
+
465
+ // Re-install a specific plugin at the latest catalog version (force-overwrites).
466
+ async upgradePlugin(pluginId: string): Promise<InstalledPluginEntry> {
467
+ const parsed = parsePluginId(pluginId);
468
+ if (!parsed) {
469
+ throw new Error(`Invalid plugin ID: "${pluginId}". Expected "name@marketplace".`);
470
+ }
471
+ return this.installPlugin(parsed.name, parsed.marketplace, { force: true });
472
+ }
473
+
474
+ // Upgrade every plugin that checkForUpdates reports as outdated.
475
+ // Per-plugin failures are skipped — partial success is returned.
476
+ async upgradeAllPlugins(): Promise<Array<{ pluginId: string; from: string; to: string }>> {
477
+ const updates = await this.checkForUpdates();
478
+ const results: Array<{ pluginId: string; from: string; to: string }> = [];
479
+ for (const update of updates) {
480
+ try {
481
+ await this.upgradePlugin(update.pluginId);
482
+ results.push(update);
483
+ } catch {
484
+ // Skip this plugin; partial upgrades are better than none.
485
+ }
486
+ }
487
+ return results;
488
+ }
489
+
490
+ // ── Private helpers ───────────────────────────────────────────────────────
491
+
492
+ async #readCatalog(entry: MarketplaceRegistryEntry): Promise<MarketplaceCatalog> {
493
+ try {
494
+ const content = await Bun.file(entry.catalogPath).text();
495
+ return parseMarketplaceCatalog(content, entry.catalogPath);
496
+ } catch (err) {
497
+ if (isEnoent(err)) {
498
+ throw new Error(
499
+ `Marketplace catalog not found at ${entry.catalogPath}. Try: /marketplace update ${entry.name}`,
500
+ );
501
+ }
502
+ throw err;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Compute the marketplace root directory for source resolution.
508
+ *
509
+ * For local sources: sourceUri IS the local path, so resolve it directly.
510
+ * This gives the directory containing `.claude-plugin/marketplace.json`,
511
+ * which is what resolvePluginSource expects as `marketplaceClonePath`.
512
+ *
513
+ * For remote sources (git/github/url): the catalog was cloned into
514
+ * `<marketplacesCacheDir>/<name>/`, so the root is the parent of catalogPath.
515
+ */
516
+ #resolveMarketplaceRoot(entry: MarketplaceRegistryEntry): string {
517
+ if (entry.sourceType === "local") {
518
+ // expandHome already happened in fetcher; resolve to ensure absolute.
519
+ const expanded = entry.sourceUri.startsWith("~/")
520
+ ? path.join(os.homedir(), entry.sourceUri.slice(2))
521
+ : entry.sourceUri;
522
+ return path.resolve(expanded);
523
+ }
524
+ // For git/github/url sources, the catalog lives at <cloneDir>/marketplace.json
525
+ // under marketplacesCacheDir/<name>/; parent = <marketplacesCacheDir>/<name>/
526
+ return path.dirname(entry.catalogPath);
527
+ }
528
+ }