@oh-my-pi/pi-coding-agent 16.0.0 → 16.0.2

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 (70) hide show
  1. package/CHANGELOG.md +140 -133
  2. package/dist/cli.js +250 -218
  3. package/dist/types/config/model-resolver.d.ts +14 -0
  4. package/dist/types/config/settings-schema.d.ts +22 -0
  5. package/dist/types/discovery/helpers.d.ts +7 -0
  6. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  7. package/dist/types/exec/non-interactive-env.d.ts +2 -0
  8. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  9. package/dist/types/modes/types.d.ts +5 -0
  10. package/dist/types/session/agent-session.d.ts +11 -1
  11. package/dist/types/session/messages.d.ts +3 -0
  12. package/dist/types/session/session-manager.d.ts +4 -1
  13. package/dist/types/task/index.d.ts +21 -0
  14. package/dist/types/tools/github-cache.d.ts +5 -4
  15. package/dist/types/tools/job.d.ts +1 -0
  16. package/dist/types/utils/markit.d.ts +8 -0
  17. package/dist/types/web/search/index.d.ts +2 -2
  18. package/dist/types/web/search/provider.d.ts +2 -0
  19. package/package.json +12 -12
  20. package/src/advisor/__tests__/advisor.test.ts +44 -0
  21. package/src/cli/args.ts +2 -0
  22. package/src/collab/host.ts +1 -1
  23. package/src/config/model-resolver.ts +35 -1
  24. package/src/config/settings-schema.ts +23 -1
  25. package/src/discovery/claude-plugins.ts +3 -42
  26. package/src/discovery/github.ts +189 -6
  27. package/src/discovery/helpers.ts +11 -0
  28. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  29. package/src/eval/js/shared/prelude.txt +12 -3
  30. package/src/eval/py/prelude.py +26 -2
  31. package/src/exec/bash-executor.ts +2 -2
  32. package/src/exec/non-interactive-env.ts +71 -0
  33. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  34. package/src/extensibility/extensions/runner.ts +17 -1
  35. package/src/extensibility/plugins/loader.ts +157 -23
  36. package/src/extensibility/plugins/manager.ts +44 -36
  37. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  38. package/src/extensibility/plugins/runtime-config.ts +9 -0
  39. package/src/internal-urls/docs-index.generated.ts +9 -9
  40. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  41. package/src/main.ts +5 -1
  42. package/src/modes/acp/acp-agent.ts +3 -3
  43. package/src/modes/components/settings-defs.ts +7 -0
  44. package/src/modes/components/tips.txt +1 -1
  45. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  46. package/src/modes/controllers/input-controller.ts +1 -0
  47. package/src/modes/controllers/selector-controller.ts +7 -0
  48. package/src/modes/interactive-mode.ts +47 -0
  49. package/src/modes/rpc/rpc-mode.ts +3 -3
  50. package/src/modes/runtime-init.ts +2 -1
  51. package/src/modes/types.ts +5 -0
  52. package/src/prompts/agents/designer.md +8 -0
  53. package/src/prompts/review-request.md +1 -1
  54. package/src/prompts/system/subagent-system-prompt.md +4 -1
  55. package/src/prompts/tools/eval.md +13 -3
  56. package/src/prompts/tools/irc.md +1 -1
  57. package/src/sdk.ts +9 -1
  58. package/src/session/agent-session.ts +260 -50
  59. package/src/session/messages.ts +1 -1
  60. package/src/session/session-manager.ts +3 -1
  61. package/src/slash-commands/builtin-registry.ts +5 -2
  62. package/src/system-prompt.ts +7 -1
  63. package/src/task/executor.ts +105 -8
  64. package/src/task/index.ts +70 -9
  65. package/src/tools/github-cache.ts +32 -7
  66. package/src/tools/job.ts +14 -1
  67. package/src/utils/lang-from-path.ts +5 -0
  68. package/src/utils/markit.ts +24 -1
  69. package/src/web/search/index.ts +2 -2
  70. package/src/web/search/provider.ts +14 -2
@@ -9,6 +9,7 @@ import * as path from "node:path";
9
9
  import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
10
10
  import { getConfigDirPaths } from "../../config";
11
11
  import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
12
+ import { normalizePluginRuntimeConfig } from "./runtime-config";
12
13
  import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
13
14
 
14
15
  installLegacyPiSpecifierShim();
