Files
oh-my-pi/packages/coding-agent/src/discovery/claude-plugins.ts
T
Miroslav Drbal 05966c8910 feat(coding-agent): marketplace plugin project scope
Plugins can now be installed at user scope (global) or project scope
(per-project, higher capability priority). Scope is encoded in registry
file location, not a metadata field:

  user:    ~/.omp/plugins/installed_plugins.json
  project: <nearest-project>/.omp/plugins/installed_plugins.json
  cache:   ~/.omp/plugins/cache/plugins/  (shared, path-referenced)

Project root discovery: resolveActiveProjectRegistryPath(cwd) walks up
from cwd looking for the nearest .omp/ directory, falling back to the
nearest .git root. This is the single resolver used by install, uninstall,
list, upgrade, discovery, and doctor.

Discovery: listClaudePluginRoots(home, cwd?) reads both registries when
cwd is provided. Project entries shadow user entries for the same plugin
ID. Cache key is canonical ("${home}:${resolvedProjectPath}") so nested
cwds within the same project share a cache entry.

Manager changes:
- installPlugin({ scope? }): routes registry reads/writes by scope;
  checks collectReferencedPaths() across both registries before deleting
  any cached plugin dir to prevent cross-scope data loss
- uninstallPlugin(id, scope?), setPluginEnabled(id, enabled, scope?),
  upgradePlugin(id, scope?): throw a disambiguation error when the plugin
  exists in both scopes and no scope is specified
- upgradePluginAcrossScopes(id): new; upgrades all scopes the plugin is
  installed in; returns InstalledPluginEntry[]
- upgradeAllPlugins(): uses upgradePluginAcrossScopes; result includes scope
- listInstalledPlugins(): returns InstalledPluginSummary[] merged from both
  registries; user entries marked shadowedBy: "project" when overridden

CLI: omp plugin install|uninstall|upgrade|enable|disable --scope user|project
Slash: /marketplace install [--scope user|project] name@marketplace
MarketplaceManager constructed with projectInstalledRegistryPath in all
CLI handlers and builtin-registry.ts via resolveActiveProjectRegistryPath.

.gitignore: .omp/plugins/ added (local runtime state, not committed).
preloadPluginRoots/clearClaudePluginRootsCache carry cwd through for LSP.
main.ts passes getProjectDir() at startup.

Tests: 226 pass across 13 files. New: project-scope.test.ts (resolver
walk-up, .git fallback, null return, canonical path, shadow precedence);
manager scope tests (registry isolation, disambiguation errors, cross-scope
cache-ref protection, upgradePluginAcrossScopes, shadowedBy marking).

fixes #581
2026-03-31 17:43:09 +02:00

297 lines
9.5 KiB
TypeScript

