diff --git a/packages/coding-agent/src/discovery/omp-extension-roots.ts b/packages/coding-agent/src/discovery/omp-extension-roots.ts index a32f0f636..f4aa7b801 100644 --- a/packages/coding-agent/src/discovery/omp-extension-roots.ts +++ b/packages/coding-agent/src/discovery/omp-extension-roots.ts @@ -17,9 +17,10 @@ */ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { isEnoent, tryParseJson } from "@oh-my-pi/pi-utils"; +import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils"; import { readDirEntries, readFile } from "../capability/fs"; import type { LoadContext } from "../capability/types"; +import { getEnabledPlugins } from "../extensibility/plugins/loader"; import { expandTilde } from "../tools/path-utils"; /** A resolved extension package directory wired into the discovery surfaces. */ @@ -122,24 +123,31 @@ async function isDirectory(p: string): Promise { * 1. CLI roots injected via {@link injectOmpExtensionCliRoots} * 2. Project `/.omp/settings.json#extensions` * 3. User `~/.omp/agent/settings.json#extensions` + * 4. Enabled plugins installed under `/node_modules/` (e.g. via + * `omp install ` / `omp plugin install` / `omp plugin link`) * * Only entries that resolve to a directory on disk are returned; file * entrypoints contribute zero sub-discovery surface and are filtered out. + * Installed-plugin enumeration failures (missing lockfile, unreadable + * `package.json`, etc.) are logged at `debug` and degrade gracefully — the + * other sources still surface. */ export async function listOmpExtensionRoots(ctx: LoadContext): Promise { const { project, user } = scopeDirs(ctx); - const [projectExtensions, userExtensions] = await Promise.all([ + const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([ readSettingsExtensions(path.join(project, "settings.json")), readSettingsExtensions(path.join(user, "settings.json")), + listInstalledPluginRoots(ctx), ]); const candidates: InjectedRoot[] = [ ...injectedCliRoots, ...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })), ...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })), + ...installedPlugins, ]; - // First-seen-wins dedup preserves CLI > project > user precedence. + // First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence. const seen = new Set(); const unique: InjectedRoot[] = []; for (const candidate of candidates) { @@ -157,3 +165,26 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise { + try { + const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home }); + // Installed plugins are always user-scope; project disablement is already + // honored by `getEnabledPlugins` via `loadProjectOverrides`. + return plugins.map(({ path: p }) => ({ path: p, level: "user" })); + } catch (err) { + logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) }); + return []; + } +} diff --git a/packages/coding-agent/src/extensibility/plugins/loader.ts b/packages/coding-agent/src/extensibility/plugins/loader.ts index 59563daa8..af85e12ef 100644 --- a/packages/coding-agent/src/extensibility/plugins/loader.ts +++ b/packages/coding-agent/src/extensibility/plugins/loader.ts @@ -19,9 +19,14 @@ installLegacyPiSpecifierShim(); /** * 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(): Promise { - const lockPath = getPluginsLockfile(); +async function loadRuntimeConfig(home?: string): Promise { + const lockPath = getPluginsLockfile(home); try { return await Bun.file(lockPath).json(); } catch (err) { @@ -46,10 +51,15 @@ async function loadProjectOverrides(cwd: string): Promise { - const pkgJsonPath = getPluginsPackageJson(); +export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise { + const { home } = opts; + const pkgJsonPath = getPluginsPackageJson(home); let pkg: { dependencies?: Record }; try { pkg = await Bun.file(pkgJsonPath).json(); @@ -58,13 +68,13 @@ export async function getEnabledPlugins(cwd: string): Promise throw err; } - const nodeModulesPath = getPluginsNodeModules(); + const nodeModulesPath = getPluginsNodeModules(home); if (!fs.existsSync(nodeModulesPath)) { return []; } const deps = pkg.dependencies || {}; - const runtimeConfig = await loadRuntimeConfig(); + const runtimeConfig = await loadRuntimeConfig(home); const projectOverrides = await loadProjectOverrides(cwd); const plugins: InstalledPlugin[] = []; for (const [name] of Object.entries(deps)) { diff --git a/packages/coding-agent/test/discovery/omp-plugins.test.ts b/packages/coding-agent/test/discovery/omp-plugins.test.ts index cb8c7b53e..0c84de4d6 100644 --- a/packages/coding-agent/test/discovery/omp-plugins.test.ts +++ b/packages/coding-agent/test/discovery/omp-plugins.test.ts @@ -176,3 +176,42 @@ test(".mcp.json with bare entries (no command/url) records a warning and is skip expect(result.items.map(s => (s as { name: string }).name)).toEqual(["ok"]); expect((result.warnings ?? []).some(w => w.includes('"broken"'))).toBe(true); }); + +test("installed plugins under `/node_modules/` are surfaced (e.g. via `omp plugin link`/`install`)", async () => { + // Simulate what `plugin install` / `plugin link` produces: a plugins root + // with `package.json#dependencies` and a populated `node_modules//`. + const pluginsDir = path.join(home, ".omp", "plugins"); + const nodeModules = path.join(pluginsDir, "node_modules"); + const installed = path.join(nodeModules, "my-installed-ext"); + fs.mkdirSync(installed, { recursive: true }); + fs.cpSync(ext, installed, { recursive: true }); + writeFile( + path.join(pluginsDir, "package.json"), + JSON.stringify({ name: "omp-plugins", dependencies: { "my-installed-ext": "1.0.0" } }), + ); + // Plugin's own package.json must carry an `omp`/`pi` manifest for the + // loader to recognise it; the buildExtensionPackage fixture already wrote + // one with `omp.extensions`, which is sufficient. + + const skills = await loadFromPlugin<{ name: string; path: string }>(skillCapability.id, ctx()); + const found = skills.find(s => s.name === "my-skill" && s.path.includes("my-installed-ext")); + expect(found).toBeDefined(); +}); + +test("disabled installed plugins do not contribute sub-discovery", async () => { + const pluginsDir = path.join(home, ".omp", "plugins"); + const installed = path.join(pluginsDir, "node_modules", "my-disabled-ext"); + fs.mkdirSync(installed, { recursive: true }); + fs.cpSync(ext, installed, { recursive: true }); + writeFile( + path.join(pluginsDir, "package.json"), + JSON.stringify({ name: "omp-plugins", dependencies: { "my-disabled-ext": "1.0.0" } }), + ); + writeFile( + path.join(pluginsDir, "omp-plugins.lock.json"), + JSON.stringify({ plugins: { "my-disabled-ext": { enabled: false } }, settings: {} }), + ); + + const skills = await loadFromPlugin<{ name: string; path: string }>(skillCapability.id, ctx()); + expect(skills.find(s => s.path.includes("my-disabled-ext"))).toBeUndefined(); +}); diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index b93b678ca..682767460 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -260,18 +260,18 @@ export function getPluginsDir(home?: string): string { } /** Where npm installs packages (~/.omp/plugins/node_modules). */ -export function getPluginsNodeModules(): string { - return path.join(getPluginsDir(), "node_modules"); +export function getPluginsNodeModules(home?: string): string { + return path.join(getPluginsDir(home), "node_modules"); } /** Plugin manifest (~/.omp/plugins/package.json). */ -export function getPluginsPackageJson(): string { - return path.join(getPluginsDir(), "package.json"); +export function getPluginsPackageJson(home?: string): string { + return path.join(getPluginsDir(home), "package.json"); } /** Plugin lock file (~/.omp/plugins/omp-plugins.lock.json). */ -export function getPluginsLockfile(): string { - return path.join(getPluginsDir(), "omp-plugins.lock.json"); +export function getPluginsLockfile(home?: string): string { + return path.join(getPluginsDir(home), "omp-plugins.lock.json"); } /** Get the remote mount directory (~/.omp/remote). */