feat(discovery): scan installed plugin packages for sub-discovery

Marketplace and `omp plugin link` installs write to
`<plugins>/node_modules/` rather than to `extensions:` in settings,
so the original PR still missed their sibling skills/, hooks/,
tools/, commands/, rules/, prompts/, .mcp.json sub-trees. Wire
listOmpExtensionRoots to enumerate getEnabledPlugins(cwd, { home })
in addition to CLI-injected and settings-driven roots.

Adds an optional { home } parameter to getEnabledPlugins so the
discovery loader can pass through LoadContext.home for tempdir-rooted
tests. The getPluginsNodeModules/getPluginsPackageJson/
getPluginsLockfile helpers gain the same optional home overload so
they mirror getPluginsDir.

Per-PR review feedback: https://github.com/can1357/oh-my-pi/pull/1498
This commit is contained in:
roboomp
2026-05-29 06:28:33 +00:00
parent db525316ab
commit bdce9cf068
4 changed files with 96 additions and 16 deletions
@@ -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<boolean> {
* 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
* 2. Project `<cwd>/.omp/settings.json#extensions`
* 3. User `~/.omp/agent/settings.json#extensions`
* 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
* `omp install <pkg>` / `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<OmpExtensionRoot[]> {
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<string>();
const unique: InjectedRoot[] = [];
for (const candidate of candidates) {
@@ -157,3 +165,26 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtens
}
return roots;
}
/**
* Enumerate every enabled installed plugin's package directory so its
* conventional `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
* `prompts/`, and `.mcp.json` are wired into discovery — mirrors how
* `getAllPluginExtensionPaths` already feeds the extension factory loader.
*
* Marketplace and `omp plugin link` installs write to the plugin manager's
* `node_modules` (or symlink into it) rather than to `extensions:` in
* settings; without this branch the sub-discovery provider would still miss
* everything those install paths produce.
*/
async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
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 [];
}
}
@@ -19,9 +19,14 @@ installLegacyPiSpecifierShim();
/**
* Load plugin runtime config from lock file.
*
* `home` controls which `<plugins>/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<PluginRuntimeConfig> {
const lockPath = getPluginsLockfile();
async function loadRuntimeConfig(home?: string): Promise<PluginRuntimeConfig> {
const lockPath = getPluginsLockfile(home);
try {
return await Bun.file(lockPath).json();
} catch (err) {
@@ -46,10 +51,15 @@ async function loadProjectOverrides(cwd: string): Promise<ProjectPluginOverrides
}
/**
* Get list of enabled plugins with their resolved configurations.
* Respects both global runtime config and project overrides.
*
* Respects both global runtime config and project overrides. The optional
* `home` parameter pins the 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): Promise<InstalledPlugin[]> {
const pkgJsonPath = getPluginsPackageJson();
export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<InstalledPlugin[]> {
const { home } = opts;
const pkgJsonPath = getPluginsPackageJson(home);
let pkg: { dependencies?: Record<string, string> };
try {
pkg = await Bun.file(pkgJsonPath).json();
@@ -58,13 +68,13 @@ export async function getEnabledPlugins(cwd: string): Promise<InstalledPlugin[]>
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)) {