diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4d27b1973..335975daa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,30 @@ - Fixed MCP tools from newly added servers not being activated after `/mcp add` — `refreshMCPTools` preserves prior MCP tool selections, so brand-new servers had their tools registered in the registry but never passed to the agent; tools are now explicitly activated on successful connection +### Added + +- Added marketplace plugin system: registry types, ID helpers, atomic read/write for `marketplaces.json` and `installed_plugins.json` (Claude Code-compatible format) +- Added `MarketplaceManager` orchestrator for marketplace and plugin lifecycle (add/remove/update marketplaces, install/uninstall/enable plugins) +- Added marketplace fetcher with source classification (GitHub, git, URL, local) and catalog validation +- Added plugin source resolver with `pathIsWithin` containment checks and versioned cache manager +- Added CLI commands: `omp plugin marketplace add|remove|update|list`, `omp plugin discover [marketplace]` +- Added `classifyInstallTarget()` to distinguish `name@marketplace` from npm install targets +- Extended `listClaudePluginRoots()` to read OMP's installed plugins registry alongside Claude Code's, with OMP as authoritative for duplicate plugin IDs +- Added `--plugin-dir ` repeatable CLI flag for loading plugins from local directories +- Added `/reload-plugins` slash command that invalidates fs content cache and plugin roots cache +- Added `printPluginHelp()` entries for marketplace and discover commands +- Added MCP server loading from marketplace plugin `.mcp.json` files with `${CLAUDE_PLUGIN_ROOT}` variable substitution +- Added skill and command namespacing for marketplace plugins (`plugin-name:skill-name`) +- Added LSP config loading from marketplace plugin roots via `getPreloadedPluginRoots()` +- Wired `--plugin-dir` runtime injection into plugin roots at session startup with highest precedence +- Added git (GitHub, SSH, HTTPS) and HTTP URL marketplace source fetching +- Added `/marketplace` TUI slash command with subcommands: add, remove, update, list, discover, install, uninstall, installed +- Added `/plugins` TUI slash command to view all installed plugins (npm + marketplace) and enable/disable marketplace plugins + +### Fixed + +- Fixed `skill://` URI resolver to handle namespaced skills via longest-prefix matching against registered skill names + ## [13.16.5] - 2026-03-29 ### Fixed diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index a7adea1e7..6b28ff889 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -38,6 +38,7 @@ export interface Args { hooks?: string[]; extensions?: string[]; noExtensions?: boolean; + pluginDirs?: string[]; print?: boolean; export?: string; noSkills?: boolean; @@ -151,6 +152,9 @@ export function parseArgs(args: string[], extensionFlags?: Map Load plugin from directory (repeatable) + ${chalk.bold("Useful Commands:")} omp agents unpack - Export bundled subagents to ~/.omp/agent/agents (default) omp agents unpack --project - Export bundled subagents to ./.omp/agents`; diff --git a/packages/coding-agent/src/cli/classify-install-target.ts b/packages/coding-agent/src/cli/classify-install-target.ts new file mode 100644 index 000000000..d8059c8b9 --- /dev/null +++ b/packages/coding-agent/src/cli/classify-install-target.ts @@ -0,0 +1,29 @@ +/** + * Classify an install spec as a marketplace plugin reference or a plain npm package. + * + * Rules (applied in order): + * 1. Starts with `@` (scoped npm) -> always npm. + * 2. Contains `@` after the first character -> split on the LAST `@`. + * If the right-hand side is a known marketplace name, it's a marketplace ref. + * Otherwise it's an npm spec (e.g. `pkg@1.2.3`). + * 3. No `@` -> npm. + */ +export function classifyInstallTarget( + spec: string, + knownMarketplaces: Set, +): { type: "marketplace"; name: string; marketplace: string } | { type: "npm"; spec: string } { + // Rule 1: scoped npm package — @ at position 0 is never a marketplace separator. + if (spec.startsWith("@")) return { type: "npm", spec }; + // Rule 2: @ somewhere after the first character. + const atIdx = spec.lastIndexOf("@"); + if (atIdx > 0) { + const rhs = spec.slice(atIdx + 1); + if (knownMarketplaces.has(rhs)) { + return { type: "marketplace", name: spec.slice(0, atIdx), marketplace: rhs }; + } + // Not a known marketplace — treat as npm version specifier. + return { type: "npm", spec }; + } + // Rule 3: no @ at all. + return { type: "npm", spec }; +} diff --git a/packages/coding-agent/src/cli/plugin-cli.ts b/packages/coding-agent/src/cli/plugin-cli.ts index bb4cf3a34..c8f2212f4 100644 --- a/packages/coding-agent/src/cli/plugin-cli.ts +++ b/packages/coding-agent/src/cli/plugin-cli.ts @@ -7,6 +7,13 @@ import { APP_NAME } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { PluginManager, parseSettingValue, validateSetting } from "../extensibility/plugins"; +import { + getInstalledPluginsRegistryPath, + getMarketplacesCacheDir, + getMarketplacesRegistryPath, + getPluginsCacheDir, + MarketplaceManager, +} from "../extensibility/plugins/marketplace/index.js"; import { theme } from "../modes/theme/theme"; // ============================================================================= @@ -22,7 +29,9 @@ export type PluginAction = | "features" | "config" | "enable" - | "disable"; + | "disable" + | "marketplace" + | "discover"; export interface PluginCommandArgs { action: PluginAction; @@ -53,6 +62,8 @@ const VALID_ACTIONS: PluginAction[] = [ "config", "enable", "disable", + "marketplace", + "discover", ]; /** @@ -108,6 +119,8 @@ export function parsePluginArgs(args: string[]): PluginCommandArgs | undefined { return result; } +export { classifyInstallTarget } from "./classify-install-target.js"; + // ============================================================================= // Command Handlers // ============================================================================= @@ -146,6 +159,127 @@ export async function runPluginCommand(cmd: PluginCommandArgs): Promise { case "disable": await handleDisable(manager, cmd.args, cmd.flags); break; + case "marketplace": + await handleMarketplace(cmd.args, cmd.flags); + break; + case "discover": + await handleDiscover(cmd.args, cmd.flags); + break; + } +} + +// ============================================================================= +// Marketplace Handlers +// ============================================================================= + +function makeMarketplaceManager(): MarketplaceManager { + return new MarketplaceManager({ + marketplacesRegistryPath: getMarketplacesRegistryPath(), + installedRegistryPath: getInstalledPluginsRegistryPath(), + marketplacesCacheDir: getMarketplacesCacheDir(), + pluginsCacheDir: getPluginsCacheDir(), + }); +} + +async function handleMarketplace(args: string[], _flags: PluginCommandArgs["flags"]): Promise { + const subcommand = args[0] ?? "list"; + const manager = makeMarketplaceManager(); + + switch (subcommand) { + case "add": { + const source = args[1]; + if (!source) { + console.error(chalk.red(`Usage: ${APP_NAME} plugin marketplace add `)); + process.exit(1); + } + try { + await manager.addMarketplace(source); + console.log(chalk.green(`${theme.status.success} Added marketplace: ${source}`)); + } catch (err) { + console.error(chalk.red(`${theme.status.error} Failed to add marketplace: ${err}`)); + process.exit(1); + } + break; + } + case "remove": + case "rm": { + const name = args[1]; + if (!name) { + console.error(chalk.red(`Usage: ${APP_NAME} plugin marketplace remove `)); + process.exit(1); + } + try { + await manager.removeMarketplace(name); + console.log(chalk.green(`${theme.status.success} Removed marketplace: ${name}`)); + } catch (err) { + console.error(chalk.red(`${theme.status.error} Failed to remove marketplace: ${err}`)); + process.exit(1); + } + break; + } + case "update": { + try { + const name = args[1]; + if (name) { + await manager.updateMarketplace(name); + console.log(chalk.green(`${theme.status.success} Updated marketplace: ${name}`)); + } else { + const results = await manager.updateAllMarketplaces(); + console.log(chalk.green(`${theme.status.success} Updated ${results.length} marketplace(s)`)); + } + } catch (err) { + console.error(chalk.red(`${theme.status.error} Failed to update marketplace: ${err}`)); + process.exit(1); + } + break; + } + default: { + if (subcommand !== "list") { + console.error(chalk.red(`Unknown marketplace subcommand: ${subcommand}`)); + console.error(chalk.dim("Valid subcommands: add, remove, update, list")); + process.exit(1); + } + try { + const marketplaces = await manager.listMarketplaces(); + if (marketplaces.length === 0) { + console.log(chalk.dim("No marketplaces configured")); + console.log(chalk.dim(`\nAdd one with: ${APP_NAME} plugin marketplace add `)); + return; + } + console.log(chalk.bold("Configured Marketplaces:\n")); + for (const mp of marketplaces) { + console.log(` ${chalk.cyan(mp.name)} ${chalk.dim(mp.sourceUri)}`); + } + } catch (err) { + console.error(chalk.red(`${theme.status.error} Failed to list marketplaces: ${err}`)); + process.exit(1); + } + break; + } + } +} + +async function handleDiscover(args: string[], _flags: PluginCommandArgs["flags"]): Promise { + const marketplace = args[0]; + const manager = makeMarketplaceManager(); + try { + const plugins = await manager.listAvailablePlugins(marketplace); + + if (plugins.length === 0) { + console.log(chalk.dim(marketplace ? `No plugins found in ${marketplace}` : "No plugins available")); + return; + } + + console.log(chalk.bold(`Available Plugins${marketplace ? ` (${marketplace})` : ""}:\n`)); + for (const plugin of plugins) { + console.log(` ${chalk.cyan(plugin.name)}${plugin.version ? `@${plugin.version}` : ""}`); + if (plugin.description) { + console.log(chalk.dim(` ${plugin.description}`)); + } + } + } catch (err) { + console.error(chalk.red(`${theme.status.error} Failed to discover plugins: ${err}`)); + process.exit(1); } } @@ -630,6 +764,8 @@ ${chalk.bold("Commands:")} config [key] [val] Manage plugin settings enable Enable a disabled plugin disable Disable plugin without uninstalling + marketplace Manage marketplace sources (add, remove, update, list) + discover [marketplace] Browse available marketplace plugins ${chalk.bold("Feature Syntax:")} pkg Install with default features diff --git a/packages/coding-agent/src/commands/plugin.ts b/packages/coding-agent/src/commands/plugin.ts index 56670339e..d38266625 100644 --- a/packages/coding-agent/src/commands/plugin.ts +++ b/packages/coding-agent/src/commands/plugin.ts @@ -15,6 +15,8 @@ const ACTIONS: PluginAction[] = [ "config", "enable", "disable", + "marketplace", + "discover", ]; export default class Plugin extends Command { diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts index ec81f64ab..26543149d 100644 --- a/packages/coding-agent/src/discovery/claude-plugins.ts +++ b/packages/coding-agent/src/discovery/claude-plugins.ts @@ -5,13 +5,24 @@ * 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, listClaudePluginRoots, loadFilesFromDir, scanSkillsFromDir } from "./helpers"; +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"; @@ -31,16 +42,20 @@ async function loadSkills(ctx: LoadContext): Promise> { const results = await Promise.all( roots.map(async root => { const skillsDir = path.join(root.path, "skills"); - return scanSkillsFromDir(ctx, { + const result = await scanSkillsFromDir(ctx, { dir: skillsDir, providerId: PROVIDER_ID, level: root.scope, }); + return { root, result }; }), ); - for (const result of results) { - items.push(...result.items); + 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); } @@ -66,7 +81,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise { const cmdName = name.replace(/\.md$/, ""); return { - name: cmdName, + name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName, path: filePath, content, level: root.scope, @@ -169,6 +184,73 @@ async function loadTools(ctx: LoadContext): Promise> { return { items, warnings }; } +// ============================================================================= +// MCP Servers +// ============================================================================= + +async function loadMCPServers(ctx: LoadContext): Promise> { + const items: MCPServer[] = []; + const warnings: string[] = []; + + const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home); + 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 }; + 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; + cwd?: string; + url?: string; + headers?: Record; + 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 // ============================================================================= @@ -204,3 +286,11 @@ registerProvider(toolCapability.id, { priority: PRIORITY, load: loadTools, }); + +registerProvider(mcpCapability.id, { + id: PROVIDER_ID, + displayName: DISPLAY_NAME, + description: "Load MCP servers from marketplace plugin .mcp.json files", + priority: PRIORITY, + load: loadMCPServers, +}); diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 9bc6e62dc..48b03e6ab 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -9,6 +9,7 @@ import type { Skill, SkillFrontmatter } from "../capability/skill"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import { parseThinkingLevel } from "../thinking"; import { parseFrontmatter } from "../utils/frontmatter"; +import { buildPluginDirRoot } from "./plugin-dir-roots"; /** * Standard paths for each config source. @@ -652,53 +653,93 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau const roots: ClaudePluginRoot[] = []; const warnings: string[] = []; + // ── Claude Code registry ────────────────────────────────────────────────── const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json"); const content = await readFile(registryPath); - if (!content) { - // No registry file - not an error, just no plugins - const result = { roots, warnings }; - pluginRootsCache.set(home, result); - return result; - } + if (content) { + const registry = parseClaudePluginsRegistry(content); + if (!registry) { + warnings.push(`Failed to parse Claude Code plugin registry: ${registryPath}`); + } else { + for (const [pluginId, entries] of Object.entries(registry.plugins)) { + if (!Array.isArray(entries) || entries.length === 0) continue; - const registry = parseClaudePluginsRegistry(content); - if (!registry) { - warnings.push(`Failed to parse Claude Code plugin registry: ${registryPath}`); - const result = { roots, warnings }; - pluginRootsCache.set(home, result); - return result; - } + // Parse plugin ID format: "plugin-name@marketplace" + const atIndex = pluginId.lastIndexOf("@"); + if (atIndex === -1) { + warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`); + continue; + } - for (const [pluginId, entries] of Object.entries(registry.plugins)) { - if (!Array.isArray(entries) || entries.length === 0) continue; + const pluginName = pluginId.slice(0, atIndex); + const marketplace = pluginId.slice(atIndex + 1); - // Parse plugin ID format: "plugin-name@marketplace" - const atIndex = pluginId.lastIndexOf("@"); - if (atIndex === -1) { - warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`); - continue; - } + // Process all valid entries, not just the first one. + // This handles plugins with multiple installs (different scopes/versions). + for (const entry of entries) { + if (!entry.installPath || typeof entry.installPath !== "string") { + warnings.push(`Plugin ${pluginId} entry has no installPath`); + continue; + } - const pluginName = pluginId.slice(0, atIndex); - const marketplace = pluginId.slice(atIndex + 1); - - // Process all valid entries, not just the first one. - // This handles plugins with multiple installs (different scopes/versions). - for (const entry of entries) { - if (!entry.installPath || typeof entry.installPath !== "string") { - warnings.push(`Plugin ${pluginId} entry has no installPath`); - continue; + roots.push({ + id: pluginId, + marketplace, + plugin: pluginName, + version: entry.version || "unknown", + path: entry.installPath, + scope: entry.scope || "user", + }); + } } + } + } - roots.push({ - id: pluginId, - marketplace, - plugin: pluginName, - version: entry.version || "unknown", - path: entry.installPath, - scope: entry.scope || "user", - }); + // ── OMP installed plugins registry ─────────────────────────────────────── + // OMP registry is authoritative: its entries replace Claude's entries for the same plugin ID. + // Path derived from `home` (not os.homedir()) so test isolation works when home is overridden. + const ompRegistryPath = path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"); + const ompContent = await readFile(ompRegistryPath); + if (ompContent) { + const ompRegistry = parseClaudePluginsRegistry(ompContent); + if (ompRegistry) { + for (const [pluginId, entries] of Object.entries(ompRegistry.plugins)) { + if (!Array.isArray(entries) || entries.length === 0) continue; + + const atIndex = pluginId.lastIndexOf("@"); + if (atIndex === -1) { + warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`); + continue; + } + const pluginName = pluginId.slice(0, atIndex); + const marketplace = pluginId.slice(atIndex + 1); + + // OMP is authoritative: drop all Claude-sourced entries for this plugin ID + const filtered = roots.filter(r => r.id !== pluginId); + roots.length = 0; + roots.push(...filtered); + + for (const entry of entries) { + if (!entry.installPath || typeof entry.installPath !== "string") { + warnings.push(`Plugin ${pluginId} entry has no installPath`); + continue; + } + // Deduplicate by installPath within same ID + if (roots.some(r => r.id === pluginId && r.path === entry.installPath)) continue; + + roots.push({ + id: pluginId, + marketplace, + plugin: pluginName, + version: entry.version || "unknown", + path: entry.installPath, + scope: entry.scope || "user", + }); + } + } + } else { + warnings.push(`Failed to parse OMP plugin registry: ${ompRegistryPath}`); } } @@ -712,4 +753,70 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau */ export function clearClaudePluginRootsCache(): void { pluginRootsCache.clear(); + preloadedPluginRoots = []; +} + +// ── Preloaded plugin roots (for sync consumers like LSP config) ───────────── +// Populated at startup by preloadPluginRoots(). Read synchronously by +// getPreloadedPluginRoots(). Safe degradation: empty array if not warmed. + +let preloadedPluginRoots: ClaudePluginRoot[] = []; + +/** + * Populate the module-level plugin roots cache for sync consumers. + * Call during session initialization, after dir resolution completes + * but before any LSP config is read. + */ +export async function preloadPluginRoots(home: string): Promise { + const { roots } = await listClaudePluginRoots(home); + preloadedPluginRoots = roots; +} + +/** + * Get pre-loaded plugin roots synchronously. + * Returns empty array if preloadPluginRoots() hasn't been called. + */ +export function getPreloadedPluginRoots(): readonly ClaudePluginRoot[] { + return preloadedPluginRoots; +} + +// ── --plugin-dir injection ────────────────────────────────────────────────── + +/** + * Inject synthetic plugin roots from --plugin-dir paths. + * These are prepended to the cache with highest precedence (before OMP/Claude entries). + * Must be called before any listClaudePluginRoots() access. + */ +export async function injectPluginDirRoots(home: string, dirs: string[]): Promise { + // Ensure the base cache is populated first + const { roots, warnings } = await listClaudePluginRoots(home); + + const injected: ClaudePluginRoot[] = []; + for (const dir of dirs) { + const resolved = path.resolve(dir); + // Read plugin name from manifest + let pluginName = path.basename(resolved); + try { + const manifestPath = path.join(resolved, ".claude-plugin", "plugin.json"); + const content = await Bun.file(manifestPath).text(); + const manifest = JSON.parse(content); + if (typeof manifest.name === "string" && manifest.name) { + pluginName = manifest.name; + } + } catch { + // No manifest or invalid — use directory name + } + + injected.push(buildPluginDirRoot(resolved, pluginName)); + } + + // --plugin-dir roots have highest precedence: prepend them, + // removing any existing entries with the same plugin ID. + const injectedIds = new Set(injected.map(r => r.id)); + const filtered = roots.filter(r => !injectedIds.has(r.id)); + const merged = [...injected, ...filtered]; + + // Replace the cache entry + pluginRootsCache.set(home, { roots: merged, warnings }); + preloadedPluginRoots = merged; } diff --git a/packages/coding-agent/src/discovery/plugin-dir-roots.ts b/packages/coding-agent/src/discovery/plugin-dir-roots.ts new file mode 100644 index 000000000..f56cc8593 --- /dev/null +++ b/packages/coding-agent/src/discovery/plugin-dir-roots.ts @@ -0,0 +1,28 @@ +import * as path from "node:path"; + +/** Synthetic plugin root for a --plugin-dir path. Shape-compatible with ClaudePluginRoot. */ +export interface PluginDirRoot { + id: string; + marketplace: string; + plugin: string; + version: string; + path: string; + scope: "user" | "project"; +} + +/** + * Build a synthetic plugin root from a --plugin-dir resolved path. + * @param resolvedPath Absolute path to the plugin directory + * @param manifestName Plugin name from manifest; falls back to directory basename + */ +export function buildPluginDirRoot(resolvedPath: string, manifestName?: string): PluginDirRoot { + const pluginName = manifestName || path.basename(resolvedPath); + return { + id: `${pluginName}@__local__`, + marketplace: "__local__", + plugin: pluginName, + version: "local", + path: resolvedPath, + scope: "user", + }; +} diff --git a/packages/coding-agent/src/discovery/substitute-plugin-root.ts b/packages/coding-agent/src/discovery/substitute-plugin-root.ts new file mode 100644 index 000000000..e6b5cef9c --- /dev/null +++ b/packages/coding-agent/src/discovery/substitute-plugin-root.ts @@ -0,0 +1,29 @@ +/** + * Recursively substitute ${CLAUDE_PLUGIN_ROOT} and ${OMP_PLUGIN_ROOT} + * with the actual plugin root path in strings, arrays, and plain objects. + */ +// Use concatenation to avoid noTemplateCurlyInString lint rule on literal placeholder names +const CLAUDE_VAR = "$" + "{CLAUDE_PLUGIN_ROOT}"; +const OMP_VAR = "$" + "{OMP_PLUGIN_ROOT}"; + +export function substitutePluginRoot(value: T, rootPath: string): T { + if (typeof value === "string") { + return value.replaceAll(CLAUDE_VAR, rootPath).replaceAll(OMP_VAR, rootPath) as T; + } + if (Array.isArray(value)) { + return value.map(v => substitutePluginRoot(v, rootPath)) as T; + } + if (value && typeof value === "object") { + const result: Record = Object.create(null); + for (const [k, v] of Object.entries(value as Record)) { + Object.defineProperty(result, k, { + value: substitutePluginRoot(v, rootPath), + enumerable: true, + writable: true, + configurable: true, + }); + } + return result as T; + } + return value; +} diff --git a/packages/coding-agent/src/extensibility/plugins/index.ts b/packages/coding-agent/src/extensibility/plugins/index.ts index 8666950e2..3e753e0d1 100644 --- a/packages/coding-agent/src/extensibility/plugins/index.ts +++ b/packages/coding-agent/src/extensibility/plugins/index.ts @@ -4,5 +4,6 @@ export * from "./doctor"; export * from "./git-url"; export * from "./loader"; export * from "./manager"; +export * from "./marketplace"; export * from "./parser"; export type * from "./types"; diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/cache.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/cache.ts new file mode 100644 index 000000000..64b4ce369 --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/cache.ts @@ -0,0 +1,128 @@ +/** + * Plugin cache management. + * + * Cache layout: `/______/` + * + * All three components are validated before any filesystem operation: + * - marketplace / pluginName: isValidNameSegment (lowercase alnum + hyphens, max 64) + * - version: isValidVersionForCache (alnum + ._+-, max 128) + * + * This ensures cache paths cannot be crafted to escape the cache directory. + */ + +import * as nodeFs from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { isEnoent } from "@oh-my-pi/pi-utils"; + +import { isValidNameSegment } from "./types"; + +// Reject anything that could be used for path traversal or shell injection in +// version strings. Only printable, unambiguous characters are allowed. +const VERSION_RE = /^[a-zA-Z0-9._+-]+$/; + +/** Return true when `version` is safe for use as a cache path component. */ +export function isValidVersionForCache(version: string): boolean { + // prevent path-traversal sequences like ".." or "1..2" + return version.length > 0 && version.length <= 128 && VERSION_RE.test(version) && !version.includes(".."); +} + +function validateCacheComponents(marketplace: string, pluginName: string, version: string): void { + if (!isValidNameSegment(marketplace)) { + throw new Error(`Invalid marketplace name for cache: "${marketplace}"`); + } + if (!isValidNameSegment(pluginName)) { + throw new Error(`Invalid plugin name for cache: "${pluginName}"`); + } + if (!isValidVersionForCache(version)) { + throw new Error(`Invalid version for cache: "${version}"`); + } +} + +/** + * Return the absolute path for a cached plugin directory. + * Throws if any component fails validation. + */ +export function getCachedPluginPath( + cacheDir: string, + marketplace: string, + pluginName: string, + version: string, +): string { + validateCacheComponents(marketplace, pluginName, version); + return path.join(cacheDir, `${marketplace}___${pluginName}___${version}`); +} + +/** + * Copy `sourcePath` into the cache, returning the absolute cache path. + * + * Idempotent: if the target already exists it is removed before copying, + * so a partial previous cache is never silently reused. + */ +export async function cachePlugin( + sourcePath: string, + cacheDir: string, + marketplace: string, + pluginName: string, + version: string, +): Promise { + const targetPath = getCachedPluginPath(cacheDir, marketplace, pluginName, version); + + // Ensure cache directory exists before writing into it + await fs.mkdir(cacheDir, { recursive: true }); + + // Remove stale/partial cache entry before re-copying + await fs.rm(targetPath, { recursive: true, force: true }); + + await fs.cp(sourcePath, targetPath, { recursive: true }); + + return targetPath; +} + +/** + * Synchronous check — true when the cache directory exists on disk. + * Uses `existsSync` because callers may need to run this check inline without async. + */ +export function isCached(cacheDir: string, marketplace: string, pluginName: string, version: string): boolean { + const targetPath = getCachedPluginPath(cacheDir, marketplace, pluginName, version); + return nodeFs.existsSync(targetPath); +} + +/** Remove a single cached plugin directory. No-op if it does not exist. */ +export async function removeCachedPlugin( + cacheDir: string, + marketplace: string, + pluginName: string, + version: string, +): Promise { + const targetPath = getCachedPluginPath(cacheDir, marketplace, pluginName, version); + await fs.rm(targetPath, { recursive: true, force: true }); +} + +/** + * Remove all cache entries whose full path is not in `installedPaths`. + * + * Returns the count of removed directories. If `cacheDir` does not exist, + * returns `{ removed: 0 }` rather than throwing. + */ +export async function cleanOrphanedCache(cacheDir: string, installedPaths: Set): Promise<{ removed: number }> { + let entries: string[]; + try { + entries = await fs.readdir(cacheDir); + } catch (err) { + if (isEnoent(err)) return { removed: 0 }; + throw err; + } + + let removed = 0; + for (const entry of entries) { + const fullPath = path.join(cacheDir, entry); + if (!installedPaths.has(fullPath)) { + await fs.rm(fullPath, { recursive: true, force: true }); + removed++; + } + } + + return { removed }; +} diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts new file mode 100644 index 000000000..b628c16b0 --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/fetcher.ts @@ -0,0 +1,314 @@ +/** + * Marketplace catalog fetcher. + * + * Classifies a source string, resolves it, and loads the catalog. + */ + +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { $ } from "bun"; + +import type { MarketplaceCatalog, MarketplaceSourceType } from "./types"; +import { isValidNameSegment } from "./types"; + +// ── Types ───────────────────────────────────────────────────────────── + +export interface FetchResult { + catalog: MarketplaceCatalog; + /** For git sources: path to the cloned marketplace directory. */ + clonePath?: string; +} + +// ── classifySource ──────────────────────────────────────────────────── + +/** + * Detects Windows-style absolute paths cross-platform: + * C:\path, C:/path → drive-letter + colon + separator + * \\server\share → UNC path + * + * Needed because path.isAbsolute("C:\...") returns false on POSIX. + */ +const WIN_ABS_RE = /^[A-Za-z]:[/\\]|^\\\\/; + +/** + * GitHub owner/repo shorthand: lowercase alphanumeric + hyphens/dots, one slash. + * Must NOT start with a protocol — that is ruled out by earlier checks. + */ +const GITHUB_SHORTHAND_RE = /^[a-z0-9-]+\/[a-z0-9._-]+$/i; + +/** + * Classify a marketplace source string into one of the four source types. + * + * Rules are ordered; the first match wins. Protocol/pattern checks (rules 1-3) + * run before any path.isAbsolute() check so that SCP-style git@ URLs are + * never misclassified as local paths on Windows. + * + * @throws if the source format is unrecognized. + */ +export function classifySource(source: string): MarketplaceSourceType { + // Rule 1: HTTP(S) URLs — .json suffix → url, everything else → git + if (source.startsWith("https://") || source.startsWith("http://")) { + return source.endsWith(".json") ? "url" : "git"; + } + + // Rule 2: SCP-style SSH git URLs + if (source.startsWith("git@") || source.startsWith("ssh://")) { + return "git"; + } + + // Rule 3: GitHub owner/repo shorthand (no protocol, no leading slash) + if (GITHUB_SHORTHAND_RE.test(source)) { + return "github"; + } + + // Rule 4: Explicit relative or home-relative paths + if (source.startsWith("./") || source.startsWith("~/")) { + return "local"; + } + + // Rule 5: Absolute paths — POSIX via path.isAbsolute, Windows via regex + if (path.isAbsolute(source) || WIN_ABS_RE.test(source)) { + return "local"; + } + + throw new Error(`Unrecognized source format. Did you mean './${source}' (local) or 'owner/repo' (GitHub)?`); +} + +// ── parseMarketplaceCatalog ─────────────────────────────────────────── + +function assertField(condition: boolean, field: string, filePath: string): void { + if (!condition) { + throw new Error(`Missing or invalid field "${field}" in catalog: ${filePath}`); + } +} + +/** + * Parse and validate a marketplace.json catalog from raw JSON content. + * + * Required fields: name (valid name segment), owner.name, plugins array. + * Each plugin entry requires name (string) and source (string or object + * with a "source" field). Extra fields are preserved via spread. + * + * @throws on JSON parse failure or missing/invalid required fields. + */ +export function parseMarketplaceCatalog(content: string, filePath: string): MarketplaceCatalog { + let raw: unknown; + try { + raw = JSON.parse(content); + } catch (err) { + throw new Error(`Failed to parse marketplace catalog at ${filePath}: ${(err as Error).message}`); + } + + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Marketplace catalog at ${filePath} must be a JSON object`); + } + + const obj = raw as Record; + + // name: required, must be a valid name segment + assertField(typeof obj.name === "string" && isValidNameSegment(obj.name), "name", filePath); + + // owner: required object with name string + assertField(typeof obj.owner === "object" && obj.owner !== null && !Array.isArray(obj.owner), "owner", filePath); + const owner = obj.owner as Record; + assertField(typeof owner.name === "string", "owner.name", filePath); + + // plugins: required array + assertField(Array.isArray(obj.plugins), "plugins", filePath); + + const plugins = obj.plugins as unknown[]; + for (let i = 0; i < plugins.length; i++) { + const entry = plugins[i]; + assertField(typeof entry === "object" && entry !== null && !Array.isArray(entry), `plugins[${i}]`, filePath); + const p = entry as Record; + assertField(typeof p.name === "string", `plugins[${i}].name`, filePath); + // source can be a string path or a typed object (github/url/git-subdir/npm) + // all typed objects carry a "source" discriminant string field + assertField( + typeof p.source === "string" || + (typeof p.source === "object" && + p.source !== null && + !Array.isArray(p.source) && + typeof (p.source as Record).source === "string"), + `plugins[${i}].source`, + filePath, + ); + } + + // Extra fields are preserved — cast through unknown for type safety + return obj as unknown as MarketplaceCatalog; +} + +// ── fetchMarketplace ────────────────────────────────────────────────── + +/** Relative path from a marketplace root to its catalog file. */ +const CATALOG_RELATIVE_PATH = path.join(".claude-plugin", "marketplace.json"); + +/** + * Expand a `~/...` path to an absolute path using os.homedir(). + * Other paths are returned unchanged. + */ +function expandHome(p: string): string { + if (p.startsWith("~/")) { + return path.join(os.homedir(), p.slice(2)); + } + return p; +} + +/** + * Fetch a marketplace catalog from a source. + * + * Dispatches on the source type: local filesystem paths are read directly; + * GitHub/git sources are cloned with `git`; URL sources are fetched over HTTP. + * + * @param source Source identifier: path, GitHub shorthand, git URL, or HTTP URL. + * @param cacheDir Cache directory root for non-local sources. + */ +export async function fetchMarketplace(source: string, cacheDir: string): Promise { + const type = classifySource(source); + + if (type === "local") { + const resolved = path.resolve(expandHome(source)); + const catalogPath = path.join(resolved, CATALOG_RELATIVE_PATH); + + let content: string; + try { + content = await Bun.file(catalogPath).text(); + } catch (err) { + if (isEnoent(err)) { + throw new Error( + `Marketplace catalog not found at "${catalogPath}". ` + + `Ensure the directory exists and contains a .claude-plugin/marketplace.json file.`, + ); + } + throw err; + } + + const catalog = parseMarketplaceCatalog(content, catalogPath); + return { catalog }; + } + + if (type === "github") { + const url = `https://github.com/${source}.git`; + return cloneAndReadCatalog(url, cacheDir); + } + + if (type === "git") { + return cloneAndReadCatalog(source, cacheDir); + } + + // type === "url" + const response = await fetch(source, { signal: AbortSignal.timeout(60_000) }); + if (!response.ok) { + throw new Error( + `Failed to fetch marketplace catalog from ${source}: HTTP ${response.status} ${response.statusText}`, + ); + } + const text = await response.text(); + const catalog = parseMarketplaceCatalog(text, source); + + const catalogDir = path.join(cacheDir, catalog.name); + await Bun.write(path.join(catalogDir, "marketplace.json"), text); + + return { catalog }; +} + +// ── cloneAndReadCatalog ─────────────────────────────────────────────── + +/** + * Clone a git repository and read its marketplace catalog. + * + * Clones to a temp directory first, reads the catalog to determine the + * canonical name, then renames the temp dir to the final location. + * Cleans up on any failure to avoid leaving partial state in cacheDir. + */ +async function cloneAndReadCatalog(url: string, cacheDir: string): Promise { + if (!Bun.which("git")) { + throw new Error("git is not installed. Install git to use git-based marketplace sources."); + } + + const tmpDir = path.join(cacheDir, `.tmp-clone-${Date.now()}`); + await fs.mkdir(cacheDir, { recursive: true }); + + logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`); + + const result = await $`git clone --depth 1 --single-branch ${url} ${tmpDir}`.quiet().nothrow(); + if (result.exitCode !== 0) { + await fs.rm(tmpDir, { recursive: true, force: true }); + const stderr = result.stderr.toString().trim(); + throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`); + } + + const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH); + let content: string; + try { + content = await Bun.file(catalogPath).text(); + } catch (err) { + await fs.rm(tmpDir, { recursive: true, force: true }); + if (isEnoent(err)) { + throw new Error(`Cloned repository has no marketplace catalog at ${CATALOG_RELATIVE_PATH}`); + } + throw err; + } + + let catalog: ReturnType; + try { + catalog = parseMarketplaceCatalog(content, catalogPath); + } catch (err) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + throw err; + } + + const finalDir = path.join(cacheDir, catalog.name); + await fs.rm(finalDir, { recursive: true, force: true }); + await fs.rename(tmpDir, finalDir); + + return { catalog, clonePath: finalDir }; +} + +/** + * Clone a git repository to a target directory. Shared by fetcher (marketplace clones) + * and source-resolver (plugin source clones). + * + * @param url - Git clone URL (HTTPS, SSH, or GitHub shorthand expanded to HTTPS) + * @param targetDir - Directory to clone into (must not exist) + * @param options.ref - Optional branch/tag to clone + * @param options.sha - Optional commit SHA to checkout after clone + */ +export async function cloneGitRepo( + url: string, + targetDir: string, + options?: { ref?: string; sha?: string }, +): Promise { + if (!Bun.which("git")) { + throw new Error("git is not installed. Install git to use git-based plugin sources."); + } + + const cloneArgs = ["git", "clone", "--depth", "1"]; + if (options?.ref) { + cloneArgs.push("--branch", options.ref, "--single-branch"); + } else { + cloneArgs.push("--single-branch"); + } + cloneArgs.push(url, targetDir); + + logger.debug("[marketplace] cloning plugin source", { url, targetDir }); + + const result = await $`${cloneArgs}`.quiet().nothrow(); + if (result.exitCode !== 0) { + await fs.rm(targetDir, { recursive: true, force: true }); + const stderr = result.stderr.toString().trim(); + throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`); + } + + // If a specific SHA is requested, checkout that commit + if (options?.sha) { + const checkout = await $`git -C ${targetDir} checkout ${options.sha}`.quiet().nothrow(); + if (checkout.exitCode !== 0) { + await fs.rm(targetDir, { recursive: true, force: true }); + throw new Error(`Failed to checkout SHA ${options.sha} — shallow clone may not contain this commit`); + } + } +} diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/index.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/index.ts new file mode 100644 index 000000000..42680ec93 --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/index.ts @@ -0,0 +1,6 @@ +export * from "./cache"; +export * from "./fetcher"; +export * from "./manager"; +export * from "./registry"; +export * from "./source-resolver"; +export * from "./types"; diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts new file mode 100644 index 000000000..ecd43116d --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/manager.ts @@ -0,0 +1,393 @@ +/** + * MarketplaceManager — orchestrates registry, fetcher, resolver, and cache. + * + * Constructor takes explicit paths for testability (same pattern as registry.ts). + * The `clearPluginRootsCache` dependency is injected so callers can provide + * the real `clearClaudePluginRootsCache` while tests supply a counter stub. + */ + +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils"; + +import { cachePlugin } from "./cache"; +import { classifySource, fetchMarketplace, parseMarketplaceCatalog } from "./fetcher"; +import { + addInstalledPlugin, + addMarketplaceEntry, + getInstalledPlugin, + getMarketplaceEntry, + readInstalledPluginsRegistry, + readMarketplacesRegistry, + removeInstalledPlugin, + removeMarketplaceEntry, + writeInstalledPluginsRegistry, + writeMarketplacesRegistry, +} from "./registry"; +import { resolvePluginSource } from "./source-resolver"; +import type { + InstalledPluginEntry, + MarketplaceCatalog, + MarketplacePluginEntry, + MarketplaceRegistryEntry, +} from "./types"; +import { buildPluginId, parsePluginId } from "./types"; + +// ── Options ────────────────────────────────────────────────────────────────── + +export interface MarketplaceManagerOptions { + marketplacesRegistryPath: string; + installedRegistryPath: string; + marketplacesCacheDir: string; + pluginsCacheDir: string; + /** Injected for testing; production callers pass clearClaudePluginRootsCache. */ + clearPluginRootsCache?: () => void; +} + +// ── Manager ────────────────────────────────────────────────────────────────── + +export class MarketplaceManager { + #opts: MarketplaceManagerOptions; + + constructor(options: MarketplaceManagerOptions) { + this.#opts = options; + } + + // ── Marketplace lifecycle ───────────────────────────────────────────────── + + async addMarketplace(source: string): Promise { + const { catalog } = await fetchMarketplace(source, this.#opts.marketplacesCacheDir); + const sourceType = classifySource(source); + + const catalogPath = path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json"); + + // Persist the fetched catalog so subsequent reads don't require re-fetching. + await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + + const now = new Date().toISOString(); + const entry: MarketplaceRegistryEntry = { + name: catalog.name, + sourceType, + sourceUri: source, + catalogPath, + addedAt: now, + updatedAt: now, + }; + + const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath); + // addMarketplaceEntry throws if name already exists — propagate to caller. + const updated = addMarketplaceEntry(reg, entry); + await writeMarketplacesRegistry(this.#opts.marketplacesRegistryPath, updated); + + logger.debug("Marketplace added", { name: catalog.name, sourceType }); + return entry; + } + + async removeMarketplace(name: string): Promise { + const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath); + // removeMarketplaceEntry throws if not found — propagate to caller. + const updated = removeMarketplaceEntry(reg, name); + await writeMarketplacesRegistry(this.#opts.marketplacesRegistryPath, updated); + + await fs.rm(path.join(this.#opts.marketplacesCacheDir, name), { + recursive: true, + force: true, + }); + + logger.debug("Marketplace removed", { name }); + } + + async updateMarketplace(name: string): Promise { + const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath); + const existing = getMarketplaceEntry(reg, name); + if (!existing) { + throw new Error(`Marketplace "${name}" not found`); + } + + const { catalog } = await fetchMarketplace(existing.sourceUri, this.#opts.marketplacesCacheDir); + + // Overwrite cached catalog + await Bun.write(existing.catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + + const updatedEntry: MarketplaceRegistryEntry = { + ...existing, + updatedAt: new Date().toISOString(), + }; + + const updatedReg = { + ...reg, + marketplaces: reg.marketplaces.map(m => (m.name === name ? updatedEntry : m)), + }; + await writeMarketplacesRegistry(this.#opts.marketplacesRegistryPath, updatedReg); + + logger.debug("Marketplace updated", { name }); + return updatedEntry; + } + + async updateAllMarketplaces(): Promise { + const marketplaces = await this.listMarketplaces(); + const results: MarketplaceRegistryEntry[] = []; + for (const m of marketplaces) { + const updated = await this.updateMarketplace(m.name); + results.push(updated); + } + return results; + } + + async listMarketplaces(): Promise { + const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath); + return reg.marketplaces; + } + + // ── Plugin discovery ────────────────────────────────────────────────────── + + async listAvailablePlugins(marketplace?: string): Promise { + const reg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath); + + if (marketplace !== undefined) { + const entry = reg.marketplaces.find(m => m.name === marketplace); + if (!entry) { + throw new Error(`Marketplace "${marketplace}" not found`); + } + const catalog = await this.#readCatalog(entry); + return catalog.plugins; + } + + const all: MarketplacePluginEntry[] = []; + for (const entry of reg.marketplaces) { + const catalog = await this.#readCatalog(entry); + all.push(...catalog.plugins); + } + return all; + } + + async getPluginInfo(name: string, marketplace: string): Promise { + const plugins = await this.listAvailablePlugins(marketplace); + return plugins.find(p => p.name === name) ?? null; + } + + // ── Install / uninstall ─────────────────────────────────────────────────── + + async installPlugin( + name: string, + marketplace: string, + options?: { force?: boolean }, + ): Promise { + const force = options?.force ?? false; + + // 1. Find marketplace entry + const mktReg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath); + const mktEntry = getMarketplaceEntry(mktReg, marketplace); + if (!mktEntry) { + throw new Error(`Marketplace "${marketplace}" not found`); + } + + // 2. Find plugin in catalog + const catalog = await this.#readCatalog(mktEntry); + const pluginEntry = catalog.plugins.find(p => p.name === name); + if (!pluginEntry) { + throw new Error(`Plugin "${name}" not found in marketplace "${marketplace}"`); + } + + const pluginId = buildPluginId(name, marketplace); + + // 3. Check if already installed + const instReg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath); + const existing = getInstalledPlugin(instReg, pluginId); + if (existing && existing.length > 0) { + if (!force) { + throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`); + } + // Remove all cached entries for this plugin (there may be multiple versions) + for (const entry of existing) { + await fs.rm(entry.installPath, { recursive: true, force: true }); + } + const prunedReg = removeInstalledPlugin(instReg, pluginId); + await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, prunedReg); + this.#opts.clearPluginRootsCache?.(); + } + + // 4. Resolve source path. + // marketplaceClonePath is the marketplace root — the directory containing .claude-plugin/ + // catalogPath is //marketplace.json, so the root is two levels up. + // For local sources the content was fetched from a local path; the stored catalog is a copy + // under marketplacesCacheDir. We need the original source root for resolving relative paths. + // Use: path.dirname(catalogPath) is //, and that IS the stored copy root, + // so `path.resolve(mktEntry.catalogPath, "../..")` = parent of / inside cacheDir + // which is wrong for local sources. Instead, derive from the stored catalog directory: + // stored at: //marketplace.json + // The marketplace root for local sources should be the actual local path, but we only have + // sourceUri. For local sources, use path.resolve of sourceUri; for others use the cache dir. + const marketplaceClonePath = this.#resolveMarketplaceRoot(mktEntry); + + const sourcePath = await resolvePluginSource(pluginEntry, { + marketplaceClonePath, + catalogMetadata: catalog.metadata, + tmpDir: os.tmpdir(), + }); + const isTemp = pathIsWithin(os.tmpdir(), sourcePath); + + // 5. Determine version: catalog entry > plugin manifest > git SHA > fallback + let version!: string; + let cachePath!: string; + try { + version = await this.#resolvePluginVersion(pluginEntry, sourcePath); + cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version); + } finally { + // Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone + if (isTemp) { + // For git-subdir, sourcePath may be a subdir of the temp clone — clean the whole clone root. + const rel = path.relative(os.tmpdir(), sourcePath); + const tempRoot = path.join(os.tmpdir(), rel.split(path.sep)[0]); + await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {}); + } + } + + // 6. Build and register the entry + const now = new Date().toISOString(); + const installedEntry: InstalledPluginEntry = { + scope: "user", + installPath: cachePath, + version, + installedAt: now, + lastUpdated: now, + }; + + const freshInstReg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath); + const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry); + await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, newInstReg); + + this.#opts.clearPluginRootsCache?.(); + + logger.debug("Plugin installed", { pluginId, version, cachePath }); + return installedEntry; + } + + /** + * Resolve plugin version from multiple sources: + * 1. Catalog entry version (if set) + * 2. Plugin manifest (.claude-plugin/plugin.json or package.json) + * 3. Git SHA from source (truncated to 7 chars) + * 4. Fallback "0.0.0" + */ + async #resolvePluginVersion(entry: MarketplacePluginEntry, sourcePath: string): Promise { + // 1. Catalog entry version + if (entry.version) return entry.version; + + // 2. Plugin manifest + for (const manifestPath of [ + path.join(sourcePath, ".claude-plugin", "plugin.json"), + path.join(sourcePath, "package.json"), + ]) { + try { + const content = await Bun.file(manifestPath).json(); + if (typeof content?.version === "string" && content.version) { + return content.version; + } + } catch { + // Missing or invalid — try next + } + } + + // 3. Git SHA from source definition + if (typeof entry.source === "object" && "sha" in entry.source && entry.source.sha) { + return entry.source.sha.slice(0, 7); + } + + return "0.0.0"; + } + + async uninstallPlugin(pluginId: string): Promise { + const parsed = parsePluginId(pluginId); + if (!parsed) { + throw new Error(`Invalid plugin ID format: "${pluginId}". Expected "name@marketplace".`); + } + + const reg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath); + const entries = getInstalledPlugin(reg, pluginId); + if (!entries || entries.length === 0) { + throw new Error(`Plugin "${pluginId}" is not installed`); + } + + // Remove all install paths from disk + for (const entry of entries) { + await fs.rm(entry.installPath, { recursive: true, force: true }); + } + + const updated = removeInstalledPlugin(reg, pluginId); + await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, updated); + + this.#opts.clearPluginRootsCache?.(); + + logger.debug("Plugin uninstalled", { pluginId }); + } + + // ── Plugin state ────────────────────────────────────────────────────────── + + async listInstalledPlugins(): Promise> { + const reg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath); + return Object.entries(reg.plugins).map(([id, entries]) => ({ id, entries })); + } + + async setPluginEnabled(pluginId: string, enabled: boolean): Promise { + const reg = await readInstalledPluginsRegistry(this.#opts.installedRegistryPath); + const entries = getInstalledPlugin(reg, pluginId); + if (!entries || entries.length === 0) { + throw new Error(`Plugin "${pluginId}" is not installed`); + } + + const updated = { + ...reg, + plugins: { + ...reg.plugins, + [pluginId]: entries.map(e => ({ ...e, enabled })), + }, + }; + await writeInstalledPluginsRegistry(this.#opts.installedRegistryPath, updated); + + this.#opts.clearPluginRootsCache?.(); + + logger.debug("Plugin enabled state changed", { pluginId, enabled }); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + async #readCatalog(entry: MarketplaceRegistryEntry): Promise { + try { + const content = await Bun.file(entry.catalogPath).text(); + return parseMarketplaceCatalog(content, entry.catalogPath); + } catch (err) { + if (isEnoent(err)) { + throw new Error( + `Marketplace catalog not found at ${entry.catalogPath}. Try: /marketplace update ${entry.name}`, + ); + } + throw err; + } + } + + /** + * Compute the marketplace root directory for source resolution. + * + * For local sources: sourceUri IS the local path, so resolve it directly. + * This gives the directory containing `.claude-plugin/marketplace.json`, + * which is what resolvePluginSource expects as `marketplaceClonePath`. + * + * For remote sources (git/github/url): the catalog was cloned into + * `//`, so the root is the parent of catalogPath. + */ + #resolveMarketplaceRoot(entry: MarketplaceRegistryEntry): string { + if (entry.sourceType === "local") { + // expandHome already happened in fetcher; resolve to ensure absolute. + const expanded = entry.sourceUri.startsWith("~/") + ? path.join(os.homedir(), entry.sourceUri.slice(2)) + : entry.sourceUri; + return path.resolve(expanded); + } + // For git/github/url sources, the catalog lives at /marketplace.json + // under marketplacesCacheDir//; parent = // + return path.dirname(entry.catalogPath); + } +} diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts new file mode 100644 index 000000000..eb23641b9 --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts @@ -0,0 +1,181 @@ +/** + * Registry read/write operations for the marketplace plugin system. + * + * Two registries: + * - marketplaces.json under getConfigRootDir() — which catalogs the user has added + * - installed_plugins.json under getPluginsDir() — which plugins are installed + * + * Read/write functions accept explicit file paths so callers control the + * location. Path helpers compute the default paths from the dir singleton. + * + * Both use atomic write (tmp + rename). On Windows, rename over existing file + * can fail with EPERM — fallback: unlink target then rename. + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { getConfigRootDir, getPluginsDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils"; + +import type { + InstalledPluginEntry, + InstalledPluginsRegistry, + MarketplaceRegistryEntry, + MarketplacesRegistry, +} from "./types"; + +// ── Path helpers ───────────────────────────────────────────────────── + +export function getMarketplacesRegistryPath(): string { + return path.join(getConfigRootDir(), "marketplaces.json"); +} + +export function getInstalledPluginsRegistryPath(): string { + return path.join(getPluginsDir(), "installed_plugins.json"); +} + +export function getMarketplacesCacheDir(): string { + return path.join(getPluginsDir(), "cache", "marketplaces"); +} + +export function getPluginsCacheDir(): string { + return path.join(getPluginsDir(), "cache", "plugins"); +} + +// ── Atomic write ───────────────────────────────────────────────────── + +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const content = `${JSON.stringify(data, null, 2)}\n`; + const tmpPath = `${filePath}.tmp`; + + await Bun.write(tmpPath, content); + + try { + await fs.rename(tmpPath, filePath); + } catch (err) { + // Windows EPERM fallback: unlink target, then rename + if ((err as NodeJS.ErrnoException).code === "EPERM") { + try { + await fs.unlink(filePath); + } catch { + // Target may not exist — that's fine + } + await fs.rename(tmpPath, filePath); + } else { + // Clean up tmp on unexpected errors + try { + await fs.unlink(tmpPath); + } catch { + // Best effort + } + throw err; + } + } +} + +// ── Marketplaces registry ──────────────────────────────────────────── + +function emptyMarketplacesRegistry(): MarketplacesRegistry { + return { version: 1, marketplaces: [] }; +} + +export async function readMarketplacesRegistry(filePath: string): Promise { + try { + const content = await Bun.file(filePath).text(); + const data = tryParseJson(content); + if (!data || typeof data !== "object" || data.version !== 1 || !Array.isArray(data.marketplaces)) { + logger.warn("Invalid marketplaces registry, returning empty", { path: filePath }); + return emptyMarketplacesRegistry(); + } + return data; + } catch (err) { + if (isEnoent(err)) return emptyMarketplacesRegistry(); + throw err; + } +} + +export async function writeMarketplacesRegistry(filePath: string, reg: MarketplacesRegistry): Promise { + await atomicWriteJson(filePath, reg); +} + +// ── Installed plugins registry ─────────────────────────────────────── + +function emptyInstalledPluginsRegistry(): InstalledPluginsRegistry { + return { version: 2, plugins: {} }; +} + +export async function readInstalledPluginsRegistry(filePath: string): Promise { + try { + const content = await Bun.file(filePath).text(); + const data = tryParseJson(content); + if ( + !data || + typeof data !== "object" || + typeof data.version !== "number" || + !data.plugins || + typeof data.plugins !== "object" || + Array.isArray(data.plugins) + ) { + logger.warn("Invalid installed plugins registry, returning empty", { path: filePath }); + return emptyInstalledPluginsRegistry(); + } + // Accept any numeric version — forward compatible reads + return { ...data, version: 2 }; + } catch (err) { + if (isEnoent(err)) return emptyInstalledPluginsRegistry(); + throw err; + } +} + +export async function writeInstalledPluginsRegistry(filePath: string, reg: InstalledPluginsRegistry): Promise { + await atomicWriteJson(filePath, reg); +} + +// ── Marketplace CRUD ───────────────────────────────────────────────── +// Pure functions that transform registry state. Caller is responsible for +// reading, mutating, and writing back. + +export function addMarketplaceEntry(reg: MarketplacesRegistry, entry: MarketplaceRegistryEntry): MarketplacesRegistry { + if (reg.marketplaces.some(m => m.name === entry.name)) { + throw new Error(`Marketplace "${entry.name}" already exists`); + } + return { ...reg, marketplaces: [...reg.marketplaces, entry] }; +} + +export function removeMarketplaceEntry(reg: MarketplacesRegistry, name: string): MarketplacesRegistry { + const filtered = reg.marketplaces.filter(m => m.name !== name); + if (filtered.length === reg.marketplaces.length) { + throw new Error(`Marketplace "${name}" not found`); + } + return { ...reg, marketplaces: filtered }; +} + +export function getMarketplaceEntry(reg: MarketplacesRegistry, name: string): MarketplaceRegistryEntry | undefined { + return reg.marketplaces.find(m => m.name === name); +} + +// ── Installed plugin CRUD ──────────────────────────────────────────── + +export function addInstalledPlugin( + reg: InstalledPluginsRegistry, + id: string, + entry: InstalledPluginEntry, +): InstalledPluginsRegistry { + const existing = reg.plugins[id] ?? []; + return { + ...reg, + plugins: { ...reg.plugins, [id]: [...existing, entry] }, + }; +} + +export function removeInstalledPlugin(reg: InstalledPluginsRegistry, id: string): InstalledPluginsRegistry { + if (!(id in reg.plugins)) { + throw new Error(`Plugin "${id}" not found in registry`); + } + const { [id]: _, ...rest } = reg.plugins; + return { ...reg, plugins: rest }; +} + +export function getInstalledPlugin(reg: InstalledPluginsRegistry, id: string): InstalledPluginEntry[] | undefined { + return reg.plugins[id]; +} diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts new file mode 100644 index 000000000..675a90013 --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/source-resolver.ts @@ -0,0 +1,133 @@ +/** + * Source resolver for marketplace plugin entries. + * + * Resolves plugin sources to absolute local directory paths: + * - Relative string "./plugins/foo" → path within marketplace clone + * - { source: "url", url: "https://...git" } → git clone + * - { source: "github", repo: "owner/repo" } → git clone from GitHub + * - { source: "git-subdir", url: "...", path: "sub/dir" } → git clone + subdir + * - { source: "npm", ... } → not yet supported + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { isEnoent, pathIsWithin } from "@oh-my-pi/pi-utils"; + +import { cloneGitRepo } from "./fetcher"; +import type { MarketplaceCatalogMetadata, MarketplacePluginEntry, PluginSource } from "./types"; + +export interface ResolveContext { + /** Absolute path to the cloned/local marketplace directory. Required for relative sources. */ + marketplaceClonePath?: string; + /** Catalog metadata — used for `pluginRoot` prepend. */ + catalogMetadata?: MarketplaceCatalogMetadata; + /** Scratch directory for sources that require cloning or extraction. */ + tmpDir: string; +} + +/** + * Resolve a plugin source to an absolute local directory path. + * + * The resolved path is verified to exist on disk. + */ +export async function resolvePluginSource(entry: MarketplacePluginEntry, context: ResolveContext): Promise { + const { source } = entry; + + if (typeof source === "string") { + return resolveRelativeSource(source, context); + } + + return resolveObjectSource(source, context); +} + +// ── Relative string source ("./plugins/foo") ──────────────────────── + +async function resolveRelativeSource(source: string, context: ResolveContext): Promise { + if (!source.startsWith("./")) { + throw new Error(`Relative plugin source paths must start with "./" — got: "${source}"`); + } + + if (!context.marketplaceClonePath) { + throw new Error(`Cannot resolve relative source "${source}": marketplaceClonePath is required`); + } + + // If pluginRoot is set, prepend it to the path segment after "./" + const pluginRoot = context.catalogMetadata?.pluginRoot; + const relativePath = pluginRoot ? `./${path.join(pluginRoot, source.slice(2))}` : source; + + // Resolve against marketplace root (not the .claude-plugin/ catalog subdirectory) + const resolved = path.resolve(context.marketplaceClonePath, relativePath); + + if (!pathIsWithin(context.marketplaceClonePath, resolved)) { + throw new Error( + `Plugin source "${source}" resolves outside marketplace root ("${context.marketplaceClonePath}")`, + ); + } + + await verifyDirExists(resolved, `Plugin source directory does not exist: "${resolved}"`); + return resolved; +} + +// ── Object source variants ────────────────────────────────────────── + +async function resolveObjectSource(source: Exclude, context: ResolveContext): Promise { + switch (source.source) { + case "url": { + // { source: "url", url: "https://github.com/owner/repo.git" } + // Despite the name, this is typically a git clone URL + const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`); + await cloneGitRepo(source.url, targetDir, { ref: source.ref, sha: source.sha }); + return targetDir; + } + + case "github": { + // { source: "github", repo: "owner/repo" } + const url = `https://github.com/${source.repo}.git`; + const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`); + await cloneGitRepo(url, targetDir, { ref: source.ref, sha: source.sha }); + return targetDir; + } + + case "git-subdir": { + // { source: "git-subdir", url: "owner/repo" | "https://...", path: "plugins/foo" } + const url = + source.url.includes("://") || source.url.startsWith("git@") + ? source.url + : `https://github.com/${source.url}.git`; + const cloneDir = path.join(context.tmpDir, `plugin-repo-${crypto.randomUUID()}`); + await cloneGitRepo(url, cloneDir, { ref: source.ref, sha: source.sha }); + + const subdirPath = path.resolve(cloneDir, source.path); + if (!pathIsWithin(cloneDir, subdirPath)) { + await fs.rm(cloneDir, { recursive: true, force: true }); + throw new Error(`git-subdir path "${source.path}" escapes the cloned repository`); + } + await verifyDirExists(subdirPath, `git-subdir path "${source.path}" does not exist in cloned repository`); + return subdirPath; + } + + case "npm": + throw new Error("npm plugin sources are not yet supported. Use git-based sources instead."); + + default: + throw new Error(`Unknown plugin source type: "${(source as { source: string }).source}"`); + } +} + +// ── Helpers ───────────────────────────────────────────────────────── + +async function verifyDirExists(dirPath: string, errorMessage: string): Promise { + try { + const stat = await fs.stat(dirPath); + if (!stat.isDirectory()) { + throw new Error(errorMessage); + } + } catch (err) { + if (isEnoent(err)) { + throw new Error(errorMessage); + } + throw err; + } +} diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/types.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/types.ts new file mode 100644 index 000000000..4237ac88b --- /dev/null +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/types.ts @@ -0,0 +1,177 @@ +/** + * Marketplace plugin system types. + * + * Two registries: + * - MarketplacesRegistry: which marketplace catalogs the user has added (config) + * - InstalledPluginsRegistry: which plugins are installed (data, Claude Code-compatible) + * + * The installed registry MUST pass `parseClaudePluginsRegistry()` validation — + * it uses `version: 2` (numeric) and `plugins: Record`. + */ + +// ── Plugin ID helpers ──────────────────────────────────────────────── + +const NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; +const MAX_NAME_LENGTH = 64; +const MAX_ID_LENGTH = 128; + +/** Validate a plugin or marketplace name segment. */ +export function isValidNameSegment(s: string): boolean { + return s.length > 0 && s.length <= MAX_NAME_LENGTH && NAME_RE.test(s); +} + +/** Build canonical plugin ID: `"name@marketplace"`. Both segments are validated. */ +export function buildPluginId(name: string, marketplace: string): string { + if (!isValidNameSegment(name)) { + throw new Error(`Invalid plugin name: "${name}"`); + } + if (!isValidNameSegment(marketplace)) { + throw new Error(`Invalid marketplace name: "${marketplace}"`); + } + const id = `${name}@${marketplace}`; + if (id.length > MAX_ID_LENGTH) { + throw new Error(`Plugin ID exceeds ${MAX_ID_LENGTH} characters: "${id}"`); + } + return id; +} + +/** Parse `"name@marketplace"` → `{ name, marketplace }` or `null`. */ +export function parsePluginId(id: string): { name: string; marketplace: string } | null { + const atIndex = id.lastIndexOf("@"); + if (atIndex <= 0 || atIndex === id.length - 1) return null; + + const name = id.slice(0, atIndex); + const marketplace = id.slice(atIndex + 1); + + if (!isValidNameSegment(name) || !isValidNameSegment(marketplace)) return null; + + return { name, marketplace }; +} + +// ── Marketplace catalog (from marketplace.json in a marketplace repo) ─ + +export interface MarketplaceCatalogOwner { + name: string; + email?: string; +} + +export interface MarketplaceCatalogMetadata { + description?: string; + version?: string; + /** If set, prepended to relative plugin source paths. */ + pluginRoot?: string; +} + +export interface MarketplaceCatalog { + name: string; + owner: MarketplaceCatalogOwner; + metadata?: MarketplaceCatalogMetadata; + plugins: MarketplacePluginEntry[]; +} + +export interface MarketplacePluginAuthor { + name: string; + email?: string; +} + +export interface MarketplacePluginEntry { + name: string; + source: PluginSource; + description?: string; + version?: string; + author?: MarketplacePluginAuthor; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; + category?: string; + tags?: string[]; + strict?: boolean; + commands?: string | string[]; + agents?: string | string[]; + hooks?: string | Record; + mcpServers?: string | Record; + lspServers?: string | Record; +} + +// ── Plugin source variants ─────────────────────────────────────────── + +export type PluginSource = + | string // relative path "./plugins/foo" + | PluginSourceGitHub + | PluginSourceUrl + | PluginSourceGitSubdir + | PluginSourceNpm; + +export interface PluginSourceGitHub { + source: "github"; + repo: string; + ref?: string; + sha?: string; +} + +export interface PluginSourceUrl { + source: "url"; + url: string; + ref?: string; + sha?: string; +} + +export interface PluginSourceGitSubdir { + source: "git-subdir"; + url: string; + path: string; + ref?: string; + sha?: string; +} + +export interface PluginSourceNpm { + source: "npm"; + package: string; + version?: string; + registry?: string; +} + +// ── Marketplaces registry (stored in /marketplaces.json) ─ + +export interface MarketplacesRegistry { + version: 1; + marketplaces: MarketplaceRegistryEntry[]; +} + +export type MarketplaceSourceType = "github" | "git" | "url" | "local"; + +export interface MarketplaceRegistryEntry { + name: string; + sourceType: MarketplaceSourceType; + sourceUri: string; + catalogPath: string; + addedAt: string; + updatedAt: string; +} + +// ── Installed plugins registry ─────────────────────────────────────── +// MUST match ClaudePluginsRegistry shape for parseClaudePluginsRegistry() +// compatibility: `version: number`, `plugins: Record`. + +export interface InstalledPluginsRegistry { + /** MUST be 2 — parseClaudePluginsRegistry rejects non-numeric version. */ + version: 2; + plugins: Record; +} + +export interface InstalledPluginEntry { + /** v1 is user-only — always "user". */ + scope: "user"; + /** Absolute path to cached plugin directory. */ + installPath: string; + version: string; + /** ISO 8601 date string. */ + installedAt: string; + /** ISO 8601 date string. */ + lastUpdated: string; + /** For git-sourced plugins. */ + gitCommitSha?: string; + /** OMP extension — not in Claude Code's type. CLI/UI concern only in v1. */ + enabled?: boolean; +} diff --git a/packages/coding-agent/src/internal-urls/index.ts b/packages/coding-agent/src/internal-urls/index.ts index fb1dbbcb3..f20354538 100644 --- a/packages/coding-agent/src/internal-urls/index.ts +++ b/packages/coding-agent/src/internal-urls/index.ts @@ -27,6 +27,7 @@ export * from "./json-query"; export * from "./local-protocol"; export * from "./mcp-protocol"; export * from "./memory-protocol"; +export * from "./parse"; export * from "./pi-protocol"; export * from "./router"; export * from "./rule-protocol"; diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index 966abe104..bf47a6f2e 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; +import { parseInternalUrl } from "./parse"; import { validateRelativePath } from "./skill-protocol"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; @@ -11,25 +12,7 @@ export interface LocalProtocolOptions { } function parseLocalUrl(input: string): InternalUrl { - let parsed: URL; - try { - parsed = new URL(input); - } catch { - throw new Error(`Invalid URL: ${input}`); - } - - const hostMatch = input.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i); - let rawHost = hostMatch ? hostMatch[2] : parsed.hostname; - try { - rawHost = decodeURIComponent(rawHost); - } catch { - // Leave rawHost as-is if decoding fails. - } - (parsed as InternalUrl).rawHost = rawHost; - - const pathMatch = input.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i); - (parsed as InternalUrl).rawPathname = pathMatch?.[1] ?? parsed.pathname; - return parsed as InternalUrl; + return parseInternalUrl(input); } function ensureWithinRoot(targetPath: string, rootPath: string): void { diff --git a/packages/coding-agent/src/internal-urls/parse.ts b/packages/coding-agent/src/internal-urls/parse.ts new file mode 100644 index 000000000..3153412f4 --- /dev/null +++ b/packages/coding-agent/src/internal-urls/parse.ts @@ -0,0 +1,72 @@ +/** + * Internal URL parser that handles colons in the host segment. + * + * Standard `new URL()` interprets colons as port separators, which breaks + * namespaced internal URLs like `skill://plugin:name`. This parser extracts + * components via regex first, then falls back to a minimal URL-like object + * when `new URL()` fails. + * + * All code that parses internal URLs (router, protocol handlers, tools) + * MUST use this function instead of calling `new URL()` directly. + */ +import type { InternalUrl } from "./types"; + +const SCHEME_HOST_RE = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i; +const PATHNAME_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i; + +/** + * Parse an internal URL into an InternalUrl. + * + * Handles URLs where `new URL()` would fail (e.g., `skill://plugin:name` + * where the colon is not a port separator). + */ +export function parseInternalUrl(input: string): InternalUrl { + const hostMatch = input.match(SCHEME_HOST_RE); + const pathMatch = input.match(PATHNAME_RE); + + let parsed: URL; + try { + parsed = new URL(input); + } catch { + // URL parse failed — build a minimal URL-like object from regex matches. + if (!hostMatch) { + throw new Error(`Invalid URL: ${input}`); + } + // Extract search and hash from the raw input before constructing the object. + const hashIdx = input.indexOf("#"); + const hash = hashIdx !== -1 ? input.slice(hashIdx) : ""; + const withoutHash = hashIdx !== -1 ? input.slice(0, hashIdx) : input; + const queryIdx = withoutHash.indexOf("?"); + const search = queryIdx !== -1 ? withoutHash.slice(queryIdx) : ""; + const queryString = search.slice(1); // strip leading ? + + // Strip search/hash from pathname captured by regex. + let rawPathname = pathMatch?.[1] ?? ""; + if (queryIdx !== -1 && rawPathname.includes("?")) { + rawPathname = rawPathname.slice(0, rawPathname.indexOf("?")); + } + + parsed = { + protocol: `${hostMatch[1]}:`, + hostname: hostMatch[2] ?? "", + host: hostMatch[2] ?? "", + pathname: rawPathname, + href: input, + search, + hash, + searchParams: new URLSearchParams(queryString), + } as unknown as URL; + } + + let rawHost = hostMatch ? hostMatch[2] : parsed.hostname; + try { + rawHost = decodeURIComponent(rawHost); + } catch { + // Leave rawHost as-is if decoding fails. + } + + const result = parsed as InternalUrl; + result.rawHost = rawHost; + result.rawPathname = pathMatch?.[1] ?? parsed.pathname; + return result; +} diff --git a/packages/coding-agent/src/internal-urls/router.ts b/packages/coding-agent/src/internal-urls/router.ts index 7e06b775a..0779cfee6 100644 --- a/packages/coding-agent/src/internal-urls/router.ts +++ b/packages/coding-agent/src/internal-urls/router.ts @@ -1,6 +1,7 @@ /** * Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://). */ +import { parseInternalUrl } from "./parse"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; /** @@ -37,24 +38,7 @@ export class InternalUrlRouter { * @throws Error if scheme is not registered or resolution fails */ async resolve(input: string): Promise { - let parsed: URL; - try { - parsed = new URL(input); - } catch { - throw new Error(`Invalid URL: ${input}`); - } - - const hostMatch = input.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i); - let rawHost = hostMatch ? hostMatch[2] : parsed.hostname; - try { - rawHost = decodeURIComponent(rawHost); - } catch { - // Leave rawHost as-is if decoding fails. - } - (parsed as InternalUrl).rawHost = rawHost; - const pathMatch = input.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i); - (parsed as InternalUrl).rawPathname = pathMatch?.[1] ?? parsed.pathname; - + const parsed = parseInternalUrl(input); const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); const handler = this.#handlers.get(scheme); diff --git a/packages/coding-agent/src/lsp/config.ts b/packages/coding-agent/src/lsp/config.ts index 0b8981b76..ad6bd5b81 100644 --- a/packages/coding-agent/src/lsp/config.ts +++ b/packages/coding-agent/src/lsp/config.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { isRecord, logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { getConfigDirPaths } from "../config"; +import { getPreloadedPluginRoots } from "../discovery/helpers"; import { BiomeClient } from "./clients/biome-client"; import { SwiftLintClient } from "./clients/swiftlint-client"; import DEFAULTS from "./defaults.json" with { type: "json" }; @@ -248,6 +249,14 @@ function getConfigPaths(cwd: string): string[] { } } + // Plugin LSP configs (from marketplace/--plugin-dir roots) + const pluginRoots = getPreloadedPluginRoots(); + for (const root of pluginRoots) { + for (const filename of ["lsp.json", ".lsp.json"]) { + paths.push(path.join(root.path, filename)); + } + } + // User home root files (lowest priority fallback) for (const filename of filenames) { paths.push(path.join(os.homedir(), filename)); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 86c1e7379..4c276f770 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -23,6 +23,7 @@ import { ModelRegistry, ModelsConfigFile } from "./config/model-registry"; import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver"; import { Settings, settings } from "./config/settings"; import { initializeWithSettings } from "./discovery"; +import { injectPluginDirRoots, preloadPluginRoots } from "./discovery/helpers"; import { exportFromFile } from "./export/html"; import type { ExtensionUIContext } from "./extensibility/extensions/types"; import type { MCPManager } from "./mcp"; @@ -639,6 +640,14 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise 0) { + await logger.timeAsync("injectPluginDirRoots", () => injectPluginDirRoots(home, parsedArgs.pluginDirs!)); + } else { + await logger.timeAsync("preloadPluginRoots", () => preloadPluginRoots(home)); + } + const { options: sessionOptions } = await logger.timeAsync("buildSessionOptions", () => buildSessionOptions(parsedArgs, scopedModels, sessionManager, modelRegistry), ); diff --git a/packages/coding-agent/src/modes/components/plugin-selector.ts b/packages/coding-agent/src/modes/components/plugin-selector.ts new file mode 100644 index 000000000..f764d47f7 --- /dev/null +++ b/packages/coding-agent/src/modes/components/plugin-selector.ts @@ -0,0 +1,86 @@ +/** + * Interactive marketplace plugin selector. + * + * Shows available plugins from all configured marketplaces in a SelectList. + * Selecting a plugin triggers installation. Esc cancels. + */ +import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui"; +import { getSelectListTheme } from "../theme/theme"; +import { DynamicBorder } from "./dynamic-border"; + +export interface PluginSelectorCallbacks { + onSelect: (pluginName: string, marketplace: string) => void; + onCancel: () => void; +} + +export interface PluginItem { + plugin: { name: string; version?: string; description?: string }; + marketplace: string; +} + +export class PluginSelectorComponent extends Container { + #selectList: SelectList; + + constructor( + marketplaceCount: number, + plugins: PluginItem[], + installedIds: Set, + callbacks: PluginSelectorCallbacks, + ) { + super(); + + const items: SelectItem[] = plugins.map(({ plugin, marketplace }) => { + const id = `${plugin.name}@${marketplace}`; + const installed = installedIds.has(id); + const version = plugin.version ? `@${plugin.version}` : ""; + const status = installed ? " [installed]" : ""; + + return { + value: id, + label: `${plugin.name}${version}${status}`, + description: plugin.description, + hint: marketplace, + }; + }); + + if (items.length === 0) { + items.push({ + value: "__empty__", + label: "No plugins available", + description: + marketplaceCount === 0 + ? "Add a marketplace first: /marketplace add " + : "Configured marketplaces have no plugins", + }); + } + + this.addChild(new DynamicBorder()); + + this.#selectList = new SelectList(items, Math.min(items.length, 20), getSelectListTheme()); + + this.#selectList.onSelect = item => { + if (item.value === "__empty__") return; + const [name, marketplace] = splitPluginId(item.value); + if (name && marketplace) { + callbacks.onSelect(name, marketplace); + } + }; + + this.#selectList.onCancel = () => { + callbacks.onCancel(); + }; + + this.addChild(this.#selectList); + this.addChild(new DynamicBorder()); + } + + getSelectList(): SelectList { + return this.#selectList; + } +} + +function splitPluginId(id: string): [string, string] | [null, null] { + const atIdx = id.lastIndexOf("@"); + if (atIdx <= 0) return [null, null]; + return [id.slice(0, atIdx), id.slice(atIdx + 1)]; +} diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 9c949cad3..ea3d3810f 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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 { + 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(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 66acf5ebc..91c3e01a4 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1243,6 +1243,10 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController.showModelSelector(options); } + showPluginSelector(mode?: "install" | "uninstall"): void { + void this.#selectorController.showPluginSelector(mode); + } + showUserMessageSelector(): void { this.#selectorController.showUserMessageSelector(); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 596434e50..d04407d85 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -198,6 +198,7 @@ export interface InteractiveModeContext { showExtensionsDashboard(): void; showAgentsDashboard(): void; showModelSelector(options?: { temporaryOnly?: boolean }): void; + showPluginSelector(mode?: "install" | "uninstall"): void; showUserMessageSelector(): void; showTreeSelector(): void; showSessionSelector(): void; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index e8cbef136..d07de51cf 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1,6 +1,20 @@ +import * as os from "node:os"; +import * as path from "node:path"; + import { getOAuthProviders } from "@oh-my-pi/pi-ai"; +import { getConfigDirName } from "@oh-my-pi/pi-utils"; +import { invalidate as invalidateFsCache } from "../capability/fs"; import type { SettingPath, SettingValue } from "../config/settings"; import { settings } from "../config/settings"; +import { clearClaudePluginRootsCache } from "../discovery/helpers.js"; +import { PluginManager } from "../extensibility/plugins"; +import { + getInstalledPluginsRegistryPath, + getMarketplacesCacheDir, + getMarketplacesRegistryPath, + getPluginsCacheDir, + MarketplaceManager, +} from "../extensibility/plugins/marketplace"; import type { InteractiveModeContext } from "../modes/types"; function refreshStatusLine(ctx: InteractiveModeContext): void { @@ -543,6 +557,244 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ description: "Exit the application", handle: shutdownHandler, }, + { + name: "marketplace", + description: "Manage marketplace plugin sources and installed plugins", + subcommands: [ + { name: "add", description: "Add a marketplace source", usage: "" }, + { name: "remove", description: "Remove a marketplace source", usage: "" }, + { name: "update", description: "Update marketplace catalog(s)", usage: "[name]" }, + { name: "list", description: "List configured marketplaces" }, + { name: "discover", description: "Browse available plugins", usage: "[marketplace]" }, + { + name: "install", + description: "Install a plugin (interactive browser if no args)", + usage: "[--force] [name@marketplace]", + }, + { name: "uninstall", description: "Uninstall a plugin (selector if no args)", usage: "[name@marketplace]" }, + { name: "installed", description: "List installed marketplace plugins" }, + ], + allowArgs: true, + handle: async (command, runtime) => { + runtime.ctx.editor.setText(""); + const args = command.args.trim().split(/\s+/); + const sub = args[0] || "install"; + const rest = args.slice(1).join(" ").trim(); + + // /marketplace (no args) or /marketplace install (no args) → interactive browser + if ((sub === "install" && !rest) || (!args[0] && !command.args.trim())) { + runtime.ctx.showPluginSelector("install"); + return; + } + + 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(); + }, + }); + + try { + switch (sub) { + case "add": { + if (!rest) { + runtime.ctx.showStatus("Usage: /marketplace add "); + return; + } + const entry = await mgr.addMarketplace(rest); + runtime.ctx.showStatus(`Added marketplace: ${entry.name}`); + break; + } + case "remove": + case "rm": { + if (!rest) { + runtime.ctx.showStatus("Usage: /marketplace remove "); + return; + } + await mgr.removeMarketplace(rest); + runtime.ctx.showStatus(`Removed marketplace: ${rest}`); + break; + } + case "update": { + if (rest) { + await mgr.updateMarketplace(rest); + runtime.ctx.showStatus(`Updated marketplace: ${rest}`); + } else { + const results = await mgr.updateAllMarketplaces(); + runtime.ctx.showStatus(`Updated ${results.length} marketplace(s)`); + } + break; + } + case "discover": { + const plugins = await mgr.listAvailablePlugins(rest || undefined); + if (plugins.length === 0) { + runtime.ctx.showStatus("No plugins available"); + } else { + const lines = plugins.map( + p => + ` ${p.name}${p.version ? `@${p.version}` : ""}${p.description ? ` - ${p.description}` : ""}`, + ); + runtime.ctx.showStatus(`Available plugins:\n${lines.join("\n")}`); + } + break; + } + case "install": { + // Parse: /marketplace install [--force] name@marketplace + const force = rest.startsWith("--force "); + const installSpec = force ? rest.slice("--force ".length).trim() : rest; + if (!installSpec?.includes("@")) { + runtime.ctx.showStatus("Usage: /marketplace install [--force] "); + return; + } + const atIdx = installSpec.lastIndexOf("@"); + const name = installSpec.slice(0, atIdx); + const marketplace = installSpec.slice(atIdx + 1); + await mgr.installPlugin(name, marketplace, { force }); + runtime.ctx.showStatus(`Installed ${name} from ${marketplace}`); + break; + } + case "uninstall": { + if (!rest) { + // No args → open interactive uninstall selector + runtime.ctx.showPluginSelector("uninstall"); + return; + } + await mgr.uninstallPlugin(rest); + runtime.ctx.showStatus(`Uninstalled ${rest}`); + break; + } + case "installed": { + const installed = await mgr.listInstalledPlugins(); + if (installed.length === 0) { + runtime.ctx.showStatus("No marketplace plugins installed"); + } else { + const lines = installed.map(p => ` ${p.id} (${p.entries.length} entry)`); + runtime.ctx.showStatus(`Installed plugins:\n${lines.join("\n")}`); + } + break; + } + default: { + // Default to list marketplaces + const marketplaces = await mgr.listMarketplaces(); + if (marketplaces.length === 0) { + runtime.ctx.showStatus("No marketplaces configured. Use /marketplace add "); + } else { + const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); + runtime.ctx.showStatus(`Marketplaces:\n${lines.join("\n")}`); + } + break; + } + } + } catch (err) { + runtime.ctx.showStatus(`Marketplace error: ${err}`); + } + }, + }, + { + name: "plugins", + description: "View and manage installed plugins", + subcommands: [ + { name: "list", description: "List all installed plugins (npm + marketplace)" }, + { name: "enable", description: "Enable a marketplace plugin", usage: "" }, + { name: "disable", description: "Disable a marketplace plugin", usage: "" }, + ], + allowArgs: true, + handle: async (command, runtime) => { + runtime.ctx.editor.setText(""); + const args = command.args.trim().split(/\s+/); + const sub = args[0] || "list"; + const rest = args.slice(1).join(" ").trim(); + + try { + 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(); + }, + }); + + switch (sub) { + case "enable": { + if (!rest) { + runtime.ctx.showStatus("Usage: /plugins enable "); + return; + } + await mgr.setPluginEnabled(rest, true); + runtime.ctx.showStatus(`Enabled ${rest}`); + break; + } + case "disable": { + if (!rest) { + runtime.ctx.showStatus("Usage: /plugins disable "); + return; + } + await mgr.setPluginEnabled(rest, false); + runtime.ctx.showStatus(`Disabled ${rest}`); + break; + } + default: { + const lines: string[] = []; + + const npm = new PluginManager(); + const npmPlugins = await npm.list(); + if (npmPlugins.length > 0) { + lines.push("npm plugins:"); + for (const p of npmPlugins) { + const status = p.enabled === false ? " (disabled)" : ""; + lines.push(` ${p.name}@${p.version}${status}`); + } + } + + const mktPlugins = await mgr.listInstalledPlugins(); + if (mktPlugins.length > 0) { + if (lines.length > 0) lines.push(""); + lines.push("marketplace plugins:"); + for (const p of mktPlugins) { + const entry = p.entries[0]; + const status = entry?.enabled === false ? " (disabled)" : ""; + lines.push(` ${p.id} v${entry?.version ?? "?"}${status}`); + } + } + + if (lines.length === 0) { + runtime.ctx.showStatus("No plugins installed"); + } else { + runtime.ctx.showStatus(lines.join("\n")); + } + break; + } + } + } catch (err) { + runtime.ctx.showStatus(`Plugin error: ${err}`); + } + }, + }, + { + name: "reload-plugins", + description: "Reload all plugins (skills, commands, hooks, tools, agents, MCP)", + handle: (_command, runtime) => { + // Invalidate the fs content cache for both registry files so + // listClaudePluginRoots re-reads from disk on next access. + const home = os.homedir(); + invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json")); + invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json")); + clearClaudePluginRootsCache(); + runtime.ctx.showStatus("Plugin cache cleared. Plugins will reload on next capability refresh."); + runtime.ctx.editor.setText(""); + }, + }, { name: "quit", description: "Quit the application", diff --git a/packages/coding-agent/src/tools/bash-skill-urls.ts b/packages/coding-agent/src/tools/bash-skill-urls.ts index 8c3164aee..bf98babea 100644 --- a/packages/coding-agent/src/tools/bash-skill-urls.ts +++ b/packages/coding-agent/src/tools/bash-skill-urls.ts @@ -40,19 +40,29 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st throw new ToolError(`Invalid skill:// URL: ${url}`); } - const skillName = parsed[1]; - if (!skillName) { + let rawSkillSegment = parsed[1]; + if (!rawSkillSegment) { throw new ToolError(`skill:// URL requires a skill name: ${url}`); } + // Decode percent-encoded colons (%3A) used for namespaced skill names + try { + rawSkillSegment = decodeURIComponent(rawSkillSegment); + } catch { + // Leave as-is if decoding fails + } - const rawPath = parsed[2] ?? ""; - const skill = skills.find(s => s.name === skillName); + // Resolve skill name by longest-prefix match against registered skills. + // This handles namespaced skills ("plugin:skill") where the URI may also + // carry a colon-delimited suffix (e.g., ":1-5" line range). + const { skill, suffix } = matchSkillName(rawSkillSegment, skills); if (!skill) { const available = skills.map(s => s.name); const availableStr = available.length > 0 ? available.join(", ") : "none"; - throw new ToolError(`Unknown skill: ${skillName}. Available: ${availableStr}`); + throw new ToolError(`Unknown skill: ${rawSkillSegment}. Available: ${availableStr}`); } + // Combine any colon suffix (line range like ":1-5") with the path segment + const rawPath = (parsed[2] ?? "") + (suffix ? `/${suffix}` : ""); const hasRelativePath = rawPath !== "" && rawPath !== "/"; if (!hasRelativePath) { @@ -82,6 +92,39 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st return resolvedPath; } +/** + * Match a raw skill segment against registered skills using longest-prefix match. + * Handles colons in both skill names (namespacing) and suffixes (line ranges). + * + * For "superpowers:brainstorming:1-5" with skill "superpowers:brainstorming": + * -> skill = superpowers:brainstorming, suffix = "1-5" + * For "brainstorming" with skill "brainstorming": + * -> skill = brainstorming, suffix = undefined + */ +function matchSkillName( + rawSegment: string, + skills: readonly Skill[], +): { skill: Skill | undefined; suffix: string | undefined } { + // Exact match first (most common case) + const exact = skills.find(s => s.name === rawSegment); + if (exact) return { skill: exact, suffix: undefined }; + + // Try stripping colon-delimited suffixes from the right + let candidate = rawSegment; + while (true) { + const lastColon = candidate.lastIndexOf(":"); + if (lastColon <= 0) break; + candidate = candidate.slice(0, lastColon); + const match = skills.find(s => s.name === candidate); + if (match) { + const suffix = rawSegment.slice(lastColon + 1); + return { skill: match, suffix }; + } + } + + return { skill: undefined, suffix: undefined }; +} + function extractScheme(url: string): SupportedInternalScheme | undefined { const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url); if (!match) return undefined; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 060497abc..39179eec7 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -9,6 +9,8 @@ import { getRemoteDir, ptree, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import { parseInternalUrl } from "../internal-urls/parse"; +import type { InternalUrl } from "../internal-urls/types"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import { computeLineHash } from "../patch/hashline"; import readDescription from "../prompts/tools/read.md" with { type: "text" }; @@ -686,18 +688,22 @@ export class ReadTool implements AgentTool { async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise> { const internalRouter = this.session.internalRouter!; - // Check if URL has query extraction (agent:// only) - let parsed: URL; + // Check if URL has query extraction (agent:// only). + // Use parseInternalUrl which handles colons in host (namespaced skills). + let parsed: InternalUrl; try { - parsed = new URL(url); - } catch { - throw new ToolError(`Invalid URL: ${url}`); + parsed = parseInternalUrl(url); + } catch (e) { + throw new ToolError(e instanceof Error ? e.message : String(e)); } const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); - const hasPathExtraction = parsed.pathname && parsed.pathname !== "/" && parsed.pathname !== ""; - const queryParam = parsed.searchParams.get("q"); - const hasQueryExtraction = scheme === "agent" && queryParam !== null && queryParam !== ""; - const hasExtraction = scheme === "agent" && (hasPathExtraction || hasQueryExtraction); + let hasExtraction = false; + if (scheme === "agent") { + const hasPathExtraction = parsed.pathname && parsed.pathname !== "/" && parsed.pathname !== ""; + const queryParam = parsed.searchParams.get("q"); + const hasQueryExtraction = queryParam !== null && queryParam !== ""; + hasExtraction = hasPathExtraction || hasQueryExtraction; + } // Reject offset/limit with query extraction if (hasExtraction && (offset !== undefined || limit !== undefined)) { diff --git a/packages/coding-agent/test/marketplace/cache.test.ts b/packages/coding-agent/test/marketplace/cache.test.ts new file mode 100644 index 000000000..b68559e4e --- /dev/null +++ b/packages/coding-agent/test/marketplace/cache.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + cachePlugin, + cleanOrphanedCache, + getCachedPluginPath, + isCached, + isValidVersionForCache, + removeCachedPlugin, +} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +async function mkSourcePlugin(baseDir: string, name: string): Promise { + const pluginDir = path.join(baseDir, name); + await fsp.mkdir(pluginDir, { recursive: true }); + await fsp.writeFile(path.join(pluginDir, "plugin.json"), JSON.stringify({ name })); + return pluginDir; +} + +// ── isValidVersionForCache ─────────────────────────────────────────────────── + +describe("isValidVersionForCache", () => { + it("accepts common valid version strings", () => { + expect(isValidVersionForCache("1.0.0")).toBe(true); + expect(isValidVersionForCache("v2.0.0-beta.1")).toBe(true); + expect(isValidVersionForCache("abc123")).toBe(true); + expect(isValidVersionForCache("1.0.0+build.42")).toBe(true); + expect(isValidVersionForCache("a")).toBe(true); + }); + + it("rejects empty string", () => { + expect(isValidVersionForCache("")).toBe(false); + }); + + it("rejects double-dot (path traversal attempt)", () => { + expect(isValidVersionForCache("..")).toBe(false); + }); + + it("rejects forward slash", () => { + expect(isValidVersionForCache("1.0/0")).toBe(false); + }); + + it("rejects backslash", () => { + expect(isValidVersionForCache("1.0\\0")).toBe(false); + }); + + it("rejects spaces", () => { + expect(isValidVersionForCache("1 0")).toBe(false); + }); + + it("rejects strings exceeding 128 characters", () => { + expect(isValidVersionForCache("a".repeat(129))).toBe(false); + expect(isValidVersionForCache("a".repeat(128))).toBe(true); + }); +}); + +// ── getCachedPluginPath ────────────────────────────────────────────────────── + +describe("getCachedPluginPath", () => { + it("returns a deterministic path with ___ separators", () => { + const p = getCachedPluginPath("/cache", "my-market", "my-plugin", "1.0.0"); + expect(p).toBe("/cache/my-market___my-plugin___1.0.0"); + }); + + it("is independent of cacheDir content — pure path construction", () => { + const p1 = getCachedPluginPath("/a", "m", "p", "1"); + const p2 = getCachedPluginPath("/b", "m", "p", "1"); + expect(path.basename(p1)).toBe(path.basename(p2)); + }); + + it("throws on invalid marketplace name (uppercase)", () => { + expect(() => getCachedPluginPath("/cache", "My-Market", "plugin", "1.0.0")).toThrow(/Invalid marketplace name/); + }); + + it("throws on invalid marketplace name (space)", () => { + expect(() => getCachedPluginPath("/cache", "bad market", "plugin", "1.0.0")).toThrow(); + }); + + it("throws on invalid plugin name (uppercase)", () => { + expect(() => getCachedPluginPath("/cache", "market", "My-Plugin", "1.0.0")).toThrow(/Invalid plugin name/); + }); + + it("throws on invalid version containing ..", () => { + expect(() => getCachedPluginPath("/cache", "market", "plugin", "..")).toThrow(/Invalid version/); + }); + + it("throws on invalid version containing /", () => { + expect(() => getCachedPluginPath("/cache", "market", "plugin", "1.0/0")).toThrow(); + }); + + it("throws on invalid version with leading dot rejected by segment validator", () => { + // ".1.0.0" passes VERSION_RE but isValidNameSegment rejects leading dot — + // version validation uses VERSION_RE, not isValidNameSegment + // ".1.0.0" starts with dot — VERSION_RE allows it, but name segment does not apply to version + // Actually ".1.0.0" should be valid per VERSION_RE: only alpha/digit/._+- + // Let's verify the boundary: space is rejected + expect(() => getCachedPluginPath("/cache", "market", "plugin", "1 0")).toThrow(); + }); +}); + +// ── cachePlugin / isCached / removeCachedPlugin ────────────────────────────── + +describe("cachePlugin, isCached, removeCachedPlugin", () => { + let tmpDir: string; + let cacheDir: string; + let sourceDir: string; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-cache-test-")); + cacheDir = path.join(tmpDir, "cache"); + sourceDir = path.join(tmpDir, "sources"); + await fsp.mkdir(sourceDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("isCached returns false before caching", async () => { + await mkSourcePlugin(sourceDir, "my-plugin"); + expect(isCached(cacheDir, "my-market", "my-plugin", "1.0.0")).toBe(false); + }); + + it("cachePlugin copies the directory and returns absolute cache path", async () => { + const sourcePath = await mkSourcePlugin(sourceDir, "my-plugin"); + const cached = await cachePlugin(sourcePath, cacheDir, "my-market", "my-plugin", "1.0.0"); + + expect(cached).toBe(path.join(cacheDir, "my-market___my-plugin___1.0.0")); + expect(fs.existsSync(cached)).toBe(true); + expect(fs.existsSync(path.join(cached, "plugin.json"))).toBe(true); + }); + + it("isCached returns true after cachePlugin", async () => { + const sourcePath = await mkSourcePlugin(sourceDir, "my-plugin"); + await cachePlugin(sourcePath, cacheDir, "my-market", "my-plugin", "1.0.0"); + expect(isCached(cacheDir, "my-market", "my-plugin", "1.0.0")).toBe(true); + }); + + it("cachePlugin is idempotent — re-caches over existing entry", async () => { + const sourcePath = await mkSourcePlugin(sourceDir, "my-plugin"); + + // First cache + await cachePlugin(sourcePath, cacheDir, "my-market", "my-plugin", "1.0.0"); + // Add a stale file to simulate a dirty cache entry + const staleFile = path.join(cacheDir, "my-market___my-plugin___1.0.0", "stale.txt"); + await fsp.writeFile(staleFile, "stale"); + + // Re-cache must remove the stale file + await cachePlugin(sourcePath, cacheDir, "my-market", "my-plugin", "1.0.0"); + expect(fs.existsSync(staleFile)).toBe(false); + expect(fs.existsSync(path.join(cacheDir, "my-market___my-plugin___1.0.0", "plugin.json"))).toBe(true); + }); + + it("removeCachedPlugin deletes the directory", async () => { + const sourcePath = await mkSourcePlugin(sourceDir, "my-plugin"); + await cachePlugin(sourcePath, cacheDir, "my-market", "my-plugin", "1.0.0"); + + await removeCachedPlugin(cacheDir, "my-market", "my-plugin", "1.0.0"); + expect(isCached(cacheDir, "my-market", "my-plugin", "1.0.0")).toBe(false); + }); + + it("removeCachedPlugin is a no-op when entry does not exist", async () => { + // Should not throw + await expect(removeCachedPlugin(cacheDir, "my-market", "my-plugin", "1.0.0")).resolves.toBeUndefined(); + }); +}); + +// ── cleanOrphanedCache ─────────────────────────────────────────────────────── + +describe("cleanOrphanedCache", () => { + let tmpDir: string; + let cacheDir: string; + let sourceDir: string; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-orphan-test-")); + cacheDir = path.join(tmpDir, "cache"); + sourceDir = path.join(tmpDir, "sources"); + await fsp.mkdir(sourceDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns { removed: 0 } when cacheDir does not exist", async () => { + const result = await cleanOrphanedCache(cacheDir, new Set()); + expect(result).toEqual({ removed: 0 }); + }); + + it("removes entries not in installedPaths", async () => { + const srcA = await mkSourcePlugin(sourceDir, "plugin-a"); + const srcB = await mkSourcePlugin(sourceDir, "plugin-b"); + + const pathA = await cachePlugin(srcA, cacheDir, "mkt", "plugin-a", "1.0.0"); + await cachePlugin(srcB, cacheDir, "mkt", "plugin-b", "1.0.0"); + + // Only keep plugin-a; plugin-b is orphaned + const result = await cleanOrphanedCache(cacheDir, new Set([pathA])); + expect(result).toEqual({ removed: 1 }); + expect(fs.existsSync(pathA)).toBe(true); + expect(isCached(cacheDir, "mkt", "plugin-b", "1.0.0")).toBe(false); + }); + + it("preserves all entries when all are in installedPaths", async () => { + const srcA = await mkSourcePlugin(sourceDir, "plugin-a"); + const pathA = await cachePlugin(srcA, cacheDir, "mkt", "plugin-a", "1.0.0"); + + const result = await cleanOrphanedCache(cacheDir, new Set([pathA])); + expect(result).toEqual({ removed: 0 }); + expect(fs.existsSync(pathA)).toBe(true); + }); + + it("removes all entries when installedPaths is empty", async () => { + const srcA = await mkSourcePlugin(sourceDir, "plugin-a"); + const srcB = await mkSourcePlugin(sourceDir, "plugin-b"); + + await cachePlugin(srcA, cacheDir, "mkt", "plugin-a", "1.0.0"); + await cachePlugin(srcB, cacheDir, "mkt", "plugin-b", "2.0.0"); + + const result = await cleanOrphanedCache(cacheDir, new Set()); + expect(result).toEqual({ removed: 2 }); + expect(fs.readdirSync(cacheDir)).toHaveLength(0); + }); +}); diff --git a/packages/coding-agent/test/marketplace/cli.test.ts b/packages/coding-agent/test/marketplace/cli.test.ts new file mode 100644 index 000000000..04549070c --- /dev/null +++ b/packages/coding-agent/test/marketplace/cli.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; + +// Import from the zero-dep classify module — plugin-cli.ts transitively loads native addons. +import { classifyInstallTarget } from "../../src/cli/classify-install-target"; + +const KNOWN = new Set(["my-marketplace"]); + +describe("classifyInstallTarget", () => { + it("classifies plugin@marketplace as marketplace when marketplace is registered", () => { + const result = classifyInstallTarget("hello@my-marketplace", KNOWN); + expect(result).toEqual({ type: "marketplace", name: "hello", marketplace: "my-marketplace" }); + }); + + it("classifies scoped @scope/pkg as npm (rule 1: starts with @)", () => { + const result = classifyInstallTarget("@scope/pkg", KNOWN); + expect(result).toEqual({ type: "npm", spec: "@scope/pkg" }); + }); + + it("classifies @scope/pkg@1.0.0 as npm (starts with @, rule 1 wins)", () => { + const result = classifyInstallTarget("@scope/pkg@1.0.0", KNOWN); + expect(result).toEqual({ type: "npm", spec: "@scope/pkg@1.0.0" }); + }); + + it("classifies bare name with no @ as npm", () => { + const result = classifyInstallTarget("bare-name", KNOWN); + expect(result).toEqual({ type: "npm", spec: "bare-name" }); + }); + + it("classifies pkg@version as npm when version is not a known marketplace", () => { + const result = classifyInstallTarget("pkg@1.2.3", KNOWN); + expect(result).toEqual({ type: "npm", spec: "pkg@1.2.3" }); + }); + + it("classifies pkg@marketplace as npm when marketplace is not registered", () => { + const result = classifyInstallTarget("hello@my-marketplace", new Set()); + expect(result).toEqual({ type: "npm", spec: "hello@my-marketplace" }); + }); + + it("scoped @scope/pkg@marketplace is still npm — rule 1 wins", () => { + // Even though this starts with @, the rule only triggers when spec.startsWith("@") + // but @scope/pkg@my-marketplace DOES start with @ so rule 1 applies -> npm. + // This confirms rule 1 is absolute for scoped packages. + const result = classifyInstallTarget("@scope/pkg@my-marketplace", KNOWN); + expect(result).toEqual({ type: "npm", spec: "@scope/pkg@my-marketplace" }); + }); + + it("splits on last @ for non-scoped multi-@ spec", () => { + // e.g. "some-pkg@my-marketplace" where my-marketplace is known + const result = classifyInstallTarget("some-pkg@my-marketplace", KNOWN); + expect(result).toEqual({ type: "marketplace", name: "some-pkg", marketplace: "my-marketplace" }); + }); +}); diff --git a/packages/coding-agent/test/marketplace/dev-ergonomics.test.ts b/packages/coding-agent/test/marketplace/dev-ergonomics.test.ts new file mode 100644 index 000000000..eca47492a --- /dev/null +++ b/packages/coding-agent/test/marketplace/dev-ergonomics.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; + +// Cannot import parseArgs from cli/args.ts (transitively loads @oh-my-pi/pi-natives +// via ../tools). Instead, test the flag parsing logic by reimplementing the relevant +// subset. The actual integration is verified by bun check:ts (Args.pluginDirs exists +// and parseArgs populates it). + +/** Minimal flag parser matching the --plugin-dir logic in parseArgs. */ +function parsePluginDirFlags(args: string[]): string[] | undefined { + let result: string[] | undefined; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--plugin-dir" && i + 1 < args.length) { + result = result || []; + result.push(args[++i]); + } + } + return result; +} + +describe("--plugin-dir flag parsing logic", () => { + it("parses single --plugin-dir", () => { + expect(parsePluginDirFlags(["--plugin-dir", "./my-plugin"])).toEqual(["./my-plugin"]); + }); + + it("parses multiple --plugin-dir flags", () => { + expect(parsePluginDirFlags(["--plugin-dir", "./a", "--plugin-dir", "./b"])).toEqual(["./a", "./b"]); + }); + + it("returns undefined when no --plugin-dir", () => { + expect(parsePluginDirFlags([])).toBeUndefined(); + }); + + it("ignores --plugin-dir with no value", () => { + expect(parsePluginDirFlags(["--plugin-dir"])).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/marketplace/discovery.test.ts b/packages/coding-agent/test/marketplace/discovery.test.ts new file mode 100644 index 000000000..3743b01f0 --- /dev/null +++ b/packages/coding-agent/test/marketplace/discovery.test.ts @@ -0,0 +1,232 @@ +/** + * Discovery integration tests for OMP plugin registry reading. + * + * NOTE: listClaudePluginRoots() lives in discovery/helpers.ts which imports + * @oh-my-pi/pi-natives (native Rust addon via glob). We cannot call it here. + * + * Instead these tests validate the structural contract that listClaudePluginRoots + * depends on: + * 1. OMP registry lives at path.join(home, ".omp", "plugins", "installed_plugins.json") + * (matches getConfigDirName() == ".omp") + * 2. The registry format passes the same validator that parseClaudePluginsRegistry uses + * 3. readInstalledPluginsRegistry / writeInstalledPluginsRegistry produce files that + * satisfy that validator + * + * End-to-end wiring (calling listClaudePluginRoots) is covered by wiring.test.ts, + * which runs in an environment where the native addon is available. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { InstalledPluginEntry } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; +import { + addInstalledPlugin, + buildPluginId, + readInstalledPluginsRegistry, + writeInstalledPluginsRegistry, +} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; + +// ── Inline validator ─────────────────────────────────────────────────────────── +// +// Mirrors parseClaudePluginsRegistry() in discovery/helpers.ts exactly. +// Kept here to avoid importing helpers.ts (which pulls in @oh-my-pi/pi-natives). +function validateClaudeRegistryFormat(content: string): Record | null { + let data: Record; + try { + data = JSON.parse(content) as Record; + } catch { + return null; + } + if (!data || typeof data !== "object") return null; + if ( + typeof data.version !== "number" || + !data.plugins || + typeof data.plugins !== "object" || + Array.isArray(data.plugins) + ) + return null; + return data; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +// Matches getConfigDirName() — single source of truth is in @oh-my-pi/pi-utils, +// but we know the value is ".omp" and hardcoding it here keeps tests free of +// native-addon transitive imports. +const OMP_CONFIG_DIR = ".omp"; + +function makeEntry(installPath: string, version = "1.0.0"): InstalledPluginEntry { + return { + scope: "user", + installPath, + version, + installedAt: "2025-01-15T10:30:00.000Z", + lastUpdated: "2025-01-15T10:30:00.000Z", + }; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +let tmpHome: string; +/** ~/.omp/plugins/installed_plugins.json inside tmpHome */ +let ompRegistryPath: string; + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omp-discovery-test-")); + ompRegistryPath = path.join(tmpHome, OMP_CONFIG_DIR, "plugins", "installed_plugins.json"); + fs.mkdirSync(path.dirname(ompRegistryPath), { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +// ── Path contract ───────────────────────────────────────────────────────────── + +describe("OMP registry path contract", () => { + it("OMP registry lives at home/.omp/plugins/installed_plugins.json", () => { + // This is the path that listClaudePluginRoots reads. + // Any change to this path must be reflected in helpers.ts. + const expected = path.join(tmpHome, ".omp", "plugins", "installed_plugins.json"); + expect(ompRegistryPath).toBe(expected); + }); + + it("OMP config dir name is .omp", () => { + // Validate our hardcoded constant matches getConfigDirName(). + // If getConfigDirName() ever changes, this assertion will fail and + // we'll know the path constant here must be updated too. + expect(OMP_CONFIG_DIR).toBe(".omp"); + }); +}); + +// ── Format compatibility ─────────────────────────────────────────────────────── + +describe("OMP registry format compatibility with Claude parser", () => { + it("empty registry written by writeInstalledPluginsRegistry passes validator", async () => { + await writeInstalledPluginsRegistry(ompRegistryPath, { version: 2, plugins: {} }); + + const content = fs.readFileSync(ompRegistryPath, "utf8"); + const parsed = validateClaudeRegistryFormat(content); + expect(parsed).not.toBeNull(); + expect((parsed as Record).version).toBe(2); + }); + + it("registry with installed plugin passes validator", async () => { + const pluginId = buildPluginId("quality-review", "example-marketplace"); + const entry = makeEntry(path.join(tmpHome, "plugins", "cache", "example-marketplace--quality-review--1.0.0")); + + let reg = await readInstalledPluginsRegistry(ompRegistryPath); + reg = addInstalledPlugin(reg, pluginId, entry); + await writeInstalledPluginsRegistry(ompRegistryPath, reg); + + const content = fs.readFileSync(ompRegistryPath, "utf8"); + const parsed = validateClaudeRegistryFormat(content); + expect(parsed).not.toBeNull(); + + const plugins = (parsed as { plugins: Record }).plugins; + expect(Array.isArray(plugins[pluginId])).toBe(true); + expect((plugins[pluginId] as InstalledPluginEntry[])[0]?.installPath).toBe(entry.installPath); + }); + + it("file with missing version field fails validator (regression)", () => { + // Ensures validator correctly rejects what parseClaudePluginsRegistry rejects. + const badContent = JSON.stringify({ plugins: {} }); + expect(validateClaudeRegistryFormat(badContent)).toBeNull(); + }); + + it("file with plugins as array fails validator (regression)", () => { + const badContent = JSON.stringify({ version: 2, plugins: [] }); + expect(validateClaudeRegistryFormat(badContent)).toBeNull(); + }); + + it("file with non-numeric version fails validator (regression)", () => { + const badContent = JSON.stringify({ version: "2", plugins: {} }); + expect(validateClaudeRegistryFormat(badContent)).toBeNull(); + }); +}); + +// ── Round-trip ──────────────────────────────────────────────────────────────── + +describe("OMP registry round-trip", () => { + it("reads back what was written — single plugin", async () => { + const id = buildPluginId("hello-plugin", "test-marketplace"); + const entry = makeEntry("/tmp/fake-plugin-path"); + + let reg = await readInstalledPluginsRegistry(ompRegistryPath); + reg = addInstalledPlugin(reg, id, entry); + await writeInstalledPluginsRegistry(ompRegistryPath, reg); + + const readBack = await readInstalledPluginsRegistry(ompRegistryPath); + expect(readBack.plugins[id]).toBeDefined(); + expect(readBack.plugins[id]?.[0]?.installPath).toBe(entry.installPath); + expect(readBack.plugins[id]?.[0]?.version).toBe("1.0.0"); + expect(readBack.plugins[id]?.[0]?.scope).toBe("user"); + }); + + it("reads back what was written — multiple plugins", async () => { + const id1 = buildPluginId("plugin-a", "mkt"); + const id2 = buildPluginId("plugin-b", "mkt"); + const entry1 = makeEntry("/tmp/fake-a", "1.0.0"); + const entry2 = makeEntry("/tmp/fake-b", "2.0.0"); + + let reg = await readInstalledPluginsRegistry(ompRegistryPath); + reg = addInstalledPlugin(reg, id1, entry1); + reg = addInstalledPlugin(reg, id2, entry2); + await writeInstalledPluginsRegistry(ompRegistryPath, reg); + + const readBack = await readInstalledPluginsRegistry(ompRegistryPath); + expect(Object.keys(readBack.plugins)).toHaveLength(2); + expect(readBack.plugins[id1]?.[0]?.version).toBe("1.0.0"); + expect(readBack.plugins[id2]?.[0]?.version).toBe("2.0.0"); + }); + + it("missing file returns empty registry (not an error)", async () => { + // listClaudePluginRoots treats absent file as empty, not a failure. + // readInstalledPluginsRegistry must match this behaviour. + const missingPath = path.join(tmpHome, "nonexistent", "installed_plugins.json"); + const reg = await readInstalledPluginsRegistry(missingPath); + expect(reg).toEqual({ version: 2, plugins: {} }); + }); +}); + +// ── Precedence contract (structural) ───────────────────────────────────────── +// +// listClaudePluginRoots must replace Claude entries with OMP entries when the same +// plugin ID appears in both registries. We cannot call that function here, but we +// can verify the data shapes that the replacement logic reads are correct. + +describe("OMP precedence contract (registry structure)", () => { + it("same plugin ID in both registries — OMP entry has required fields for deduplication", () => { + // The replacement logic: roots.filter(r => r.id !== pluginId) keyed by id. + // OMP entries must have installPath so they can be added to roots[]. + const id = buildPluginId("shared-plugin", "common-mkt"); + const ompEntry = makeEntry("/omp/cached/path"); + + // OMP registry entry has installPath (required by listClaudePluginRoots) + expect(ompEntry.installPath).toBeTruthy(); + expect(typeof ompEntry.installPath).toBe("string"); + // ID parses correctly with lastIndexOf("@") + const atIndex = id.lastIndexOf("@"); + expect(atIndex).toBeGreaterThan(0); + expect(id.slice(0, atIndex)).toBe("shared-plugin"); + expect(id.slice(atIndex + 1)).toBe("common-mkt"); + }); + + it("installPath deduplication: same path → one entry", () => { + // Mirrors the deduplication check: roots.some(r => r.id === pluginId && r.path === entry.installPath) + const id = buildPluginId("dup-plugin", "mkt"); + const sharedPath = "/tmp/shared-install-path"; + + // Simulate what listClaudePluginRoots would do: + const roots: Array<{ id: string; path: string }> = [{ id, path: sharedPath }]; + + // Second entry with same installPath should be deduplicated + const isDuplicate = roots.some(r => r.id === id && r.path === sharedPath); + expect(isDuplicate).toBe(true); + + // Entry with different installPath should NOT be deduplicated + const isDifferent = roots.some(r => r.id === id && r.path === "/tmp/other-path"); + expect(isDifferent).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/marketplace/fetcher.test.ts b/packages/coding-agent/test/marketplace/fetcher.test.ts new file mode 100644 index 000000000..2977195df --- /dev/null +++ b/packages/coding-agent/test/marketplace/fetcher.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + classifySource, + fetchMarketplace, + parseMarketplaceCatalog, +} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; + +// Fixture lives at test/marketplace/fixtures/valid-marketplace/ +const FIXTURE_DIR = path.join(import.meta.dir, "fixtures", "valid-marketplace"); + +// ── classifySource ──────────────────────────────────────────────────── + +describe("classifySource", () => { + // ── local ───────────────────────────────────────────────────────── + + it("classifies './' prefix as local", () => { + expect(classifySource("./my-marketplace")).toBe("local"); + }); + + it("classifies POSIX absolute path as local", () => { + expect(classifySource("/abs/path")).toBe("local"); + }); + + it("classifies '~/' prefix as local", () => { + expect(classifySource("~/my-marketplace")).toBe("local"); + }); + + it("classifies Windows absolute path as local", () => { + // C:\Users\me\marketplace — path.isAbsolute returns false on POSIX, + // so the WIN_ABS_RE fallback must handle this. + expect(classifySource("C:\\Users\\me\\marketplace")).toBe("local"); + }); + + // ── url ─────────────────────────────────────────────────────────── + + it("classifies https .json URL as url", () => { + expect(classifySource("https://example.com/marketplace.json")).toBe("url"); + }); + + // ── git ─────────────────────────────────────────────────────────── + + it("classifies https non-.json URL as git", () => { + expect(classifySource("https://github.com/owner/repo.git")).toBe("git"); + }); + + it("classifies git@ SCP-style URL as git", () => { + expect(classifySource("git@github.com:owner/repo.git")).toBe("git"); + }); + + it("classifies ssh:// URL as git", () => { + expect(classifySource("ssh://git@github.com/owner/repo")).toBe("git"); + }); + + // ── github ──────────────────────────────────────────────────────── + + it("classifies owner/repo shorthand as github", () => { + expect(classifySource("owner/repo")).toBe("github"); + }); + + // ── errors ──────────────────────────────────────────────────────── + + it("throws on bare name with suggestion", () => { + expect(() => classifySource("just-a-name")).toThrow( + "Unrecognized source format. Did you mean './just-a-name' (local) or 'owner/repo' (GitHub)?", + ); + }); +}); + +// ── parseMarketplaceCatalog ─────────────────────────────────────────── + +describe("parseMarketplaceCatalog", () => { + const VALID = JSON.stringify({ + name: "test-marketplace", + owner: { name: "Test Author", email: "test@example.com" }, + metadata: { description: "A test marketplace" }, + plugins: [{ name: "hello-plugin", source: "./plugins/hello-plugin", description: "Greets" }], + }); + + it("parses a valid catalog", () => { + const catalog = parseMarketplaceCatalog(VALID, "/fake/marketplace.json"); + expect(catalog.name).toBe("test-marketplace"); + expect(catalog.owner.name).toBe("Test Author"); + expect(catalog.plugins).toHaveLength(1); + expect(catalog.plugins[0].name).toBe("hello-plugin"); + }); + + it("throws on missing name", () => { + const bad = JSON.stringify({ owner: { name: "x" }, plugins: [] }); + expect(() => parseMarketplaceCatalog(bad, "/f.json")).toThrow(/"name"/); + }); + + it("throws when name fails isValidNameSegment", () => { + const bad = JSON.stringify({ name: "Invalid Name", owner: { name: "x" }, plugins: [] }); + expect(() => parseMarketplaceCatalog(bad, "/f.json")).toThrow(/"name"/); + }); + + it("throws on missing plugins", () => { + const bad = JSON.stringify({ name: "valid-name", owner: { name: "x" } }); + expect(() => parseMarketplaceCatalog(bad, "/f.json")).toThrow(/"plugins"/); + }); + + it("throws on missing owner", () => { + const bad = JSON.stringify({ name: "valid-name", plugins: [] }); + expect(() => parseMarketplaceCatalog(bad, "/f.json")).toThrow(/"owner"/); + }); + + it("empty plugins array is valid", () => { + const catalog = parseMarketplaceCatalog( + JSON.stringify({ name: "valid-name", owner: { name: "x" }, plugins: [] }), + "/f.json", + ); + expect(catalog.plugins).toHaveLength(0); + }); + + it("preserves extra fields in output", () => { + const extra = JSON.stringify({ + name: "my-market", + owner: { name: "x" }, + plugins: [], + myCustomField: "preserved", + anotherExtra: 42, + }); + const catalog = parseMarketplaceCatalog(extra, "/f.json") as unknown as Record; + expect(catalog.myCustomField).toBe("preserved"); + expect(catalog.anotherExtra).toBe(42); + }); + + it("accepts plugin with object source (typed source object)", () => { + const content = JSON.stringify({ + name: "my-market", + owner: { name: "x" }, + plugins: [{ name: "p1", source: { source: "github", repo: "owner/repo" } }], + }); + const catalog = parseMarketplaceCatalog(content, "/f.json"); + expect(catalog.plugins[0].name).toBe("p1"); + }); + + it("throws on invalid JSON", () => { + expect(() => parseMarketplaceCatalog("{not json", "/f.json")).toThrow( + "Failed to parse marketplace catalog at /f.json", + ); + }); +}); + +// ── fetchMarketplace ────────────────────────────────────────────────── + +describe("fetchMarketplace", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-fetcher-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("resolves catalog from fixture directory", async () => { + const result = await fetchMarketplace(FIXTURE_DIR, tmpDir); + expect(result.catalog.name).toBe("test-marketplace"); + expect(result.catalog.owner.name).toBe("Test Author"); + expect(result.catalog.plugins).toHaveLength(1); + expect(result.catalog.plugins[0].name).toBe("hello-plugin"); + // local fetch never returns a clonePath + expect(result.clonePath).toBeUndefined(); + }); + + it("throws a clear error for nonexistent local directory", async () => { + const missing = path.join(tmpDir, "nonexistent"); + await expect(fetchMarketplace(missing, tmpDir)).rejects.toThrow(/Marketplace catalog not found/); + }); + + it("throws a clear error for relative nonexistent path", async () => { + // Use a path that resolves within tmpDir but doesn't exist + const fakeSrc = path.join(tmpDir, "ghost-marketplace"); + await expect(fetchMarketplace(fakeSrc, tmpDir)).rejects.toThrow(/Marketplace catalog not found/); + }); + + // Network-dependent tests — skip in CI / offline environments. + // These verify real git clone and HTTP fetch error handling. + it.skip("github source throws on nonexistent repo", async () => { + await expect(fetchMarketplace("nonexistent-owner-xyz/nonexistent-repo-xyz", tmpDir)).rejects.toThrow( + /git clone failed/, + ); + }); + + it.skip("git source throws on nonexistent repo", async () => { + await expect( + fetchMarketplace("git@github.com:nonexistent-owner-xyz/nonexistent-repo-xyz.git", tmpDir), + ).rejects.toThrow(/git clone failed/); + }); + + it.skip("url source throws on non-2xx response", async () => { + await expect(fetchMarketplace("https://example.com/nonexistent-catalog-xyz.json", tmpDir)).rejects.toThrow( + /HTTP [45]\d\d/, + ); + }); +}); diff --git a/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/.claude-plugin/marketplace.json b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/.claude-plugin/marketplace.json new file mode 100644 index 000000000..65dc81a4a --- /dev/null +++ b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "test-marketplace", + "owner": { + "name": "Test Author", + "email": "test@example.com" + }, + "metadata": { + "description": "A test marketplace for unit tests", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "hello-plugin", + "source": "./plugins/hello-plugin", + "description": "A test plugin that greets", + "version": "1.0.0", + "author": { "name": "Test Author" }, + "keywords": ["test", "greeting"] + } + ] +} diff --git a/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.claude-plugin/plugin.json b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.claude-plugin/plugin.json new file mode 100644 index 000000000..829ef41db --- /dev/null +++ b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "hello-plugin", + "description": "A test plugin", + "version": "1.0.0" +} diff --git a/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.lsp.json b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.lsp.json new file mode 100644 index 000000000..bb826ca81 --- /dev/null +++ b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.lsp.json @@ -0,0 +1,8 @@ +{ + "lspServers": { + "test-lsp": { + "command": "${CLAUDE_PLUGIN_ROOT}/bin/lsp", + "args": ["--stdio"] + } + } +} diff --git a/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.mcp.json b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.mcp.json new file mode 100644 index 000000000..38ea45ff0 --- /dev/null +++ b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "test-server": { + "command": "${CLAUDE_PLUGIN_ROOT}/bin/server", + "args": ["--port", "3000"] + } + } +} diff --git a/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/agents/reviewer.md b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/agents/reviewer.md new file mode 100644 index 000000000..2cec2db97 --- /dev/null +++ b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/agents/reviewer.md @@ -0,0 +1,6 @@ +--- +name: reviewer +description: A test code review agent +--- + +Review the code for quality issues. diff --git a/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/commands/hello.md b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/commands/hello.md new file mode 100644 index 000000000..ccdb3b9d5 --- /dev/null +++ b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/commands/hello.md @@ -0,0 +1,6 @@ +--- +name: hello +description: Say hello +--- + +Greet the user warmly. diff --git a/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/skills/greet/SKILL.md b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/skills/greet/SKILL.md new file mode 100644 index 000000000..0ca56e8e3 --- /dev/null +++ b/packages/coding-agent/test/marketplace/fixtures/valid-marketplace/plugins/hello-plugin/skills/greet/SKILL.md @@ -0,0 +1,6 @@ +--- +name: greet +description: A greeting skill for testing +--- + +Say hello to the user. diff --git a/packages/coding-agent/test/marketplace/manager.test.ts b/packages/coding-agent/test/marketplace/manager.test.ts new file mode 100644 index 000000000..a4cd2f80c --- /dev/null +++ b/packages/coding-agent/test/marketplace/manager.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { MarketplaceManager } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; + +// Fixture: the valid-marketplace directory used across all tests. +const FIXTURE_DIR = path.join(import.meta.dir, "fixtures", "valid-marketplace"); + +// ── Test helper ─────────────────────────────────────────────────────────────── + +interface TestContext { + manager: MarketplaceManager; + tmpDir: string; + /** Incremented each time clearPluginRootsCache is called. */ + clearCount: () => number; +} + +function createTestContext(): TestContext { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mgr-test-")); + + const dirs = { + mktRegistry: path.join(tmpDir, "marketplaces.json"), + instRegistry: path.join(tmpDir, "installed_plugins.json"), + mktCache: path.join(tmpDir, "cache", "marketplaces"), + plugCache: path.join(tmpDir, "cache", "plugins"), + }; + + let count = 0; + + const manager = new MarketplaceManager({ + marketplacesRegistryPath: dirs.mktRegistry, + installedRegistryPath: dirs.instRegistry, + marketplacesCacheDir: dirs.mktCache, + pluginsCacheDir: dirs.plugCache, + clearPluginRootsCache: () => { + count++; + }, + }); + + return { manager, tmpDir, clearCount: () => count }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("MarketplaceManager", () => { + let ctx: TestContext; + + beforeEach(() => { + ctx = createTestContext(); + }); + + afterEach(() => { + fs.rmSync(ctx.tmpDir, { recursive: true, force: true }); + }); + + // ── Marketplace lifecycle ────────────────────────────────────────────── + + it("addMarketplace with local fixture → appears in listMarketplaces", async () => { + const entry = await ctx.manager.addMarketplace(FIXTURE_DIR); + + expect(entry.name).toBe("test-marketplace"); + expect(entry.sourceType).toBe("local"); + expect(entry.sourceUri).toBe(FIXTURE_DIR); + + const list = await ctx.manager.listMarketplaces(); + expect(list).toHaveLength(1); + expect(list[0].name).toBe("test-marketplace"); + }); + + it("addMarketplace with duplicate name → throws", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + await expect(ctx.manager.addMarketplace(FIXTURE_DIR)).rejects.toThrow(/already exists/); + }); + + it("removeMarketplace → gone from list and catalog cache removed", async () => { + const entry = await ctx.manager.addMarketplace(FIXTURE_DIR); + + // Catalog file should exist in cache + expect(fs.existsSync(entry.catalogPath)).toBe(true); + + await ctx.manager.removeMarketplace("test-marketplace"); + + const list = await ctx.manager.listMarketplaces(); + expect(list).toHaveLength(0); + + // Catalog cache dir should be gone + const catalogDir = path.dirname(entry.catalogPath); + expect(fs.existsSync(catalogDir)).toBe(false); + }); + + it("updateMarketplace on nonexistent marketplace → throws", async () => { + await expect(ctx.manager.updateMarketplace("ghost")).rejects.toThrow(/not found/); + }); + + it("updateMarketplace re-fetches and updates updatedAt", async () => { + const added = await ctx.manager.addMarketplace(FIXTURE_DIR); + + // Small sleep so clock advances + await Bun.sleep(5); + + const updated = await ctx.manager.updateMarketplace("test-marketplace"); + expect(updated.name).toBe("test-marketplace"); + expect(updated.addedAt).toBe(added.addedAt); + // updatedAt must be at or after addedAt + expect(new Date(updated.updatedAt) >= new Date(added.addedAt)).toBe(true); + }); + + // ── Plugin discovery ─────────────────────────────────────────────────── + + it("listAvailablePlugins → returns catalog entries", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + const plugins = await ctx.manager.listAvailablePlugins(); + expect(plugins).toHaveLength(1); + expect(plugins[0].name).toBe("hello-plugin"); + }); + + it("listAvailablePlugins(marketplace) → filtered to that marketplace", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + const plugins = await ctx.manager.listAvailablePlugins("test-marketplace"); + expect(plugins).toHaveLength(1); + expect(plugins[0].name).toBe("hello-plugin"); + }); + + it("listAvailablePlugins(unknown) → throws", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + await expect(ctx.manager.listAvailablePlugins("no-such")).rejects.toThrow(/not found/); + }); + + // ── Install ──────────────────────────────────────────────────────────── + + it("installPlugin → plugin in cache + in registry", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + const instEntry = await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + + expect(instEntry.scope).toBe("user"); + expect(instEntry.version).toBe("1.0.0"); + expect(fs.existsSync(instEntry.installPath)).toBe(true); + + const installed = await ctx.manager.listInstalledPlugins(); + expect(installed).toHaveLength(1); + expect(installed[0].id).toBe("hello-plugin@test-marketplace"); + }); + + it("installPlugin already installed → throws without force", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + await expect(ctx.manager.installPlugin("hello-plugin", "test-marketplace")).rejects.toThrow(/already installed/); + }); + + it("installPlugin with force:true → replaces existing", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + const first = await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + const second = await ctx.manager.installPlugin("hello-plugin", "test-marketplace", { + force: true, + }); + + expect(second.installPath).toBe(first.installPath); + expect(fs.existsSync(second.installPath)).toBe(true); + + const installed = await ctx.manager.listInstalledPlugins(); + expect(installed).toHaveLength(1); + }); + + it("installPlugin with nonexistent marketplace → clear error", async () => { + await expect(ctx.manager.installPlugin("hello-plugin", "no-such-market")).rejects.toThrow( + /Marketplace "no-such-market" not found/, + ); + }); + + it("installPlugin with nonexistent plugin in catalog → clear error", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + await expect(ctx.manager.installPlugin("ghost-plugin", "test-marketplace")).rejects.toThrow( + /Plugin "ghost-plugin" not found in marketplace "test-marketplace"/, + ); + }); + + it("installPlugin calls clearPluginRootsCache", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + const before = ctx.clearCount(); + await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + expect(ctx.clearCount()).toBe(before + 1); + }); + + // ── Uninstall ────────────────────────────────────────────────────────── + + it("uninstallPlugin → cache removed + deregistered", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + const instEntry = await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + + await ctx.manager.uninstallPlugin("hello-plugin@test-marketplace"); + + expect(fs.existsSync(instEntry.installPath)).toBe(false); + + const installed = await ctx.manager.listInstalledPlugins(); + expect(installed).toHaveLength(0); + }); + + it("uninstallPlugin nonexistent → throws", async () => { + await expect(ctx.manager.uninstallPlugin("ghost-plugin@nowhere")).rejects.toThrow(/not installed/); + }); + + it("uninstallPlugin with invalid ID format → throws clear error", async () => { + await expect(ctx.manager.uninstallPlugin("no-at-sign")).rejects.toThrow(/Invalid plugin ID format/); + }); + + it("uninstallPlugin calls clearPluginRootsCache", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + const before = ctx.clearCount(); + await ctx.manager.uninstallPlugin("hello-plugin@test-marketplace"); + expect(ctx.clearCount()).toBe(before + 1); + }); + + // ── setPluginEnabled ─────────────────────────────────────────────────── + + it("setPluginEnabled → persisted in registry", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + + await ctx.manager.setPluginEnabled("hello-plugin@test-marketplace", false); + + const installed = await ctx.manager.listInstalledPlugins(); + expect(installed[0].entries[0].enabled).toBe(false); + + await ctx.manager.setPluginEnabled("hello-plugin@test-marketplace", true); + const updated = await ctx.manager.listInstalledPlugins(); + expect(updated[0].entries[0].enabled).toBe(true); + }); + + it("setPluginEnabled on nonexistent plugin → throws", async () => { + await expect(ctx.manager.setPluginEnabled("ghost@nowhere", true)).rejects.toThrow(/not installed/); + }); + + it("setPluginEnabled calls clearPluginRootsCache", async () => { + await ctx.manager.addMarketplace(FIXTURE_DIR); + await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + const before = ctx.clearCount(); + await ctx.manager.setPluginEnabled("hello-plugin@test-marketplace", false); + expect(ctx.clearCount()).toBe(before + 1); + }); + + // ── version fallback ─────────────────────────────────────────────────── + + it("installPlugin falls back to plugin.json version when catalog version is missing", async () => { + // Write a catalog without a version field on the plugin + await ctx.manager.addMarketplace(FIXTURE_DIR); + + // Mutate the cached catalog to remove version + const list = await ctx.manager.listMarketplaces(); + const catalogPath = list[0].catalogPath; + const content = await Bun.file(catalogPath).text(); + const catalog = JSON.parse(content) as { + plugins: Array>; + }; + catalog.plugins[0] = { ...catalog.plugins[0] }; + delete catalog.plugins[0].version; + await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + + const instEntry = await ctx.manager.installPlugin("hello-plugin", "test-marketplace"); + // No catalog version, but fixture's .claude-plugin/plugin.json has version "1.0.0" + expect(instEntry.version).toBe("1.0.0"); + }); +}); diff --git a/packages/coding-agent/test/marketplace/parse-internal-url.test.ts b/packages/coding-agent/test/marketplace/parse-internal-url.test.ts new file mode 100644 index 000000000..ca3c456dc --- /dev/null +++ b/packages/coding-agent/test/marketplace/parse-internal-url.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "bun:test"; +import { parseInternalUrl } from "@oh-my-pi/pi-coding-agent/internal-urls/parse"; + +// ── Basic parsing (URLs that new URL() handles fine) ───────────────── + +describe("parseInternalUrl — standard URLs", () => { + it("parses a simple skill:// URL", () => { + const u = parseInternalUrl("skill://brainstorming"); + expect(u.rawHost).toBe("brainstorming"); + expect(u.protocol).toBe("skill:"); + }); + + it("parses skill:// with path", () => { + const u = parseInternalUrl("skill://my-skill/subdir/file.md"); + expect(u.rawHost).toBe("my-skill"); + expect(u.rawPathname).toBe("/subdir/file.md"); + }); + + it("parses agent:// URL", () => { + const u = parseInternalUrl("agent://reviewer_0"); + expect(u.rawHost).toBe("reviewer_0"); + expect(u.protocol).toBe("agent:"); + }); + + it("parses agent:// with path extraction", () => { + const u = parseInternalUrl("agent://output_id/field"); + expect(u.rawHost).toBe("output_id"); + expect(u.rawPathname).toBe("/field"); + }); + + it("parses memory:// URL", () => { + const u = parseInternalUrl("memory://root"); + expect(u.rawHost).toBe("root"); + expect(u.protocol).toBe("memory:"); + }); + + it("parses local:// URL", () => { + const u = parseInternalUrl("local://PLAN.md"); + expect(u.rawHost).toBe("PLAN.md"); + expect(u.protocol).toBe("local:"); + }); + + it("preserves query parameters when URL parses normally", () => { + const u = parseInternalUrl("agent://output_id?q=foo.bar"); + expect(u.rawHost).toBe("output_id"); + expect(u.searchParams.get("q")).toBe("foo.bar"); + }); + + it("preserves href", () => { + const input = "skill://my-skill/path"; + const u = parseInternalUrl(input); + expect(u.href).toContain("skill://"); + }); +}); + +// ── Namespaced URLs (colons in host — new URL() fails) ─────────────── + +describe("parseInternalUrl — namespaced host (colon in host)", () => { + it("parses skill://plugin:name (colon as namespace separator)", () => { + const u = parseInternalUrl("skill://superpowers:brainstorming"); + expect(u.rawHost).toBe("superpowers:brainstorming"); + expect(u.protocol).toBe("skill:"); + expect(u.rawPathname).toBe(""); + }); + + it("parses skill://plugin:name/path", () => { + const u = parseInternalUrl("skill://superpowers:brainstorming/subdir/file.md"); + expect(u.rawHost).toBe("superpowers:brainstorming"); + expect(u.rawPathname).toBe("/subdir/file.md"); + }); + + it("parses skill://plugin:name:suffix (multiple colons)", () => { + const u = parseInternalUrl("skill://superpowers:brainstorming:1-5"); + expect(u.rawHost).toBe("superpowers:brainstorming:1-5"); + expect(u.protocol).toBe("skill:"); + }); + + it("parses namespaced URL with path after multiple colons", () => { + const u = parseInternalUrl("skill://superpowers:brainstorming:1-5/extra"); + expect(u.rawHost).toBe("superpowers:brainstorming:1-5"); + expect(u.rawPathname).toBe("/extra"); + }); + + it("provides empty searchParams for fallback-parsed URLs", () => { + const u = parseInternalUrl("skill://superpowers:brainstorming"); + // searchParams should exist and be empty (not throw) + expect(u.searchParams.get("q")).toBeNull(); + }); +}); + +// ── Percent-encoded colons ─────────────────────────────────────────── + +describe("parseInternalUrl — percent-encoded host", () => { + it("decodes %3A in host to colon", () => { + const u = parseInternalUrl("skill://superpowers%3Abrainstorming"); + expect(u.rawHost).toBe("superpowers:brainstorming"); + }); + + it("decodes %3A with path", () => { + const u = parseInternalUrl("skill://superpowers%3Abrainstorming/file.md"); + expect(u.rawHost).toBe("superpowers:brainstorming"); + expect(u.rawPathname).toBe("/file.md"); + }); + + it("decodes multiple %3A segments", () => { + const u = parseInternalUrl("skill://a%3Ab%3Ac"); + expect(u.rawHost).toBe("a:b:c"); + }); + + it("decodes mixed %3A and literal colons consistently", () => { + // literal colon triggers fallback; %3A is decoded in rawHost + const u1 = parseInternalUrl("skill://plugin:name"); + const u2 = parseInternalUrl("skill://plugin%3Aname"); + expect(u1.rawHost).toBe(u2.rawHost); + }); +}); + +// ── Edge cases ─────────────────────────────────────────────────────── + +describe("parseInternalUrl — edge cases", () => { + it("throws on completely invalid input", () => { + expect(() => parseInternalUrl("not-a-url")).toThrow(/Invalid URL/); + }); + + it("throws on empty string", () => { + expect(() => parseInternalUrl("")).toThrow(/Invalid URL/); + }); + + it("parses empty host", () => { + const u = parseInternalUrl("skill:///path/to/file"); + expect(u.rawHost).toBe(""); + expect(u.rawPathname).toBe("/path/to/file"); + }); + + it("handles host with only valid port (new URL succeeds)", () => { + // skill://host:8080 — new URL() parses this with hostname=host, port=8080 + // rawHost should still capture the full "host:8080" via regex + const u = parseInternalUrl("skill://host:8080"); + expect(u.rawHost).toBe("host:8080"); + }); + + it("handles host with hyphens and dots", () => { + const u = parseInternalUrl("skill://my-plugin.v2"); + expect(u.rawHost).toBe("my-plugin.v2"); + }); + + it("handles uppercase scheme", () => { + const u = parseInternalUrl("SKILL://my-skill"); + expect(u.rawHost).toBe("my-skill"); + expect(u.protocol).toBe("skill:"); + }); + + it("preserves hash fragment when URL parses", () => { + const u = parseInternalUrl("agent://output#section"); + expect(u.rawHost).toBe("output"); + }); + + it("does not include query in rawHost", () => { + const u = parseInternalUrl("agent://output?q=test"); + expect(u.rawHost).toBe("output"); + }); + + it("does not include path in rawHost", () => { + const u = parseInternalUrl("agent://output/deep/path"); + expect(u.rawHost).toBe("output"); + expect(u.rawPathname).toBe("/deep/path"); + }); + + it("rawHost does not include fragment", () => { + const u = parseInternalUrl("skill://name#frag"); + expect(u.rawHost).toBe("name"); + }); +}); + +// ── Protocol extraction ────────────────────────────────────────────── + +describe("parseInternalUrl — protocol field", () => { + it("extracts skill: protocol", () => { + expect(parseInternalUrl("skill://x").protocol).toBe("skill:"); + }); + + it("extracts agent: protocol", () => { + expect(parseInternalUrl("agent://x").protocol).toBe("agent:"); + }); + + it("extracts memory: protocol", () => { + expect(parseInternalUrl("memory://x").protocol).toBe("memory:"); + }); + + it("extracts local: protocol", () => { + expect(parseInternalUrl("local://x").protocol).toBe("local:"); + }); + + it("extracts rule: protocol", () => { + expect(parseInternalUrl("rule://x").protocol).toBe("rule:"); + }); + + it("extracts artifact: protocol", () => { + expect(parseInternalUrl("artifact://x").protocol).toBe("artifact:"); + }); + + it("extracts protocol from fallback-parsed URL", () => { + // This URL fails new URL() due to colon-as-port + expect(parseInternalUrl("skill://a:b").protocol).toBe("skill:"); + }); +}); diff --git a/packages/coding-agent/test/marketplace/plugin-dir-roots.test.ts b/packages/coding-agent/test/marketplace/plugin-dir-roots.test.ts new file mode 100644 index 000000000..39f9727d8 --- /dev/null +++ b/packages/coding-agent/test/marketplace/plugin-dir-roots.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; +import { buildPluginDirRoot } from "@oh-my-pi/pi-coding-agent/discovery/plugin-dir-roots"; + +describe("buildPluginDirRoot", () => { + it("builds root with manifest name", () => { + const root = buildPluginDirRoot("/path/to/my-plugin", "custom-name"); + expect(root).toEqual({ + id: "custom-name@__local__", + marketplace: "__local__", + plugin: "custom-name", + version: "local", + path: "/path/to/my-plugin", + scope: "user", + }); + }); + + it("falls back to directory basename when no manifest name", () => { + const root = buildPluginDirRoot("/path/to/my-plugin"); + expect(root.plugin).toBe("my-plugin"); + expect(root.id).toBe("my-plugin@__local__"); + }); + + it("falls back to directory basename when manifest name is undefined", () => { + const root = buildPluginDirRoot("/some/dir/cool-plugin", undefined); + expect(root.plugin).toBe("cool-plugin"); + expect(root.id).toBe("cool-plugin@__local__"); + }); + + it("uses __local__ marketplace", () => { + const root = buildPluginDirRoot("/any/path", "test"); + expect(root.marketplace).toBe("__local__"); + }); + + it("uses local version string", () => { + const root = buildPluginDirRoot("/any/path", "test"); + expect(root.version).toBe("local"); + }); + + it("sets scope to user", () => { + const root = buildPluginDirRoot("/any/path", "test"); + expect(root.scope).toBe("user"); + }); + + it("preserves absolute path", () => { + const root = buildPluginDirRoot("/absolute/path/to/plugin", "test"); + expect(root.path).toBe("/absolute/path/to/plugin"); + }); + + it("constructs id as pluginName@__local__", () => { + const root = buildPluginDirRoot("/p", "my-tool"); + expect(root.id).toBe("my-tool@__local__"); + }); +}); diff --git a/packages/coding-agent/test/marketplace/registry.test.ts b/packages/coding-agent/test/marketplace/registry.test.ts new file mode 100644 index 000000000..fc261fc8f --- /dev/null +++ b/packages/coding-agent/test/marketplace/registry.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { + InstalledPluginEntry, + InstalledPluginsRegistry, + MarketplaceRegistryEntry, + MarketplacesRegistry, +} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; +import { + addInstalledPlugin, + addMarketplaceEntry, + buildPluginId, + getInstalledPlugin, + getMarketplaceEntry, + isValidNameSegment, + parsePluginId, + readInstalledPluginsRegistry, + readMarketplacesRegistry, + removeInstalledPlugin, + removeMarketplaceEntry, + writeInstalledPluginsRegistry, + writeMarketplacesRegistry, +} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; + +// Inline the parseClaudePluginsRegistry validation logic to avoid pulling +// in discovery/helpers.ts which transitively imports @oh-my-pi/pi-natives. +// Matches the exact checks in helpers.ts parseClaudePluginsRegistry(). +function validateClaudeRegistryFormat(content: string): Record | null { + let data: Record; + try { + data = JSON.parse(content); + } catch { + return null; + } + if (!data || typeof data !== "object") return null; + if ( + typeof data.version !== "number" || + !data.plugins || + typeof data.plugins !== "object" || + Array.isArray(data.plugins) + ) + return null; + return data; +} + +// ── ID helpers ─────────────────────────────────────────────────────── + +describe("isValidNameSegment", () => { + it("accepts lowercase alphanumeric with hyphens", () => { + expect(isValidNameSegment("hello")).toBe(true); + expect(isValidNameSegment("my-plugin")).toBe(true); + expect(isValidNameSegment("a1-b2-c3")).toBe(true); + expect(isValidNameSegment("x")).toBe(true); + }); + + it("rejects invalid segments", () => { + expect(isValidNameSegment("")).toBe(false); + expect(isValidNameSegment("Hello")).toBe(false); + expect(isValidNameSegment("my plugin")).toBe(false); + expect(isValidNameSegment("my@plugin")).toBe(false); + expect(isValidNameSegment("my/plugin")).toBe(false); + expect(isValidNameSegment("-leading")).toBe(false); + expect(isValidNameSegment("trailing-")).toBe(false); + expect(isValidNameSegment("UPPER")).toBe(false); + expect(isValidNameSegment("a".repeat(65))).toBe(false); + }); +}); + +describe("buildPluginId / parsePluginId", () => { + it("round-trips valid IDs", () => { + const id = buildPluginId("my-plugin", "my-market"); + expect(id).toBe("my-plugin@my-market"); + + const parsed = parsePluginId(id); + expect(parsed).toEqual({ name: "my-plugin", marketplace: "my-market" }); + }); + + it("buildPluginId rejects invalid names", () => { + expect(() => buildPluginId("Bad", "market")).toThrow(/Invalid plugin name/); + expect(() => buildPluginId("ok", "Bad Market")).toThrow(/Invalid marketplace name/); + }); + + it("buildPluginId rejects combined length > 128", () => { + const longName = "a".repeat(64); + const longMarket = "b".repeat(64); + // 64 + "@" + 64 = 129 > 128 + expect(() => buildPluginId(longName, longMarket)).toThrow(/exceeds 128/); + }); + + it("parsePluginId returns null for missing @", () => { + expect(parsePluginId("no-at-sign")).toBeNull(); + }); + + it("parsePluginId returns null for @ at start or end", () => { + expect(parsePluginId("@market")).toBeNull(); + expect(parsePluginId("plugin@")).toBeNull(); + }); + + it("parsePluginId returns null for invalid segments", () => { + expect(parsePluginId("BAD@market")).toBeNull(); + expect(parsePluginId("plugin@BAD")).toBeNull(); + }); + + it("parsePluginId splits on last @", () => { + // "a@b" is not a valid name segment (contains @), so this returns null + expect(parsePluginId("a@b@c")).toBeNull(); + }); +}); + +// ── Marketplace CRUD (pure functions) ──────────────────────────────── + +describe("marketplace registry CRUD", () => { + const entry: MarketplaceRegistryEntry = { + name: "test-market", + sourceType: "local", + sourceUri: "/tmp/market", + catalogPath: "/tmp/market/catalog.json", + addedAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }; + const empty: MarketplacesRegistry = { version: 1, marketplaces: [] }; + + it("addMarketplaceEntry + getMarketplaceEntry round-trip", () => { + const reg = addMarketplaceEntry(empty, entry); + expect(getMarketplaceEntry(reg, "test-market")).toEqual(entry); + }); + + it("addMarketplaceEntry throws on duplicate", () => { + const reg = addMarketplaceEntry(empty, entry); + expect(() => addMarketplaceEntry(reg, entry)).toThrow(/already exists/); + }); + + it("removeMarketplaceEntry removes entry, leaves others", () => { + const other: MarketplaceRegistryEntry = { ...entry, name: "other" }; + let reg = addMarketplaceEntry(empty, entry); + reg = addMarketplaceEntry(reg, other); + reg = removeMarketplaceEntry(reg, "test-market"); + expect(getMarketplaceEntry(reg, "test-market")).toBeUndefined(); + expect(getMarketplaceEntry(reg, "other")).toEqual(other); + }); + + it("removeMarketplaceEntry throws on not found", () => { + expect(() => removeMarketplaceEntry(empty, "ghost")).toThrow(/not found/); + }); +}); + +// ── Installed plugin CRUD (pure functions) ─────────────────────────── + +describe("installed plugin CRUD", () => { + const entry: InstalledPluginEntry = { + scope: "user", + installPath: "/tmp/plugins/cache/my-market--my-plugin--1.0.0", + version: "1.0.0", + installedAt: "2025-01-01T00:00:00.000Z", + lastUpdated: "2025-01-01T00:00:00.000Z", + }; + const empty: InstalledPluginsRegistry = { version: 2, plugins: {} }; + + it("addInstalledPlugin + getInstalledPlugin round-trip", () => { + const id = "my-plugin@my-market"; + const reg = addInstalledPlugin(empty, id, entry); + expect(getInstalledPlugin(reg, id)).toEqual([entry]); + }); + + it("addInstalledPlugin appends to existing entries for same ID", () => { + const id = "my-plugin@my-market"; + const entry2: InstalledPluginEntry = { + ...entry, + version: "2.0.0", + installPath: "/tmp/plugins/cache/my-market--my-plugin--2.0.0", + }; + let reg = addInstalledPlugin(empty, id, entry); + reg = addInstalledPlugin(reg, id, entry2); + expect(getInstalledPlugin(reg, id)).toEqual([entry, entry2]); + }); + + it("removeInstalledPlugin removes all entries for that ID", () => { + const id = "my-plugin@my-market"; + const otherId = "other@other"; + const otherEntry: InstalledPluginEntry = { ...entry, installPath: "/other" }; + let reg = addInstalledPlugin(empty, id, entry); + reg = addInstalledPlugin(reg, otherId, otherEntry); + reg = removeInstalledPlugin(reg, id); + expect(getInstalledPlugin(reg, id)).toBeUndefined(); + expect(getInstalledPlugin(reg, otherId)).toEqual([otherEntry]); + }); + + it("removeInstalledPlugin throws on not found", () => { + expect(() => removeInstalledPlugin(empty, "ghost@nowhere")).toThrow(/not found/); + }); +}); + +// ── Registry file I/O ──────────────────────────────────────────────── +// Tests use temp directory paths directly — no singleton override needed. + +describe("registry file I/O", () => { + let tmpDir: string; + let marketplacesPath: string; + let installedPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mkt-test-")); + marketplacesPath = path.join(tmpDir, "marketplaces.json"); + installedPath = path.join(tmpDir, "installed_plugins.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ── Marketplaces registry ──────────────────────────────────────── + + it("readMarketplacesRegistry returns empty on missing file", async () => { + const reg = await readMarketplacesRegistry(marketplacesPath); + expect(reg).toEqual({ version: 1, marketplaces: [] }); + }); + + it("readMarketplacesRegistry returns empty on malformed JSON", async () => { + await Bun.write(marketplacesPath, "not json{{{"); + const reg = await readMarketplacesRegistry(marketplacesPath); + expect(reg).toEqual({ version: 1, marketplaces: [] }); + }); + + it("marketplaces registry round-trip", async () => { + const entry: MarketplaceRegistryEntry = { + name: "test-market", + sourceType: "github", + sourceUri: "owner/repo", + catalogPath: path.join(tmpDir, "cache", "marketplaces", "test-market", "marketplace.json"), + addedAt: "2025-01-15T10:00:00.000Z", + updatedAt: "2025-01-15T10:00:00.000Z", + }; + const reg: MarketplacesRegistry = { + version: 1, + marketplaces: [entry], + }; + await writeMarketplacesRegistry(marketplacesPath, reg); + const read = await readMarketplacesRegistry(marketplacesPath); + expect(read).toEqual(reg); + }); + + // ── Installed plugins registry ─────────────────────────────────── + + it("readInstalledPluginsRegistry returns empty on missing file", async () => { + const reg = await readInstalledPluginsRegistry(installedPath); + expect(reg).toEqual({ version: 2, plugins: {} }); + }); + + it("readInstalledPluginsRegistry returns empty on malformed JSON", async () => { + await Bun.write(installedPath, "}{broken"); + const reg = await readInstalledPluginsRegistry(installedPath); + expect(reg).toEqual({ version: 2, plugins: {} }); + }); + + it("installed plugins registry round-trip", async () => { + const entry: InstalledPluginEntry = { + scope: "user", + installPath: path.join(tmpDir, "cache", "plugins", "mkt--plug--1.0.0"), + version: "1.0.0", + installedAt: "2025-01-15T10:30:00.000Z", + lastUpdated: "2025-01-15T10:30:00.000Z", + }; + const reg: InstalledPluginsRegistry = { + version: 2, + plugins: { "plug@mkt": [entry] }, + }; + await writeInstalledPluginsRegistry(installedPath, reg); + const read = await readInstalledPluginsRegistry(installedPath); + expect(read).toEqual(reg); + }); + + it("written installed registry passes Claude Code registry validation", async () => { + const entry: InstalledPluginEntry = { + scope: "user", + installPath: path.join(tmpDir, "cache", "plugins", "mkt--plug--1.0.0"), + version: "1.0.0", + installedAt: "2025-01-15T10:30:00.000Z", + lastUpdated: "2025-01-15T10:30:00.000Z", + }; + const reg: InstalledPluginsRegistry = { + version: 2, + plugins: { "plug@mkt": [entry] }, + }; + await writeInstalledPluginsRegistry(installedPath, reg); + + const content = await Bun.file(installedPath).text(); + const parsed = validateClaudeRegistryFormat(content); + expect(parsed).not.toBeNull(); + expect(parsed!.version).toBe(2); + const plugins = parsed!.plugins as Record; + expect(plugins["plug@mkt"]).toBeDefined(); + }); + + it("atomic write leaves no .tmp file after success", async () => { + const reg: InstalledPluginsRegistry = { version: 2, plugins: {} }; + await writeInstalledPluginsRegistry(installedPath, reg); + + const tmpFilePath = `${installedPath}.tmp`; + expect(fs.existsSync(tmpFilePath)).toBe(false); + }); + + it("read-modify-write preserves unknown fields", async () => { + // Write a registry with an extra field that our types don't define + const data = { + version: 2, + plugins: { + "plug@mkt": [ + { + scope: "user", + installPath: "/some/path", + version: "1.0.0", + installedAt: "2025-01-01T00:00:00.000Z", + lastUpdated: "2025-01-01T00:00:00.000Z", + someExtraField: "preserved", + }, + ], + }, + }; + await Bun.write(installedPath, JSON.stringify(data)); + + const reg = await readInstalledPluginsRegistry(installedPath); + const entries = getInstalledPlugin(reg, "plug@mkt"); + expect(entries).toBeDefined(); + expect((entries![0] as unknown as Record).someExtraField).toBe("preserved"); + }); +}); diff --git a/packages/coding-agent/test/marketplace/source-resolver.test.ts b/packages/coding-agent/test/marketplace/source-resolver.test.ts new file mode 100644 index 000000000..366256da6 --- /dev/null +++ b/packages/coding-agent/test/marketplace/source-resolver.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { MarketplacePluginEntry } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; +import { resolvePluginSource } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; + +// Fixture: a cloned marketplace with a single plugin at ./plugins/hello-plugin +const FIXTURE_DIR = path.resolve(import.meta.dir, "fixtures/valid-marketplace"); + +// Helper — build a minimal MarketplacePluginEntry with the given source +function makeEntry(source: MarketplacePluginEntry["source"]): MarketplacePluginEntry { + return { name: "hello-plugin", source }; +} + +describe("resolvePluginSource", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-src-res-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("resolves relative source to absolute plugin directory", async () => { + const entry = makeEntry("./plugins/hello-plugin"); + const resolved = await resolvePluginSource(entry, { + marketplaceClonePath: FIXTURE_DIR, + tmpDir, + }); + expect(resolved).toBe(path.resolve(FIXTURE_DIR, "plugins/hello-plugin")); + }); + + it("throws when source string would escape marketplace root", async () => { + // "../../escape" does not start with "./" — hits the non-relative guard + const entry = makeEntry("../../escape"); + await expect(resolvePluginSource(entry, { marketplaceClonePath: FIXTURE_DIR, tmpDir })).rejects.toThrow(); + }); + + it("throws when relative source would escape via path traversal (./../../escape)", async () => { + // Starts with "./" but resolves outside marketplace root + const entry = makeEntry("./../../escape"); + await expect(resolvePluginSource(entry, { marketplaceClonePath: FIXTURE_DIR, tmpDir })).rejects.toThrow( + /outside marketplace root/, + ); + }); + + it("throws when marketplaceClonePath is missing for relative source", async () => { + const entry = makeEntry("./plugins/hello-plugin"); + await expect(resolvePluginSource(entry, { tmpDir })).rejects.toThrow(/marketplaceClonePath/); + }); + + it("prepends catalogMetadata.pluginRoot to the relative source path", async () => { + // pluginRoot "plugins" + source "./hello-plugin" → ./plugins/hello-plugin + const entry = makeEntry("./hello-plugin"); + const resolved = await resolvePluginSource(entry, { + marketplaceClonePath: FIXTURE_DIR, + catalogMetadata: { pluginRoot: "plugins" }, + tmpDir, + }); + expect(resolved).toBe(path.resolve(FIXTURE_DIR, "plugins/hello-plugin")); + }); + + // Network-dependent: object sources attempt real git clones + it.skip("resolves github object source via git clone", async () => { + const entry = makeEntry({ source: "github", repo: "nonexistent-owner/nonexistent-repo" }); + await expect(resolvePluginSource(entry, { marketplaceClonePath: FIXTURE_DIR, tmpDir })).rejects.toThrow( + /git clone failed/, + ); + }); + + it.skip("resolves url object source via git clone", async () => { + const entry = makeEntry({ source: "url", url: "https://example.com/nonexistent.git" }); + await expect(resolvePluginSource(entry, { marketplaceClonePath: FIXTURE_DIR, tmpDir })).rejects.toThrow( + /git clone failed/, + ); + }); + + it("throws when resolved directory does not exist", async () => { + const entry = makeEntry("./plugins/nonexistent-plugin"); + await expect(resolvePluginSource(entry, { marketplaceClonePath: FIXTURE_DIR, tmpDir })).rejects.toThrow( + /does not exist/, + ); + }); +}); diff --git a/packages/coding-agent/test/marketplace/substitute-plugin-root.test.ts b/packages/coding-agent/test/marketplace/substitute-plugin-root.test.ts new file mode 100644 index 000000000..741fa574c --- /dev/null +++ b/packages/coding-agent/test/marketplace/substitute-plugin-root.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import { substitutePluginRoot } from "@oh-my-pi/pi-coding-agent/discovery/substitute-plugin-root"; + +// Use concatenation to avoid noTemplateCurlyInString lint rule on literal placeholder names +const CLAUDE_VAR = "$" + "{CLAUDE_PLUGIN_ROOT}"; +const OMP_VAR = "$" + "{OMP_PLUGIN_ROOT}"; + +describe("substitutePluginRoot", () => { + const ROOT = "/plugins/my-plugin"; + + it("replaces CLAUDE_PLUGIN_ROOT in strings", () => { + expect(substitutePluginRoot(`${CLAUDE_VAR}/bin/server`, ROOT)).toBe("/plugins/my-plugin/bin/server"); + }); + + it("replaces OMP_PLUGIN_ROOT in strings", () => { + expect(substitutePluginRoot(`${OMP_VAR}/bin/server`, ROOT)).toBe("/plugins/my-plugin/bin/server"); + }); + + it("replaces both variables in same string", () => { + expect(substitutePluginRoot(`${CLAUDE_VAR}:${OMP_VAR}`, ROOT)).toBe("/plugins/my-plugin:/plugins/my-plugin"); + }); + + it("handles arrays recursively", () => { + expect(substitutePluginRoot(["--config", `${CLAUDE_VAR}/config.json`], ROOT)).toEqual([ + "--config", + "/plugins/my-plugin/config.json", + ]); + }); + + it("handles objects recursively", () => { + expect(substitutePluginRoot({ PATH: `${CLAUDE_VAR}/bin` }, ROOT)).toEqual({ + PATH: "/plugins/my-plugin/bin", + }); + }); + + it("handles nested structures", () => { + const input = { + command: `${CLAUDE_VAR}/server`, + args: ["--port", "3000"], + env: { HOME: OMP_VAR }, + }; + expect(substitutePluginRoot(input, ROOT)).toEqual({ + command: "/plugins/my-plugin/server", + args: ["--port", "3000"], + env: { HOME: "/plugins/my-plugin" }, + }); + }); + + it("passes through non-string primitives", () => { + expect(substitutePluginRoot(42, ROOT)).toBe(42); + expect(substitutePluginRoot(true, ROOT)).toBe(true); + expect(substitutePluginRoot(null, ROOT)).toBeNull(); + expect(substitutePluginRoot(undefined, ROOT)).toBeUndefined(); + }); + + it("returns string unchanged when no variables present", () => { + expect(substitutePluginRoot("no-vars-here", ROOT)).toBe("no-vars-here"); + }); +});