Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed marketplace plugin installs registering only in `installed_plugins.json` and never in the runtime plugin tree, leaving slash commands and extensions unavailable after `omp plugin install name@marketplace`. The runtime loader now also enumerates the project-scope plugins root (`<projectAnchor>/.omp/plugins`) so `--scope project` installs surface alongside user-scope installs, with project entries shadowing same-named user entries ([#3244](https://github.com/can1357/oh-my-pi/issues/3244)).

## [16.1.14] - 2026-06-22

### Added
Expand Down
4 changes: 1 addition & 3 deletions packages/coding-agent/src/discovery/omp-extension-roots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,7 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtens
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" }));
return plugins.map(({ path: p, scope }) => ({ path: p, level: scope }));
} catch (err) {
logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
return [];
Expand Down
94 changes: 71 additions & 23 deletions packages/coding-agent/src/extensibility/plugins/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
import { getPluginsDir, getPluginsLockfile, isEnoent } from "@oh-my-pi/pi-utils";
import { getConfigDirPaths } from "../../config";
import { 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();

// =============================================================================
Expand Down Expand Up @@ -51,38 +57,38 @@ async function loadProjectOverrides(cwd: string): Promise<ProjectPluginOverrides
return {};
}
/**
* Get list of enabled plugins with their resolved configurations.
*
* Respects both global runtime config and project overrides. Iterates the
* union of `<plugins>/package.json#dependencies` (`bun install`-installed
* packages) and `<plugins>/omp-plugins.lock.json#plugins` (so locally
* `plugin link`-symlinked extensions, which never get a dependency entry,
* are still discovered). 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`).
* Per-root enumeration of plugins from `<root>/node_modules`,
* `<root>/package.json#dependencies`, and `<root>/omp-plugins.lock.json#plugins`.
* Honors `projectOverrides.disabled` and `projectOverrides.features`. Returns an
* empty array when the root has no `node_modules` yet.
*/
export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<InstalledPlugin[]> {
const { home } = opts;

const nodeModulesPath = getPluginsNodeModules(home);
if (!fs.existsSync(nodeModulesPath)) {
return [];
}
async function collectPluginsAtRoot(
root: string,
projectOverrides: ProjectPluginOverrides,
scope: ScopedInstalledPlugin["scope"],
): Promise<ScopedInstalledPlugin[]> {
const nodeModulesPath = path.join(root, "node_modules");
if (!fs.existsSync(nodeModulesPath)) return [];

let depsKeys: string[] = [];
const pkgJsonPath = getPluginsPackageJson(home);
const pkgJsonPath = path.join(root, "package.json");
try {
const pkg: { dependencies?: Record<string, string> } = await Bun.file(pkgJsonPath).json();
depsKeys = Object.keys(pkg.dependencies ?? {});
} catch (err) {
// Linked-only setups may have no `<plugins>/package.json` yet — that's
// Linked-only setups may have no `<root>/package.json` yet — that's
// fine, the lockfile still records the link.
if (!isEnoent(err)) throw err;
}

const runtimeConfig = await loadRuntimeConfig(home);
const projectOverrides = await loadProjectOverrides(cwd);
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,
Expand All @@ -92,7 +98,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
names.add(name);
}

const plugins: InstalledPlugin[] = [];
const plugins: ScopedInstalledPlugin[] = [];
for (const name of names) {
const pluginPkgPath = path.join(nodeModulesPath, name, "package.json");
let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest };
Expand Down Expand Up @@ -130,6 +136,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
name,
version: pluginPkg.version,
path: path.join(nodeModulesPath, name),
scope,
manifest,
enabledFeatures,
enabled: true,
Expand All @@ -139,6 +146,47 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
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
* (`<projectAnchor>/.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<ScopedInstalledPlugin[]> {
const { home } = opts;
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<string, ScopedInstalledPlugin>();
for (const plugin of userPlugins) merged.set(plugin.name, plugin);
for (const plugin of projectPlugins) merged.set(plugin.name, plugin);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve project shadowing by plugin ID

When the same marketplace plugin ID is installed in both user and project scopes but the two versions resolve to different runtime package names (for example, an upgrade adds or renames package.json#name), listInstalledPlugins marks the user entry as shadowed by the project entry, but this merge only replaces entries with the same package name. In that project, getEnabledPlugins() will return both the old user package and the project package, so both sets of commands/extensions can load despite the project install being intended to take precedence for that plugin ID.

Useful? React with 👍 / 👎.

return Array.from(merged.values());
}

// =============================================================================
// Path Resolution
// =============================================================================
Expand Down
134 changes: 134 additions & 0 deletions packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import * as os from "node:os";
import * as path from "node:path";

import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
import { normalizePluginRuntimeConfig } from "../runtime-config";
import type { PluginRuntimeConfig } from "../types";

import { cachePlugin } from "./cache";
import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
Expand Down Expand Up @@ -38,6 +40,16 @@ import type {
} from "./types";
import { buildPluginId, parsePluginId } from "./types";

const RUNTIME_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
const MAX_RUNTIME_PACKAGE_NAME_LENGTH = 214;

function assertRuntimePackageName(name: string): string {
if (name.length > MAX_RUNTIME_PACKAGE_NAME_LENGTH || !RUNTIME_PACKAGE_NAME_RE.test(name)) {
throw new Error(`Invalid marketplace plugin package name: ${JSON.stringify(name)}`);
}
return name;
}

// ── Options ──────────────────────────────────────────────────────────────────

export interface MarketplaceManagerOptions {
Expand Down Expand Up @@ -298,6 +310,9 @@ export class MarketplaceManager {
}
}

const packageName = await this.#resolvePluginPackageName(cachePath, name);
const previousPackageNames = await this.#resolveInstalledPackageNames(existing ?? [], name);

// Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
if (existing && existing.length > 0) {
// Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
Expand Down Expand Up @@ -337,6 +352,13 @@ export class MarketplaceManager {
const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
await writeInstalledPluginsRegistry(registryPath, newInstReg);

for (const previousPackageName of previousPackageNames) {
if (previousPackageName !== packageName) {
await this.#removeRuntimePlugin(scope, previousPackageName);
}
}
await this.#registerRuntimePlugin(scope, packageName, cachePath, version, wasDisabled ? false : undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make project-scoped runtime installs discoverable

When installPlugin(..., { scope: "project" }) reaches this call, #registerRuntimePlugin writes the symlink and omp-plugins.lock.json under the project registry directory, but the runtime loader still enumerates only getPluginsNodeModules(home) / getPluginsLockfile(home) in loader.ts#getEnabledPlugins and uses project data only for overrides. In the documented omp plugin install --scope project name@marketplace flow, the new runtime bookkeeping is therefore written to a tree that slash commands/extensions never read, so project-scoped marketplace packages remain invisible.

Useful? React with 👍 / 👎.


this.#clearCache();

logger.debug("Plugin installed", { pluginId, version, cachePath });
Expand Down Expand Up @@ -434,6 +456,7 @@ export class MarketplaceManager {
const targetEntries = targetScope === "project" ? projectEntries! : userEntries!;
const targetReg = targetScope === "project" ? projectReg : userReg;
const registryPath = this.#registryPath(targetScope);
const packageNames = await this.#resolveInstalledPackageNames(targetEntries, parsed.name);

const updatedReg = removeInstalledPlugin(targetReg, pluginId);
await writeInstalledPluginsRegistry(registryPath, updatedReg);
Expand All @@ -453,6 +476,10 @@ export class MarketplaceManager {
}
}

for (const packageName of packageNames) {
await this.#removeRuntimePlugin(targetScope, packageName);
}

this.#clearCache();

logger.debug("Plugin uninstalled", { pluginId, scope: targetScope });
Expand Down Expand Up @@ -539,6 +566,12 @@ export class MarketplaceManager {
};
await writeInstalledPluginsRegistry(registryPath, updated);

const fallbackName = parsePluginId(pluginId)?.name ?? pluginId;
const packageNames = await this.#resolveInstalledPackageNames(entries, fallbackName);
for (const packageName of packageNames) {
await this.#setRuntimePluginEnabled(targetScope, packageName, enabled);
}

this.#clearCache();

logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope });
Expand Down Expand Up @@ -701,6 +734,107 @@ export class MarketplaceManager {

// ── Private helpers ───────────────────────────────────────────────────────

#runtimeRoot(scope: "user" | "project"): string {
return path.dirname(this.#registryPath(scope));
}

#nodeModulesPath(scope: "user" | "project"): string {
return path.join(this.#runtimeRoot(scope), "node_modules");
}

#runtimeLockPath(scope: "user" | "project"): string {
return path.join(this.#runtimeRoot(scope), "omp-plugins.lock.json");
}

async #loadRuntimeConfig(scope: "user" | "project"): Promise<PluginRuntimeConfig> {
try {
return normalizePluginRuntimeConfig(await Bun.file(this.#runtimeLockPath(scope)).json());
} catch (err) {
if (isEnoent(err)) return normalizePluginRuntimeConfig({});
logger.warn("Failed to load marketplace plugin runtime config", {
path: this.#runtimeLockPath(scope),
error: String(err),
});
return normalizePluginRuntimeConfig({});
}
}

async #writeRuntimeConfig(scope: "user" | "project", config: PluginRuntimeConfig): Promise<void> {
await Bun.write(this.#runtimeLockPath(scope), JSON.stringify(config, null, 2));
}

async #resolvePluginPackageName(installPath: string, fallbackName: string): Promise<string> {
try {
const pkg: { name?: unknown } = await Bun.file(path.join(installPath, "package.json")).json();
const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : fallbackName;
return assertRuntimePackageName(name);
} catch (err) {
if (isEnoent(err)) return assertRuntimePackageName(fallbackName);
throw err;
}
}

#runtimePackagePath(scope: "user" | "project", packageName: string): string {
const nodeModules = path.resolve(this.#nodeModulesPath(scope));
const linkPath = path.resolve(nodeModules, assertRuntimePackageName(packageName));
const relative = path.relative(nodeModules, linkPath);
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Marketplace plugin package path escapes node_modules: ${JSON.stringify(packageName)}`);
}
return linkPath;
}

async #resolveInstalledPackageNames(
entries: readonly InstalledPluginEntry[],
fallbackName: string,
): Promise<Set<string>> {
const packageNames = new Set<string>();
for (const entry of entries) {
packageNames.add(await this.#resolvePluginPackageName(entry.installPath, fallbackName));
}
return packageNames;
}

async #registerRuntimePlugin(
scope: "user" | "project",
packageName: string,
cachePath: string,
version: string,
enabled: boolean | undefined,
): Promise<void> {
const linkPath = this.#runtimePackagePath(scope, packageName);
await fs.mkdir(path.dirname(linkPath), { recursive: true });
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(cachePath, linkPath, process.platform === "win32" ? "junction" : "dir");
Comment on lines +807 to +808

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard runtime links against package-name collisions

When two installed marketplace entries in the same scope resolve to the same package.json name (for example a forked plugin from another marketplace), this unconditional remove/symlink overwrites the first plugin's runtime link while both plugin IDs remain in installed_plugins.json; later uninstalling either ID can also delete the shared lock entry and leave the other installed-but-invisible. Since buildPluginId permits distinct name@marketplace entries but the runtime tree is keyed only by package name, detect this collision or reference-count it before replacing the link.

Useful? React with 👍 / 👎.


const config = await this.#loadRuntimeConfig(scope);
const previous = config.plugins[packageName];
config.plugins[packageName] = {
version,
enabledFeatures: previous?.enabledFeatures ?? null,
enabled: enabled ?? previous?.enabled ?? true,
};
await this.#writeRuntimeConfig(scope, config);
}

