@oh-my-pi/pi-coding-agent 16.0.1 → 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.
@@ -143,29 +143,161 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
143
143
  // Path Resolution
144
144
  // =============================================================================
145
145
 
146
- 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
+ }
147
219
 
148
220
  /**
149
- * Resolve a plugin manifest entry to a concrete loadable file path. Returns the
150
- * file path itself when the entry points at a file, the matching index file when
151
- * the entry points at a directory containing index.{ts,js,mjs,cjs}, and null
152
- * 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)
153
230
  */
154
- 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[] {
155
287
  let stats: fs.Stats;
156
288
  try {
157
289
  stats = fs.statSync(joined);
158
290
  } catch {
159
- return null;
291
+ return [];
160
292
  }
161
- if (stats.isDirectory()) {
162
- for (const name of MANIFEST_ENTRY_INDEX_NAMES) {
163
- const candidate = path.join(joined, name);
164
- if (fs.existsSync(candidate)) return candidate;
165
- }
166
- return null;
293
+ if (!stats.isDirectory()) {
294
+ return [joined];
295
+ }
296
+ if (expandDirectory) {
297
+ return resolveDirectoryEntries(joined);
167
298
  }
168
- return joined;
299
+ const index = findDirectoryIndex(joined);
300
+ return index ? [index] : [];
169
301
  }
170
302
 
171
303
  /**
@@ -196,16 +328,17 @@ export function resolvePluginManifestEntries(
196
328
  const declared: Array<{ entry: string; resolvedPath: string | null }> = [];
197
329
  const manifest = plugin.manifest;
198
330
 
199
- const resolveEntry = (entry: string): { entry: string; resolvedPath: string | null } => ({
200
- entry,
201
- resolvedPath: resolveManifestEntryFile(path.join(plugin.path, entry)),
202
- });
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
+ };
203
336
 
204
337
  const base = manifest[key];
205
338
  if (base) {
206
339
  const entries = Array.isArray(base) ? base : [base];
207
340
  for (const entry of entries) {
208
- declared.push(resolveEntry(entry));
341
+ declared.push(...resolveEntry(entry));
209
342
  }
210
343
  }
211
344
 
@@ -215,7 +348,7 @@ export function resolvePluginManifestEntries(
215
348
  if (!enabledSet.has(featName)) continue;
216
349
  if (feat[key]) {
217
350
  for (const entry of feat[key]) {
218
- declared.push(resolveEntry(entry));
351
+ declared.push(...resolveEntry(entry));
219
352
  }
220
353
  }
221
354
  }
@@ -225,7 +358,7 @@ export function resolvePluginManifestEntries(
225
358
  if (!feat.default) continue;
226
359
  if (feat[key]) {
227
360
  for (const entry of feat[key]) {
228
- declared.push(resolveEntry(entry));
361
+ declared.push(...resolveEntry(entry));
229
362
  }
230
363
  }
231
364
  }
@@ -205,6 +205,17 @@ export class PluginManager {
205
205
  }
206
206
  }
207
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
+
208
219
  async #snapshotInstalledPackage(actualName: string | undefined): Promise<PluginPackageSnapshot | null> {
209
220
  if (!actualName) {
210
221
  return null;
@@ -490,21 +501,22 @@ export class PluginManager {
490
501
  */
491
502
  async list(): Promise<InstalledPlugin[]> {
492
503
  const pkgJsonPath = getPluginsPackageJson();
493
- let pkg: { dependencies?: Record<string, string> };
504
+ let deps: Record<string, string> = {};
494
505
  try {
495
- pkg = await Bun.file(pkgJsonPath).json();
506
+ const pkg: { dependencies?: Record<string, string> } = await Bun.file(pkgJsonPath).json();
507
+ deps = pkg.dependencies ?? {};
496
508
  } catch (err) {
497
- if (isEnoent(err)) return [];
498
- throw err;
509
+ if (!isEnoent(err)) throw err;
499
510
  }
500
511
 
501
- const deps = pkg.dependencies || {};
502
512
  const projectOverrides = await this.#loadProjectOverrides();
503
513
  const config = await this.#ensureConfigLoaded();
504
514
  const plugins: InstalledPlugin[] = [];
515
+ const installedNames = this.#collectInstalledNames(deps, config);
505
516
 
506
- for (const [name] of Object.entries(deps)) {
507
- 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");
508
520
  let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest };
509
521
  try {
510
522
  pluginPkg = await Bun.file(pluginPkgPath).json();
@@ -521,14 +533,13 @@ export class PluginManager {
521
533
  enabled: true,
522
534
  };
523
535
 
524
- // Apply project overrides
525
536
  const isDisabledInProject = projectOverrides.disabled?.includes(name) ?? false;
526
537
  const projectFeatures = projectOverrides.features?.[name];
527
538
 
528
539
  plugins.push({
529
540
  name,
530
541
  version: pluginPkg.version,
531
- path: path.join(getPluginsNodeModules(), name),
542
+ path: pluginPath,
532
543
  manifest,
533
544
  enabledFeatures: projectFeatures ?? runtimeState.enabledFeatures,
534
545
  enabled: runtimeState.enabled && !isDisabledInProject,
@@ -744,15 +755,14 @@ export class PluginManager {
744
755
  message: hasNodeModules ? "Found" : "Missing (run npm install in plugins dir)",
745
756
  });
746
757
 
747
- if (!hasPkgJson) {
748
- return checks;
749
- }
750
758
  const deps = pkg.dependencies || {};
751
759
  const config = await this.#ensureConfigLoaded();
760
+ const installedNames = this.#collectInstalledNames(deps, config);
752
761
 
753
- for (const [name] of Object.entries(deps)) {
762
+ for (const name of installedNames) {
754
763
  const pluginPath = path.join(nodeModulesPath, name);
755
764
  const pluginPkgPath = path.join(pluginPath, "package.json");
765
+ const fromDependencies = name in deps;
756
766
 
757
767
  let pluginPkg: { version: string; description?: string; omp?: PluginManifest; pi?: PluginManifest };
758
768
  try {
@@ -760,13 +770,23 @@ export class PluginManager {
760
770
  } catch (err) {
761
771
  if (isEnoent(err)) {
762
772
  if (!fs.existsSync(pluginPath)) {
763
- const fixed = options.fix ? await this.#fixMissingPlugin() : false;
764
- checks.push({
765
- name: `plugin:${name}`,
766
- status: "error",
767
- message: "Missing from node_modules",
768
- fixed,
769
- });
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
+ }
770
790
  } else {
771
791
  checks.push({
772
792
  name: `plugin:${name}`,
@@ -844,19 +864,6 @@ export class PluginManager {
844
864
  }
845
865
  }
846
866
 
847
- // Check for orphaned runtime config entries
848
- for (const name of Object.keys(config.plugins)) {
849
- if (!(name in deps)) {
850
- const fixed = options.fix ? await this.#removeOrphanedConfig(name) : false;
851
- checks.push({
852
- name: `orphan:${name}`,
853
- status: "warning",
854
- message: "Plugin in config but not installed",
855
- fixed,
856
- });
857
- }
858
- }
859
-
860
867
  return checks;
861
868
  }
862
869