Files
oh-my-pi/packages/coding-agent/src/extensibility/plugins/loader.ts
T
can1357 a83175c94c refactor: migrated imports to unified package root and consolidated skill discovery logic
- Consolidated @oh-my-pi/pi-utils subpath imports into single package root import across 100+ files.
- Moved tryParseJson utility from local web scrapers module to @oh-my-pi/pi-utils package for centralized JSON parsing.
- Renamed loadSkillsFromDir to scanSkillsFromDir and refactored skill discovery to use fs.promises.readdir instead of glob-based approach.
- Replaced custom parseJSON with tryParseJson across discovery modules for consistent error handling.
- Removed emitCustomToolSessionEvent method and cleanupSshResources function, consolidating shutdown logic into dispose method.
- Updated glob pattern construction to use GlobBuilder with literal_separator(true) for improved path handling.
2026-02-23 20:59:17 +01:00

250 lines
7.1 KiB
TypeScript

/**
* Plugin loader - discovers and loads tools/hooks from installed plugins.
*
* Reads enabled plugins from the runtime config and loads their tools/hooks
* based on manifest entries and enabled features.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
import { getConfigDirPaths } from "../../config";
import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
// =============================================================================
// Runtime Config Loading
// =============================================================================
/**
* Load plugin runtime config from lock file.
*/
async function loadRuntimeConfig(): Promise<PluginRuntimeConfig> {
const lockPath = getPluginsLockfile();
try {
return await Bun.file(lockPath).json();
} catch (err) {
if (isEnoent(err)) return { plugins: {}, settings: {} };
throw err;
}
}
/**
* Load project-local plugin overrides (checks .omp and .pi directories).
*/
async function loadProjectOverrides(cwd: string): Promise<ProjectPluginOverrides> {
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 {};
}
// =============================================================================
// Plugin Discovery
// =============================================================================
/**
* Get list of enabled plugins with their resolved configurations.
* Respects both global runtime config and project overrides.
*/
export async function getEnabledPlugins(cwd: string): Promise<InstalledPlugin[]> {
const pkgJsonPath = getPluginsPackageJson();
let pkg: { dependencies?: Record<string, string> };
try {
pkg = await Bun.file(pkgJsonPath).json();
} catch (err) {
if (isEnoent(err)) return [];
throw err;
}
const nodeModulesPath = getPluginsNodeModules();
if (!fs.existsSync(nodeModulesPath)) {
return [];
}
const deps = pkg.dependencies || {};
const runtimeConfig = await loadRuntimeConfig();
const projectOverrides = await loadProjectOverrides(cwd);
const plugins: InstalledPlugin[] = [];
for (const [name] of Object.entries(deps)) {
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) {
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),
manifest,
enabledFeatures,
enabled: true,
});
}
return plugins;
}
// =============================================================================
// Path Resolution
// =============================================================================
/**
* Generic path resolver for plugin manifest entries (tools, hooks, commands).
* Handles both single-string and string[] base entries, plus feature-specific entries.
*/
function resolvePluginPaths(plugin: InstalledPlugin, key: "tools" | "hooks" | "commands"): string[] {
const paths: string[] = [];
const manifest = plugin.manifest;
// Base entry (always included if exists)
const base = manifest[key];
if (base) {
const entries = Array.isArray(base) ? base : [base];
for (const entry of entries) {
const resolved = path.join(plugin.path, entry);
if (fs.existsSync(resolved)) {
paths.push(resolved);
}
}
}
// Feature-specific entries
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]) {
const resolved = path.join(plugin.path, entry);
if (fs.existsSync(resolved)) {
paths.push(resolved);
}
}
}
}
} 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]) {
const resolved = path.join(plugin.path, entry);
if (fs.existsSync(resolved)) {
paths.push(resolved);
}
}
}
}
}
return paths;
}
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");
}
// =============================================================================
// Aggregated Discovery
// =============================================================================
/**
* Get all tool paths from all enabled plugins.
*/
export async function getAllPluginToolPaths(cwd: string): Promise<string[]> {
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<string[]> {
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<string[]> {
const plugins = await getEnabledPlugins(cwd);
const paths: string[] = [];
for (const plugin of plugins) {
paths.push(...resolvePluginCommandPaths(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<Record<string, unknown>> {
const runtimeConfig = await loadRuntimeConfig();
const projectOverrides = await loadProjectOverrides(cwd);
const global = runtimeConfig.settings[pluginName] || {};
const project = projectOverrides.settings?.[pluginName] || {};
return { ...global, ...project };
}