async #removeRuntimePlugin(scope: "user" | "project", packageName: string): Promise<void> {
await fs.rm(this.#runtimePackagePath(scope, packageName), { recursive: true, force: true });

const config = await this.#loadRuntimeConfig(scope);
delete config.plugins[packageName];
delete config.settings[packageName];
await this.#writeRuntimeConfig(scope, config);
}

async #setRuntimePluginEnabled(scope: "user" | "project", packageName: string, enabled: boolean): Promise<void> {
const config = await this.#loadRuntimeConfig(scope);
const previous = config.plugins[packageName];
if (!previous) return;

config.plugins[packageName] = { ...previous, enabled };
await this.#writeRuntimeConfig(scope, config);
}

#registryPath(scope: "user" | "project"): string {
if (scope === "project") {
if (!this.#opts.projectInstalledRegistryPath) {
Expand Down
21 changes: 21 additions & 0 deletions packages/coding-agent/test/discovery/omp-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,27 @@ test("installed plugins under `<plugins>/node_modules/` are surfaced (e.g. via `
expect(found).toBeDefined();
});

test("project-scoped installed plugins surface project-level sub-discovery", async () => {
const pluginsDir = path.join(project, ".omp", "plugins");
const installed = path.join(pluginsDir, "node_modules", "my-project-ext");
fs.mkdirSync(installed, { recursive: true });
fs.cpSync(ext, installed, { recursive: true });
writeFile(
path.join(pluginsDir, "omp-plugins.lock.json"),
JSON.stringify({
plugins: { "my-project-ext": { version: "1.0.0", enabled: true, enabledFeatures: null } },
settings: {},
}),
);

const skills = await loadFromPlugin<{ name: string; path: string; level: "user" | "project" }>(
skillCapability.id,
ctx(),
);
const found = skills.find(s => s.name === "my-skill" && s.path.includes("my-project-ext"));
expect(found?.level).toBe("project");
});

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");
Expand Down
Loading
Loading