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