@@ -28,9 +29,9 @@ installLegacyPiSpecifierShim();
28
29
  async function loadRuntimeConfig(home?: string): Promise<PluginRuntimeConfig> {
29
30
  const lockPath = getPluginsLockfile(home);
30
31
  try {
31
- return await Bun.file(lockPath).json();
32
+ return normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
32
33
  } catch (err) {
33
- if (isEnoent(err)) return { plugins: {}, settings: {} };
34
+ if (isEnoent(err)) return normalizePluginRuntimeConfig({});
34
35
  throw err;
35
36
  }
36
37
  }
@@ -142,29 +143,161 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
142
143
  // Path Resolution
143
144
  // =============================================================================
144
145
 
145
- const MANIFEST_ENTRY_INDEX_NAMES = ["index.ts", "index.js", "index.mjs", "index.cjs"];
146
+ const MANIFEST_ENTRY_MODULE_EXTENSIONS = [".ts", ".js", ".mjs", ".cjs"];
147
+ const MANIFEST_ENTRY_INDEX_NAMES = MANIFEST_ENTRY_MODULE_EXTENSIONS.map(ext => `index${ext}`);
148
+
149
+ /** `.d.ts` / `.d.mts` / `.d.cts` TypeScript declaration files — never loadable as modules. */
150
+ const DECLARATION_FILE_RE = /\.d\.[mc]?ts$/;
151
+
152
+ /** A loadable module file: a .ts/.js/.mjs/.cjs that is not a declaration file. */
153
+ function isModuleFile(name: string): boolean {
154
+ return MANIFEST_ENTRY_MODULE_EXTENSIONS.includes(path.extname(name)) && !DECLARATION_FILE_RE.test(name);
155
+ }
156
+
157
+ /** First `index.{ts,js,mjs,cjs}` inside `dir`, or null when none exists. */
158
+ function findDirectoryIndex(dir: string): string | null {
159
+ for (const name of MANIFEST_ENTRY_INDEX_NAMES) {
160
+ const candidate = path.join(dir, name);
161
+ if (fs.existsSync(candidate)) return candidate;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ interface DeclaredManifestEntries {
167
+ /** True when the directory's package.json declares a non-empty `omp`/`pi` `extensions` array. */
168
+ declared: boolean;
169
+ /** Resolved, existing module files for the declared entries (may be empty when declared files are missing). */
170
+ files: string[];
171
+ }
172
+
173
+ /**
174
+ * Read the extension entries declared by `dir`'s own package.json `omp`/`pi`
175
+ * manifest. `declared` distinguishes "a manifest explicitly lists extensions"
176
+ * (authoritative — callers must not fall back to index/scan, so a missing
177
+ * declared file surfaces as a missing entry instead of silently loading a stale
178
+ * index) from "no manifest / no extensions field" (callers fall back to
179
+ * convention). Mirrors the manifest branch of the configured-directory (`-e`)
180
+ * scanner: a declared entry that is a file resolves to itself; one that is a
181
+ * directory resolves to its direct index.{ts,js,mjs,cjs}.
182
+ */
183
+ function readDeclaredManifestEntries(dir: string): DeclaredManifestEntries {
184
+ let raw: string;
185
+ try {
186
+ raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
187
+ } catch {
188
+ return { declared: false, files: [] };
189
+ }
190
+ let pkg: { omp?: { extensions?: unknown }; pi?: { extensions?: unknown } };
191
+ try {
192
+ pkg = JSON.parse(raw) as { omp?: { extensions?: unknown }; pi?: { extensions?: unknown } };
193
+ } catch {
194
+ return { declared: false, files: [] };
195
+ }
196
+ const declared = (pkg.omp ?? pkg.pi)?.extensions;
197
+ if (!Array.isArray(declared) || declared.length === 0) {
198
+ return { declared: false, files: [] };
199
+ }
200
+ const files: string[] = [];
201
+ for (const entry of declared) {
202
+ if (typeof entry !== "string") continue;
203
+ const candidate = path.resolve(dir, entry);
204
+ let candidateStats: fs.Stats;
205
+ try {
206
+ candidateStats = fs.statSync(candidate);
207
+ } catch {
208
+ continue;
209
+ }
210
+ if (candidateStats.isDirectory()) {
211
+ const index = findDirectoryIndex(candidate);
212
+ if (index) files.push(index);
213
+ } else {
214
+ files.push(candidate);
215
+ }
216
+ }
217
+ return { declared: true, files };
218
+ }
146
219
 
147
220
  /**
148
- * Resolve a plugin manifest entry to a concrete loadable file path. Returns the
149
- * file path itself when the entry points at a file, the matching index file when
150
- * the entry points at a directory containing index.{ts,js,mjs,cjs}, and null
151
- * when no entry exists at the joined path.
221
+ * Resolve a directory to its loadable extension module files, mirroring the
222
+ * configured-directory (`-e`) scanner in extensions/loader.ts:
223
+ * 1. the directory's own package.json `omp`/`pi` `extensions` entries
224
+ * authoritative: a manifest that lists extensions suppresses the index/scan
225
+ * fallback, so a missing declared file is reported rather than silently
226
+ * replaced by a decoy index
227
+ * 2. a direct index.{ts,js,mjs,cjs}
228
+ * 3. one level of children: each direct *.{ts,js,mjs,cjs} file plus each
229
+ * sub-directory resolved by the same precedence (manifest, then index)
152
230
  */
153
- function resolveManifestEntryFile(joined: string): string | null {
231
+ function resolveDirectoryEntries(dir: string): string[] {
232
+ const manifest = readDeclaredManifestEntries(dir);
233
+ if (manifest.declared) return manifest.files;
234
+
235
+ const directIndex = findDirectoryIndex(dir);
236
+ if (directIndex) return [directIndex];
237
+
238
+ let children: string[];
239
+ try {
240
+ children = fs.readdirSync(dir);
241
+ } catch {
242
+ return [];
243
+ }
244
+ const resolved: string[] = [];
245
+ for (const child of children.sort()) {
246
+ const childPath = path.join(dir, child);
247
+ let childStats: fs.Stats;
248
+ try {
249
+ // statSync follows symlinks, matching the configured-dir loader.
250
+ childStats = fs.statSync(childPath);
251
+ } catch {
252
+ continue;
253
+ }
254
+ if (childStats.isDirectory()) {
255
+ const childManifest = readDeclaredManifestEntries(childPath);
256
+ if (childManifest.declared) {
257
+ resolved.push(...childManifest.files);
258
+ } else {
259
+ const index = findDirectoryIndex(childPath);
260
+ if (index) resolved.push(index);
261
+ }
262
+ } else if (isModuleFile(child)) {
263
+ resolved.push(childPath);
264
+ }
265
+ }
266
+ return resolved;
267
+ }
268
+
269
+ /**
270
+ * Resolve a plugin manifest entry to the loadable module files it names:
271
+ * - a file entry → that file
272
+ * - a directory:
273
+ * - when `expandDirectory` (the `extensions` key), resolved by
274
+ * {@link resolveDirectoryEntries} — its own package.json `omp`/`pi`
275
+ * `extensions`, then a direct index, then a one-level scan of
276
+ * sub-extensions — matching the pi `extensions/<name>/index.ts` convention
277
+ * and OMP's configured-directory (`-e`) extension loader
278
+ * - otherwise (tools/hooks/commands) only a direct index.{ts,js,mjs,cjs}.
279
+ * The sub-extension scan and the `omp`/`pi` `extensions` manifest are
280
+ * extensions-specific and must not hijack a non-extension directory entry
281
+ * (e.g. a `tools: "."` entry must still resolve `./index.ts`).
282
+ *
283
+ * Returns an empty array when nothing loadable exists at `joined`, letting
284
+ * callers flag a missing entry instead of silently dropping it.
285
+ */
286
+ function resolveManifestEntryFiles(joined: string, expandDirectory: boolean): string[] {
154
287
  let stats: fs.Stats;
155
288
  try {
156
289
  stats = fs.statSync(joined);
157
290
  } catch {
158
- return null;
291
+ return [];
159
292
  }
160
- if (stats.isDirectory()) {
161
- for (const name of MANIFEST_ENTRY_INDEX_NAMES) {
162
- const candidate = path.join(joined, name);
163
- if (fs.existsSync(candidate)) return candidate;
164
- }
165
- return null;
293
+ if (!stats.isDirectory()) {
294
+ return [joined];
295
+ }
296
+ if (expandDirectory) {
297
+ return resolveDirectoryEntries(joined);
166
298
  }
167
- return joined;
299
+ const index = findDirectoryIndex(joined);
300
+ return index ? [index] : [];
168
301
  }
169
302
 
170
303
  /**
@@ -195,16 +328,17 @@ export function resolvePluginManifestEntries(
195
328
  const declared: Array<{ entry: string; resolvedPath: string | null }> = [];
196
329
  const manifest = plugin.manifest;
197
330
 
198
- const resolveEntry = (entry: string): { entry: string; resolvedPath: string | null } => ({
199
- entry,
200
- resolvedPath: resolveManifestEntryFile(path.join(plugin.path, entry)),
201
- });
331
+ const expandDirectory = key === "extensions";
332
+ const resolveEntry = (entry: string): Array<{ entry: string; resolvedPath: string | null }> => {
333
+ const files = resolveManifestEntryFiles(path.join(plugin.path, entry), expandDirectory);
334
+ return files.length > 0 ? files.map(resolvedPath => ({ entry, resolvedPath })) : [{ entry, resolvedPath: null }];
335
+ };
202
336
 
203
337
  const base = manifest[key];
204
338
  if (base) {
205
339
  const entries = Array.isArray(base) ? base : [base];
206
340
  for (const entry of entries) {
207
- declared.push(resolveEntry(entry));
341
+ declared.push(...resolveEntry(entry));
208
342
  }
209
343
  }
210
344
 
@@ -214,7 +348,7 @@ export function resolvePluginManifestEntries(
214
348
  if (!enabledSet.has(featName)) continue;
215
349
  if (feat[key]) {
216
350
  for (const entry of feat[key]) {
217
- declared.push(resolveEntry(entry));
351
+ declared.push(...resolveEntry(entry));
218
352
  }
219
353
  }
220
354
  }
@@ -224,7 +358,7 @@ export function resolvePluginManifestEntries(
224
358
  if (!feat.default) continue;
225
359
  if (feat[key]) {
226
360
  for (const entry of feat[key]) {
227
- declared.push(resolveEntry(entry));
361
+ declared.push(...resolveEntry(entry));
228
362
  }
229
363
  }
230
364
  }
@@ -15,6 +15,7 @@ import { type GitSource, parseGitUrl } from "./git-url";
15
15
  import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
16
16
  import { resolvePluginManifestEntries } from "./loader";
17
17
  import { extractPackageName, parsePluginSpec } from "./parser";
18
+ import { normalizePluginRuntimeConfig } from "./runtime-config";
18
19
  import type {
19
20
  DoctorCheck,
20
21
  DoctorOptions,
@@ -124,11 +125,11 @@ export class PluginManager {
124
125
  async #loadRuntimeConfig(): Promise<PluginRuntimeConfig> {
125
126
  const lockPath = getPluginsLockfile();
126
127
  try {
127
- return await Bun.file(lockPath).json();
128
+ return normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
128
129
  } catch (err) {
129
- if (isEnoent(err)) return { plugins: {}, settings: {} };
130
+ if (isEnoent(err)) return normalizePluginRuntimeConfig({});
130
131
  logger.warn("Failed to load plugin runtime config", { path: lockPath, error: String(err) });
131
- return { plugins: {}, settings: {} };
132
+ return normalizePluginRuntimeConfig({});
132
133
  }
133
134
  }
134
135
 
@@ -204,6 +205,17 @@ export class PluginManager {
204
205
  }
205
206
  }
206
207
 
208
+ #collectInstalledNames(deps: Record<string, string>, config: PluginRuntimeConfig): Set<string> {
209
+ const installedNames = new Set<string>();
210
+ for (const name of Object.keys(deps)) {
211
+ installedNames.add(name);
212
+ }
213
+ for (const name of Object.keys(config.plugins)) {
214
+ installedNames.add(name);
215
+ }
216
+ return installedNames;
217
+ }
218
+
207
219
  async #snapshotInstalledPackage(actualName: string | undefined): Promise<PluginPackageSnapshot | null> {
208
220
  if (!actualName) {
209
221
  return null;
@@ -489,21 +501,22 @@ export class PluginManager {
489
501
  */
490
502
  async list(): Promise<InstalledPlugin[]> {
491
503
  const pkgJsonPath = getPluginsPackageJson();
492
- let pkg: { dependencies?: Record<string, string> };
504
+ let deps: Record<string, string> = {};
493
505
  try {
494
- pkg = await Bun.file(pkgJsonPath).json();
506
+ const pkg: { dependencies?: Record<string, string> } = await Bun.file(pkgJsonPath).json();
507
+ deps = pkg.dependencies ?? {};
495
508
  } catch (err) {
496
- if (isEnoent(err)) return [];
497
- throw err;
509
+ if (!isEnoent(err)) throw err;
498
510
  }
499
511
 
500
- const deps = pkg.dependencies || {};
501
512
  const projectOverrides = await this.#loadProjectOverrides();
502
513
  const config = await this.#ensureConfigLoaded();
503
514
  const plugins: InstalledPlugin[] = [];
515
+ const installedNames = this.#collectInstalledNames(deps, config);
504
516
 
505
- for (const [name] of Object.entries(deps)) {
506
- const pluginPkgPath = path.join(getPluginsNodeModules(), name, "package.json");
517
+ for (const name of installedNames) {
518
+ const pluginPath = path.join(getPluginsNodeModules(), name);
519
+ const pluginPkgPath = path.join(pluginPath, "package.json");
507
520
  let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest };
508
521
  try {
509
522
  pluginPkg = await Bun.file(pluginPkgPath).json();
@@ -520,14 +533,13 @@ export class PluginManager {
520
533
  enabled: true,
521
534
  };
522
535
 
523
- // Apply project overrides
524
536
  const isDisabledInProject = projectOverrides.disabled?.includes(name) ?? false;
525
537
  const projectFeatures = projectOverrides.features?.[name];
526
538
 
527
539
  plugins.push({
528
540
  name,
529
541
  version: pluginPkg.version,
530
- path: path.join(getPluginsNodeModules(), name),
542
+ path: pluginPath,
531
543
  manifest,
532
544
  enabledFeatures: projectFeatures ?? runtimeState.enabledFeatures,
533
545
  enabled: runtimeState.enabled && !isDisabledInProject,
@@ -743,15 +755,14 @@ export class PluginManager {
743
755
  message: hasNodeModules ? "Found" : "Missing (run npm install in plugins dir)",
744
756
  });
745
757
 
746
- if (!hasPkgJson) {
747
- return checks;
748
- }
749
758
  const deps = pkg.dependencies || {};
750
759
  const config = await this.#ensureConfigLoaded();
760
+ const installedNames = this.#collectInstalledNames(deps, config);
751
761
 
752
- for (const [name] of Object.entries(deps)) {
762
+ for (const name of installedNames) {
753
763
  const pluginPath = path.join(nodeModulesPath, name);
754
764
  const pluginPkgPath = path.join(pluginPath, "package.json");
765
+ const fromDependencies = name in deps;
755
766
 
756
767
  let pluginPkg: { version: string; description?: string; omp?: PluginManifest; pi?: PluginManifest };
757
768
  try {
@@ -759,13 +770,23 @@ export class PluginManager {
759
770
  } catch (err) {
760
771
  if (isEnoent(err)) {
761
772
  if (!fs.existsSync(pluginPath)) {
762
- const fixed = options.fix ? await this.#fixMissingPlugin() : false;
763
- checks.push({
764
- name: `plugin:${name}`,
765
- status: "error",
766
- message: "Missing from node_modules",
767
- fixed,
768
- });
773
+ if (fromDependencies) {
774
+ const fixed = options.fix ? await this.#fixMissingPlugin() : false;
775
+ checks.push({
776
+ name: `plugin:${name}`,
777
+ status: "error",
778
+ message: "Missing from node_modules",
779
+ fixed,
780
+ });
781
+ } else {
782
+ const fixed = options.fix ? await this.#removeOrphanedConfig(name) : false;
783
+ checks.push({
784
+ name: `orphan:${name}`,
785
+ status: "warning",
786
+ message: "Plugin in config but not installed",
787
+ fixed,
788
+ });
789
+ }
769
790
  } else {
770
791
  checks.push({
771
792
  name: `plugin:${name}`,
@@ -843,19 +864,6 @@ export class PluginManager {
843
864
  }
844
865
  }
845
866
 
846
- // Check for orphaned runtime config entries
847
- for (const name of Object.keys(config.plugins)) {
848
- if (!(name in deps)) {
849
- const fixed = options.fix ? await this.#removeOrphanedConfig(name) : false;
850
- checks.push({
851
- name: `orphan:${name}`,
852
- status: "warning",
853
- message: "Plugin in config but not installed",
854
- fixed,
855
- });
856
- }
857
- }
858
-
859
867
  return checks;
860
868
  }
861
869
 
@@ -192,8 +192,33 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
192
192
 
193
193
  // ── fetchMarketplace ──────────────────────────────────────────────────
194
194
 
195
- /** Relative path from a marketplace root to its catalog file. */
196
- const CATALOG_RELATIVE_PATH = path.join(".claude-plugin", "marketplace.json");
195
+ /**
196
+ * Catalog paths tried in priority order: omp-namespaced override first, then
197
+ * the Claude Code-compatible fallback so existing marketplaces keep loading.
198
+ */
199
+ const CATALOG_RELATIVE_PATHS: readonly string[] = [
200
+ path.join(".omp-plugin", "marketplace.json"),
201
+ path.join(".claude-plugin", "marketplace.json"),
202
+ ];
203
+
204
+ async function readMarketplaceCatalog(root: string): Promise<{ catalogPath: string; content: string }> {
205
+ const tried: string[] = [];
206
+ for (const rel of CATALOG_RELATIVE_PATHS) {
207
+ const catalogPath = path.join(root, rel);
208
+ tried.push(catalogPath);
209
+ try {
210
+ const content = await Bun.file(catalogPath).text();
211
+ return { catalogPath, content };
212
+ } catch (err) {
213
+ if (isEnoent(err)) continue;
214
+ throw err;
215
+ }
216
+ }
217
+ throw new Error(
218
+ `Marketplace catalog not found at ${tried.map(p => `"${p}"`).join(" or ")}. ` +
219
+ `Ensure the directory exists and contains one of: ${CATALOG_RELATIVE_PATHS.join(", ")}.`,
220
+ );
221
+ }
197
222
 
198
223
  /**
199
224
  * Expand a `~/...` path to an absolute path using os.homedir().
@@ -220,21 +245,7 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
220
245
 
221
246
  if (type === "local") {
222
247
  const resolved = path.resolve(expandHome(source));
223
- const catalogPath = path.join(resolved, CATALOG_RELATIVE_PATH);
224
-
225
- let content: string;
226
- try {
227
- content = await Bun.file(catalogPath).text();
228
- } catch (err) {
229
- if (isEnoent(err)) {
230
- throw new Error(
231
- `Marketplace catalog not found at "${catalogPath}". ` +
232
- `Ensure the directory exists and contains a .claude-plugin/marketplace.json file.`,
233
- );
234
- }
235
- throw err;
236
- }
237
-
248
+ const { catalogPath, content } = await readMarketplaceCatalog(resolved);
238
249
  const catalog = parseMarketplaceCatalog(content, catalogPath);
239
250
  return { catalog };
240
251
  }
@@ -280,27 +291,14 @@ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<Fetch
280
291
  logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`);
281
292
  await git.clone(url, tmpDir);
282
293
 
283
- const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH);
284
- let content: string;
285
294
  try {
286
- content = await Bun.file(catalogPath).text();
287
- } catch (err) {
288
- await fs.rm(tmpDir, { recursive: true, force: true });
289
- if (isEnoent(err)) {
290
- throw new Error(`Cloned repository has no marketplace catalog at ${CATALOG_RELATIVE_PATH}`);
291
- }
292
- throw err;
293
- }
294
-
295
- let catalog: MarketplaceCatalog;
296
- try {
297
- catalog = parseMarketplaceCatalog(content, catalogPath);
295
+ const { catalogPath, content } = await readMarketplaceCatalog(tmpDir);
296
+ const catalog = parseMarketplaceCatalog(content, catalogPath);
297
+ return { catalog, clonePath: tmpDir };
298
298
  } catch (err) {
299
299
  await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
300
- throw err;
300
+ throw new Error(`Cloned repository ${url}: ${(err as Error).message}`, { cause: err });
301
301
  }
302
-
303
- return { catalog, clonePath: tmpDir };
304
302
  }
305
303
 
306
304
  /**
@@ -0,0 +1,9 @@
1
+ import type { PluginRuntimeConfig } from "./types";
2
+
3
+ /** Normalizes persisted plugin runtime config across legacy lockfile shapes. */
4
+ export function normalizePluginRuntimeConfig(config: Partial<PluginRuntimeConfig>): PluginRuntimeConfig {
5
+ return {
6
+ plugins: config.plugins ?? {},
7
+ settings: config.settings ?? {},
8
+ };
9
+ }