/** * Plugin loader - discovers and loads manifest entry points from installed plugins. * * Reads enabled plugins from the runtime config and loads their * tools/hooks/extensions/commands based on manifest entries and enabled features. */ import * as fs from "node:fs"; import * as path from "node:path"; import { getPluginsDir, getPluginsLockfile, isEnoent } from "@oh-my-pi/pi-utils"; import { getConfigDirPaths } from "../../config"; import { registerPluginCacheInvalidator, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; import { installLegacyPiSpecifierShim } from "./legacy-pi-compat"; import { normalizePluginRuntimeConfig } from "./runtime-config"; import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types"; /** Installed plugin plus the root scope that supplied its runtime metadata. */ export interface ScopedInstalledPlugin extends InstalledPlugin { scope: "user" | "project"; } installLegacyPiSpecifierShim(); const enabledPluginsCache = new Map>(); function enabledPluginsCacheKey(cwd: string, home?: string): string { return `${path.resolve(cwd)}\0${home === undefined ? "" : path.resolve(home)}`; } function clearEnabledPluginsCache(): void { enabledPluginsCache.clear(); } registerPluginCacheInvalidator(clearEnabledPluginsCache); // ============================================================================= // Runtime Config Loading // ============================================================================= /** * Load plugin runtime config from lock file. * * `home` controls which `/omp-plugins.lock.json` is read — pass it * through whenever the caller is loading plugins for a tempdir-rooted * scenario (tests, discovery sub-surfaces that need to mirror an alternate * `LoadContext.home`). */ async function loadRuntimeConfig(home?: string): Promise { const lockPath = getPluginsLockfile(home); try { return normalizePluginRuntimeConfig(await Bun.file(lockPath).json()); } catch (err) { if (isEnoent(err)) return normalizePluginRuntimeConfig({}); throw err; } } /** * Load project-local plugin overrides (checks .omp and .pi directories). */ async function loadProjectOverrides(cwd: string): Promise { for (const overridesPath of getConfigDirPaths("plugin-overrides.json", { user: false, cwd })) { try { return await Bun.file(overridesPath).json(); } catch (err) { if (isEnoent(err)) continue; // JSON parse error - continue to next path } } return {}; } /** * Per-root enumeration of plugins from `/node_modules`, * `/package.json#dependencies`, and `/omp-plugins.lock.json#plugins`. * Honors `projectOverrides.disabled` and `projectOverrides.features`. Returns an * empty array when the root has no `node_modules` yet. */ async function collectPluginsAtRoot( root: string, projectOverrides: ProjectPluginOverrides, scope: ScopedInstalledPlugin["scope"], ): Promise { const nodeModulesPath = path.join(root, "node_modules"); if (!fs.existsSync(nodeModulesPath)) return []; let depsKeys: string[] = []; const pkgJsonPath = path.join(root, "package.json"); try { const pkg: { dependencies?: Record } = await Bun.file(pkgJsonPath).json(); depsKeys = Object.keys(pkg.dependencies ?? {}); } catch (err) { // Linked-only setups may have no `/package.json` yet — that's // fine, the lockfile still records the link. if (!isEnoent(err)) throw err; } const lockPath = path.join(root, "omp-plugins.lock.json"); let runtimeConfig: PluginRuntimeConfig; try { runtimeConfig = normalizePluginRuntimeConfig(await Bun.file(lockPath).json()); } catch (err) { if (!isEnoent(err)) throw err; runtimeConfig = normalizePluginRuntimeConfig({}); } // Union: dependencies (npm/marketplace installs) ∪ runtime-config plugins // (links + already-recorded installs). Set preserves first-seen order, // putting deps before link-only entries for deterministic output. const names = new Set(depsKeys); for (const name of Object.keys(runtimeConfig.plugins ?? {})) { names.add(name); } const plugins: ScopedInstalledPlugin[] = []; for (const name of names) { const pluginPkgPath = path.join(nodeModulesPath, name, "package.json"); let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest }; try { pluginPkg = await Bun.file(pluginPkgPath).json(); } catch (err) { // Lockfile entry without a corresponding node_modules tree means the // link was deleted out from under us; skip silently. if (isEnoent(err)) continue; throw err; } const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi; if (!manifest) { // Not an omp plugin, skip continue; } manifest.version = pluginPkg.version; const runtimeState = runtimeConfig.plugins[name]; // Check if disabled globally if (runtimeState && !runtimeState.enabled) { continue; } // Check if disabled in project if (projectOverrides.disabled?.includes(name)) { continue; } // Resolve enabled features (project overrides take precedence) const enabledFeatures = projectOverrides.features?.[name] ?? runtimeState?.enabledFeatures ?? null; plugins.push({ name, version: pluginPkg.version, path: path.join(nodeModulesPath, name), scope, manifest, enabledFeatures, enabled: true, }); } return plugins; } /** * Get list of enabled plugins with their resolved configurations. * * Enumerates two plugin roots in order: the user root * (`getPluginsDir(home)`) and, when a project anchor (`.omp/` or `.git/`) * exists at or above `cwd`, the project root * (`/.omp/plugins`). Each root contributes the union of its * `package.json#dependencies` and `omp-plugins.lock.json#plugins`. Project * entries shadow user entries with the same package name, matching the * shadow semantics of `MarketplaceManager.listInstalledPlugins`. * * The optional `home` parameter pins the user plugins root for callers that * need to enumerate plugins relative to a non-default home (tests with a * tempdir, discovery loaders threaded with `LoadContext.home`). */ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise { const { home } = opts; const cacheKey = enabledPluginsCacheKey(cwd, home); const cached = enabledPluginsCache.get(cacheKey); if (cached) return cached; const loadPromise = loadEnabledPlugins(cwd, home); enabledPluginsCache.set(cacheKey, loadPromise); try { return await loadPromise; } catch (err) { if (enabledPluginsCache.get(cacheKey) === loadPromise) { enabledPluginsCache.delete(cacheKey); } throw err; } } async function loadEnabledPlugins(cwd: string, home?: string): Promise { const projectOverrides = await loadProjectOverrides(cwd); const userRoot = getPluginsDir(home); const userPlugins = await collectPluginsAtRoot(userRoot, projectOverrides, "user"); let projectPlugins: ScopedInstalledPlugin[] = []; const projectRegistryPath = await resolveActiveProjectRegistryPath(cwd); if (projectRegistryPath) { const projectRoot = path.dirname(projectRegistryPath); if (projectRoot !== userRoot) { projectPlugins = await collectPluginsAtRoot(projectRoot, projectOverrides, "project"); } } if (projectPlugins.length === 0) return userPlugins; if (userPlugins.length === 0) return projectPlugins; // Project entries shadow user entries with the same package name. const merged = new Map(); for (const plugin of userPlugins) merged.set(plugin.name, plugin); for (const plugin of projectPlugins) merged.set(plugin.name, plugin); return Array.from(merged.values()); } // ============================================================================= // Path Resolution // ============================================================================= const MANIFEST_ENTRY_MODULE_EXTENSIONS = [".ts", ".js", ".mjs", ".cjs"]; const MANIFEST_ENTRY_INDEX_NAMES = MANIFEST_ENTRY_MODULE_EXTENSIONS.map(ext => `index${ext}`); /** `.d.ts` / `.d.mts` / `.d.cts` TypeScript declaration files — never loadable as modules. */ const DECLARATION_FILE_RE = /\.d\.[mc]?ts$/; /** A loadable module file: a .ts/.js/.mjs/.cjs that is not a declaration file. */ function isModuleFile(name: string): boolean { return MANIFEST_ENTRY_MODULE_EXTENSIONS.includes(path.extname(name)) && !DECLARATION_FILE_RE.test(name); } /** First `index.{ts,js,mjs,cjs}` inside `dir`, or null when none exists. */ function findDirectoryIndex(dir: string): string | null { for (const name of MANIFEST_ENTRY_INDEX_NAMES) { const candidate = path.join(dir, name); if (fs.existsSync(candidate)) return candidate; } return null; } interface DeclaredManifestEntries { /** True when the directory's package.json declares a non-empty `omp`/`pi` `extensions` array. */ declared: boolean; /** Resolved, existing module files for the declared entries (may be empty when declared files are missing). */ files: string[]; } /** * Read the extension entries declared by `dir`'s own package.json `omp`/`pi` * manifest. `declared` distinguishes "a manifest explicitly lists extensions" * (authoritative — callers must not fall back to index/scan, so a missing * declared file surfaces as a missing entry instead of silently loading a stale * index) from "no manifest / no extensions field" (callers fall back to * convention). Mirrors the manifest branch of the configured-directory (`-e`) * scanner: a declared entry that is a file resolves to itself; one that is a * directory resolves to its direct index.{ts,js,mjs,cjs}. */ function readDeclaredManifestEntries(dir: string): DeclaredManifestEntries { let raw: string; try { raw = fs.readFileSync(path.join(dir, "package.json"), "utf8"); } catch { return { declared: false, files: [] }; } let pkg: { omp?: { extensions?: unknown }; pi?: { extensions?: unknown } }; try { pkg = JSON.parse(raw) as { omp?: { extensions?: unknown }; pi?: { extensions?: unknown } }; } catch { return { declared: false, files: [] }; } const declared = (pkg.omp ?? pkg.pi)?.extensions; if (!Array.isArray(declared) || declared.length === 0) { return { declared: false, files: [] }; } const files: string[] = []; for (const entry of declared) { if (typeof entry !== "string") continue; const candidate = path.resolve(dir, entry); let candidateStats: fs.Stats; try { candidateStats = fs.statSync(candidate); } catch { continue; } if (candidateStats.isDirectory()) { const index = findDirectoryIndex(candidate); if (index) files.push(index); } else { files.push(candidate); } } return { declared: true, files }; } /** * Resolve a directory to its loadable extension module files, mirroring the * configured-directory (`-e`) scanner in extensions/loader.ts: * 1. the directory's own package.json `omp`/`pi` `extensions` entries — * authoritative: a manifest that lists extensions suppresses the index/scan * fallback, so a missing declared file is reported rather than silently * replaced by a decoy index * 2. a direct index.{ts,js,mjs,cjs} * 3. one level of children: each direct *.{ts,js,mjs,cjs} file plus each * sub-directory resolved by the same precedence (manifest, then index) */ function resolveDirectoryEntries(dir: string): string[] { const manifest = readDeclaredManifestEntries(dir); if (manifest.declared) return manifest.files; const directIndex = findDirectoryIndex(dir); if (directIndex) return [directIndex]; let children: string[]; try { children = fs.readdirSync(dir); } catch { return []; } const resolved: string[] = []; for (const child of children.sort()) { const childPath = path.join(dir, child); let childStats: fs.Stats; try { // statSync follows symlinks, matching the configured-dir loader. childStats = fs.statSync(childPath); } catch { continue; } if (childStats.isDirectory()) { const childManifest = readDeclaredManifestEntries(childPath); if (childManifest.declared) { resolved.push(...childManifest.files); } else { const index = findDirectoryIndex(childPath); if (index) resolved.push(index); } } else if (isModuleFile(child)) { resolved.push(childPath); } } return resolved; } /** * Resolve a plugin manifest entry to the loadable module files it names: * - a file entry → that file * - a directory: * - when `expandDirectory` (the `extensions` key), resolved by * {@link resolveDirectoryEntries} — its own package.json `omp`/`pi` * `extensions`, then a direct index, then a one-level scan of * sub-extensions — matching the pi `extensions//index.ts` convention * and OMP's configured-directory (`-e`) extension loader * - otherwise (tools/hooks/commands) only a direct index.{ts,js,mjs,cjs}. * The sub-extension scan and the `omp`/`pi` `extensions` manifest are * extensions-specific and must not hijack a non-extension directory entry * (e.g. a `tools: "."` entry must still resolve `./index.ts`). * * Returns an empty array when nothing loadable exists at `joined`, letting * callers flag a missing entry instead of silently dropping it. */ function resolveManifestEntryFiles(joined: string, expandDirectory: boolean): string[] { let stats: fs.Stats; try { stats = fs.statSync(joined); } catch { return []; } if (!stats.isDirectory()) { return [joined]; } if (expandDirectory) { return resolveDirectoryEntries(joined); } const index = findDirectoryIndex(joined); return index ? [index] : []; } /** * Generic path resolver for plugin manifest entries (tools, hooks, commands, extensions). * Handles both single-string and string[] base entries, plus feature-specific entries. */ function resolvePluginPaths(plugin: InstalledPlugin, key: "tools" | "hooks" | "commands" | "extensions"): string[] { const resolved: string[] = []; for (const entry of resolvePluginManifestEntries(plugin, key)) { if (entry.resolvedPath) { resolved.push(entry.resolvedPath); } } return resolved; } /** * Declared manifest entries paired with their resolved file path. Returns one * record per declared entry — base entries first, then enabled-feature entries * — so callers (e.g. install-time validation) can detect manifest entries that * point at missing files instead of silently skipping them like * {@link resolvePluginPaths} does. */ export function resolvePluginManifestEntries( plugin: InstalledPlugin, key: "tools" | "hooks" | "commands" | "extensions", ): Array<{ entry: string; resolvedPath: string | null }> { const declared: Array<{ entry: string; resolvedPath: string | null }> = []; const manifest = plugin.manifest; const expandDirectory = key === "extensions"; const resolveEntry = (entry: string): Array<{ entry: string; resolvedPath: string | null }> => { const files = resolveManifestEntryFiles(path.join(plugin.path, entry), expandDirectory); return files.length > 0 ? files.map(resolvedPath => ({ entry, resolvedPath })) : [{ entry, resolvedPath: null }]; }; const base = manifest[key]; if (base) { const entries = Array.isArray(base) ? base : [base]; for (const entry of entries) { declared.push(...resolveEntry(entry)); } } if (manifest.features && plugin.enabledFeatures) { const enabledSet = new Set(plugin.enabledFeatures); for (const [featName, feat] of Object.entries(manifest.features)) { if (!enabledSet.has(featName)) continue; if (feat[key]) { for (const entry of feat[key]) { declared.push(...resolveEntry(entry)); } } } } else if (manifest.features && plugin.enabledFeatures === null) { // null means use defaults - enable features with default: true for (const [_featName, feat] of Object.entries(manifest.features)) { if (!feat.default) continue; if (feat[key]) { for (const entry of feat[key]) { declared.push(...resolveEntry(entry)); } } } } return declared; } export function resolvePluginToolPaths(plugin: InstalledPlugin): string[] { return resolvePluginPaths(plugin, "tools"); } export function resolvePluginHookPaths(plugin: InstalledPlugin): string[] { return resolvePluginPaths(plugin, "hooks"); } export function resolvePluginCommandPaths(plugin: InstalledPlugin): string[] { return resolvePluginPaths(plugin, "commands"); } export function resolvePluginExtensionPaths(plugin: InstalledPlugin): string[] { return resolvePluginPaths(plugin, "extensions"); } // ============================================================================= // Aggregated Discovery // ============================================================================= /** * Get all tool paths from all enabled plugins. */ export async function getAllPluginToolPaths(cwd: string): Promise { const plugins = await getEnabledPlugins(cwd); const paths: string[] = []; for (const plugin of plugins) { paths.push(...resolvePluginToolPaths(plugin)); } return paths; } /** * Get all hook paths from all enabled plugins. */ export async function getAllPluginHookPaths(cwd: string): Promise { const plugins = await getEnabledPlugins(cwd); const paths: string[] = []; for (const plugin of plugins) { paths.push(...resolvePluginHookPaths(plugin)); } return paths; } /** * Get all command paths from all enabled plugins. */ export async function getAllPluginCommandPaths(cwd: string): Promise { const plugins = await getEnabledPlugins(cwd); const paths: string[] = []; for (const plugin of plugins) { paths.push(...resolvePluginCommandPaths(plugin)); } return paths; } /** * Get all extension module paths from all enabled plugins. */ export async function getAllPluginExtensionPaths(cwd: string): Promise { const plugins = await getEnabledPlugins(cwd); const paths: string[] = []; for (const plugin of plugins) { paths.push(...resolvePluginExtensionPaths(plugin)); } return paths; } /** * Get plugin settings for use in tool/hook contexts. * Merges global settings with project overrides. */ export async function getPluginSettings(pluginName: string, cwd: string): Promise> { const runtimeConfig = await loadRuntimeConfig(); const projectOverrides = await loadProjectOverrides(cwd); const global = runtimeConfig.settings[pluginName] || {}; const project = projectOverrides.settings?.[pluginName] || {}; return { ...global, ...project }; }