/**
* Claude Code Marketplace Plugin Provider
*
* Loads configuration from ~/.claude/plugins/cache/ based on installed_plugins.json registry.
* Priority: 70 (below claude.ts at 80, so user overrides in .claude/ take precedence)
*/
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { registerProvider } from "../capability";
import { readFile } from "../capability/fs";
import { type Hook, hookCapability } from "../capability/hook";
import { type MCPServer, mcpCapability } from "../capability/mcp";
import { type Skill, skillCapability } from "../capability/skill";
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
import { type CustomTool, toolCapability } from "../capability/tool";
import type { LoadContext, LoadResult } from "../capability/types";
import {
type ClaudePluginRoot,
createSourceMeta,
listClaudePluginRoots,
loadFilesFromDir,
scanSkillsFromDir,
} from "./helpers";
import { substitutePluginRoot } from "./substitute-plugin-root";
const PROVIDER_ID = "claude-plugins";
const DISPLAY_NAME = "Claude Code Marketplace";
const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
// =============================================================================
// Skills
// =============================================================================
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
const items: Skill[] = [];
const warnings: string[] = [];
const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
warnings.push(...rootWarnings);
const results = await Promise.all(
roots.map(async root => {
const skillsDir = path.join(root.path, "skills");
const result = await scanSkillsFromDir(ctx, {
dir: skillsDir,
providerId: PROVIDER_ID,
level: root.scope,
});
return { root, result };
}),
);
for (const { root, result } of results) {
for (const skill of result.items) {
if (root.plugin) skill.name = `${root.plugin}:${skill.name}`;
items.push(skill);
}
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
// =============================================================================
// Slash Commands
// =============================================================================
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
const items: SlashCommand[] = [];
const warnings: string[] = [];
const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
warnings.push(...rootWarnings);
const results = await Promise.all(
roots.map(async root => {
const commandsDir = path.join(root.path, "commands");
return loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
extensions: ["md"],
transform: (name, content, filePath, source) => {
const cmdName = name.replace(/\.md$/, "");
return {
name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
path: filePath,
content,
level: root.scope,
_source: source,
};
},
});
}),
);
for (const result of results) {
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
// =============================================================================
// Hooks
// =============================================================================
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
const items: Hook[] = [];
const warnings: string[] = [];
const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
warnings.push(...rootWarnings);
const hookTypes = ["pre", "post"] as const;
const loadTasks: { root: ClaudePluginRoot; hookType: "pre" | "post" }[] = [];
for (const root of roots) {
for (const hookType of hookTypes) {
loadTasks.push({ root, hookType });
}
}
const results = await Promise.all(
loadTasks.map(async ({ root, hookType }) => {
const hooksDir = path.join(root.path, "hooks", hookType);
return loadFilesFromDir<Hook>(ctx, hooksDir, PROVIDER_ID, root.scope, {
transform: (name, _content, filePath, source) => {
const toolName = name.replace(/\.(sh|bash|zsh|fish)$/, "");
return {
name,
path: filePath,
type: hookType,
tool: toolName,
level: root.scope,
_source: source,
};
},
});
}),
);
for (const result of results) {
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
// =============================================================================
// Custom Tools
// =============================================================================
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
const items: CustomTool[] = [];
const warnings: string[] = [];
const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
warnings.push(...rootWarnings);
const results = await Promise.all(
roots.map(async root => {
const toolsDir = path.join(root.path, "tools");
return loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, root.scope, {
transform: (name, _content, filePath, source) => {
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
return {
name: toolName,
path: filePath,
description: `${toolName} custom tool`,
level: root.scope,
_source: source,
};
},
});
}),
);
for (const result of results) {
items.push(...result.items);
if (result.warnings) warnings.push(...result.warnings);
}
return { items, warnings };
}
// =============================================================================
// MCP Servers
// =============================================================================
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
const items: MCPServer[] = [];
const warnings: string[] = [];
const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
warnings.push(...rootWarnings);
for (const root of roots) {
const mcpPath = path.join(root.path, ".mcp.json");
const raw = await readFile(mcpPath);
if (raw === null) continue; // file absent — skip silently
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
warnings.push(`[claude-plugins] Invalid JSON in ${mcpPath}`);
logger.warn(`[claude-plugins] Invalid JSON in ${mcpPath}`);
continue;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
const config = parsed as { mcpServers?: Record<string, unknown> };
if (!config.mcpServers || typeof config.mcpServers !== "object") continue;
for (const [serverName, serverCfg] of Object.entries(config.mcpServers)) {
if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
const raw = serverCfg as {
enabled?: boolean;
timeout?: number;
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
auth?: MCPServer["auth"];
oauth?: MCPServer["oauth"];
type?: string;
};
const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
const server: MCPServer = {
name: namespacedName,
...(raw.enabled !== undefined && { enabled: raw.enabled }),
...(raw.timeout !== undefined && { timeout: raw.timeout }),
...(raw.command !== undefined && { command: substitutePluginRoot(raw.command, root.path) }),
...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }),
...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }),
...(raw.cwd !== undefined && { cwd: substitutePluginRoot(raw.cwd, root.path) }),
...(raw.url !== undefined && { url: raw.url }),
...(raw.headers !== undefined && { headers: raw.headers }),
...(raw.auth !== undefined && { auth: raw.auth }),
...(raw.oauth !== undefined && { oauth: raw.oauth }),
...(raw.type !== undefined && { transport: raw.type as MCPServer["transport"] }),
_source: createSourceMeta(PROVIDER_ID, mcpPath, root.scope),
};
items.push(server);
}
}
return { items, warnings };
}
// =============================================================================
// Provider Registration
// =============================================================================
registerProvider<Skill>(skillCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load skills from Claude Code marketplace plugins (~/.claude/plugins/cache/)",
priority: PRIORITY,
load: loadSkills,
});
registerProvider<SlashCommand>(slashCommandCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load slash commands from Claude Code marketplace plugins",
priority: PRIORITY,
load: loadSlashCommands,
});
registerProvider<Hook>(hookCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load hooks from Claude Code marketplace plugins",
priority: PRIORITY,
load: loadHooks,
});
registerProvider<CustomTool>(toolCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load custom tools from Claude Code marketplace plugins",
priority: PRIORITY,
load: loadTools,
});
registerProvider<MCPServer>(mcpCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load MCP servers from marketplace plugin .mcp.json files",
priority: PRIORITY,
load: loadMCPServers,
});