feat: add marketplace plugin system

Add Claude Code-compatible marketplace plugin infrastructure:

- Registry: types, ID helpers, atomic read/write for marketplaces.json
  and installed_plugins.json (Claude Code format with version: 2)
- Fetcher: classifySource (6 ordered rules), parseMarketplaceCatalog,
  fetchMarketplace (local sources; git/http stubs for Phase 2)
- Resolver + Cache: resolvePluginSource with pathIsWithin containment,
  cachePlugin, removeCachedPlugin, cleanOrphanedCache
- MarketplaceManager: orchestrates add/remove/update marketplaces,
  install/uninstall/enable plugins, clearPluginRootsCache on mutation
- CLI: omp plugin marketplace add|remove|update|list,
  omp plugin discover, classifyInstallTarget for name@marketplace
- Discovery: listClaudePluginRoots reads OMP registry alongside
  Claude's, OMP authoritative for duplicate plugin IDs
- Args: --plugin-dir repeatable flag (parsing only, runtime wiring TBD)
- Slash command: /reload-plugins clears fs + roots cache
- Test fixtures and 130 tests across 8 test files
This commit is contained in:
Miroslav Drbal
2026-03-30 13:44:53 +02:00
committed by can1357
parent bc2b99f9b0
commit 4e199f93f7
48 changed files with 4239 additions and 96 deletions
@@ -1,12 +1,23 @@
import * as os from "node:os";
import * as path from "node:path";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
import { getAgentDbPath, getConfigDirName, getProjectDir } from "@oh-my-pi/pi-utils";
import { invalidate as invalidateFsCache } from "../../capability/fs";
import { getRoleInfo } from "../../config/model-registry";
import { settings } from "../../config/settings";
import { DebugSelectorComponent } from "../../debug";
import { disableProvider, enableProvider } from "../../discovery";
import { clearClaudePluginRootsCache } from "../../discovery/helpers";
import {
getInstalledPluginsRegistryPath,
getMarketplacesCacheDir,
getMarketplacesRegistryPath,
getPluginsCacheDir,
MarketplaceManager,
} from "../../extensibility/plugins/marketplace";
import {
getAvailableThemes,
getSymbolTheme,
@@ -27,6 +38,7 @@ import { ExtensionDashboard } from "../components/extensions";
import { HistorySearchComponent } from "../components/history-search";
import { ModelSelectorComponent } from "../components/model-selector";
import { OAuthSelectorComponent } from "../components/oauth-selector";
import { PluginSelectorComponent } from "../components/plugin-selector";
import { SessionSelectorComponent } from "../components/session-selector";
import { SettingsSelectorComponent } from "../components/settings-selector";
import { ToolExecutionComponent } from "../components/tool-execution";
@@ -414,6 +426,90 @@ export class SelectorController {
});
}
async showPluginSelector(mode: "install" | "uninstall" = "install"): Promise<void> {
const mgr = new MarketplaceManager({
marketplacesRegistryPath: getMarketplacesRegistryPath(),
installedRegistryPath: getInstalledPluginsRegistryPath(),
marketplacesCacheDir: getMarketplacesCacheDir(),
pluginsCacheDir: getPluginsCacheDir(),
clearPluginRootsCache: () => {
const home = os.homedir();
invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
clearClaudePluginRootsCache();
},
});
const [marketplaces, installed] = await Promise.all([mgr.listMarketplaces(), mgr.listInstalledPlugins()]);
const installedIds = new Set(installed.map(p => p.id));
if (mode === "uninstall") {
// Show only installed plugins for uninstall
const items = installed.map(p => {
const entry = p.entries[0];
const atIdx = p.id.lastIndexOf("@");
const pluginName = atIdx > 0 ? p.id.slice(0, atIdx) : p.id;
const mkt = atIdx > 0 ? p.id.slice(atIdx + 1) : "unknown";
return {
plugin: { name: pluginName, version: entry?.version, description: undefined as string | undefined },
marketplace: mkt,
};
});
this.showSelector(done => {
const selector = new PluginSelectorComponent(0, items, new Set(), {
onSelect: async (name, marketplace) => {
done();
const pluginId = `${name}@${marketplace}`;
this.ctx.showStatus(`Uninstalling ${pluginId}...`);
this.ctx.ui.requestRender();
try {
await mgr.uninstallPlugin(pluginId);
this.ctx.showStatus(`Uninstalled ${pluginId}`);
} catch (err) {
this.ctx.showStatus(`Uninstall failed: ${err}`);
}
this.ctx.ui.requestRender();
},
onCancel: () => done(),
});
return { component: selector, focus: selector.getSelectList() };
});
return;
}
// Install mode: show all available plugins from all marketplaces
const allPlugins: Array<{
plugin: { name: string; version?: string; description?: string };
marketplace: string;
}> = [];
for (const mkt of marketplaces) {
const plugins = await mgr.listAvailablePlugins(mkt.name);
for (const plugin of plugins) {
allPlugins.push({ plugin, marketplace: mkt.name });
}
}
this.showSelector(done => {
const selector = new PluginSelectorComponent(marketplaces.length, allPlugins, installedIds, {
onSelect: async (name, marketplace) => {
done();
this.ctx.showStatus(`Installing ${name} from ${marketplace}...`);
this.ctx.ui.requestRender();
try {
const force = installedIds.has(`${name}@${marketplace}`);
await mgr.installPlugin(name, marketplace, { force });
this.ctx.showStatus(`Installed ${name} from ${marketplace}`);
} catch (err) {
this.ctx.showStatus(`Install failed: ${err}`);
}
this.ctx.ui.requestRender();
},
onCancel: () => done(),
});
return { component: selector, focus: selector.getSelectList() };
});
}
showUserMessageSelector(): void {
const userMessages = this.ctx.session.getUserMessagesForBranching();