From 39dc62c62af5de806409b2df62e6f10ce11bf40c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:37 +0900 Subject: [PATCH] refactor(claude-code-plugin-loader): split loader.ts into per-type loaders Extract plugin component loading into dedicated modules: - discovery.ts: plugin directory detection - plugin-path-resolver.ts: path resolution logic - agent-loader.ts, command-loader.ts, hook-loader.ts - mcp-server-loader.ts, skill-loader.ts --- .../claude-code-plugin-loader/agent-loader.ts | 69 +++ .../command-loader.ts | 53 ++ .../claude-code-plugin-loader/discovery.ts | 180 +++++++ .../claude-code-plugin-loader/hook-loader.ts | 26 + .../claude-code-plugin-loader/index.ts | 7 + .../claude-code-plugin-loader/loader.ts | 468 +----------------- .../mcp-server-loader.ts | 48 ++ .../plugin-path-resolver.ts | 23 + .../claude-code-plugin-loader/skill-loader.ts | 60 +++ 9 files changed, 483 insertions(+), 451 deletions(-) create mode 100644 src/features/claude-code-plugin-loader/agent-loader.ts create mode 100644 src/features/claude-code-plugin-loader/command-loader.ts create mode 100644 src/features/claude-code-plugin-loader/discovery.ts create mode 100644 src/features/claude-code-plugin-loader/hook-loader.ts create mode 100644 src/features/claude-code-plugin-loader/mcp-server-loader.ts create mode 100644 src/features/claude-code-plugin-loader/plugin-path-resolver.ts create mode 100644 src/features/claude-code-plugin-loader/skill-loader.ts diff --git a/src/features/claude-code-plugin-loader/agent-loader.ts b/src/features/claude-code-plugin-loader/agent-loader.ts new file mode 100644 index 000000000..0f52dac52 --- /dev/null +++ b/src/features/claude-code-plugin-loader/agent-loader.ts @@ -0,0 +1,69 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { basename, join } from "path" +import type { AgentConfig } from "@opencode-ai/sdk" +import { parseFrontmatter } from "../../shared/frontmatter" +import { isMarkdownFile } from "../../shared/file-utils" +import { log } from "../../shared/logger" +import type { AgentFrontmatter } from "../claude-code-agent-loader/types" +import type { LoadedPlugin } from "./types" + +function parseToolsConfig(toolsStr?: string): Record | undefined { + if (!toolsStr) return undefined + + const tools = toolsStr + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean) + + if (tools.length === 0) return undefined + + const result: Record = {} + for (const tool of tools) { + result[tool.toLowerCase()] = true + } + return result +} + +export function loadPluginAgents(plugins: LoadedPlugin[]): Record { + const agents: Record = {} + + for (const plugin of plugins) { + if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue + + const entries = readdirSync(plugin.agentsDir, { withFileTypes: true }) + + for (const entry of entries) { + if (!isMarkdownFile(entry)) continue + + const agentPath = join(plugin.agentsDir, entry.name) + const agentName = basename(entry.name, ".md") + const namespacedName = `${plugin.name}:${agentName}` + + try { + const content = readFileSync(agentPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const originalDescription = data.description || "" + const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}` + + const config: AgentConfig = { + description: formattedDescription, + mode: "subagent", + prompt: body.trim(), + } + + const toolsConfig = parseToolsConfig(data.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + agents[namespacedName] = config + log(`Loaded plugin agent: ${namespacedName}`, { path: agentPath }) + } catch (error) { + log(`Failed to load plugin agent: ${agentPath}`, error) + } + } + } + + return agents +} diff --git a/src/features/claude-code-plugin-loader/command-loader.ts b/src/features/claude-code-plugin-loader/command-loader.ts new file mode 100644 index 000000000..9b55cd6b0 --- /dev/null +++ b/src/features/claude-code-plugin-loader/command-loader.ts @@ -0,0 +1,53 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { basename, join } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { isMarkdownFile } from "../../shared/file-utils" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import { log } from "../../shared/logger" +import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types" +import type { LoadedPlugin } from "./types" + +export function loadPluginCommands(plugins: LoadedPlugin[]): Record { + const commands: Record = {} + + for (const plugin of plugins) { + if (!plugin.commandsDir || !existsSync(plugin.commandsDir)) continue + + const entries = readdirSync(plugin.commandsDir, { withFileTypes: true }) + + for (const entry of entries) { + if (!isMarkdownFile(entry)) continue + + const commandPath = join(plugin.commandsDir, entry.name) + const commandName = basename(entry.name, ".md") + const namespacedName = `${plugin.name}:${commandName}` + + try { + const content = readFileSync(commandPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const wrappedTemplate = `\n${body.trim()}\n\n\n\n$ARGUMENTS\n` + const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}` + + const definition = { + name: namespacedName, + description: formattedDescription, + template: wrappedTemplate, + agent: data.agent, + model: sanitizeModelField(data.model, "claude-code"), + subtask: data.subtask, + argumentHint: data["argument-hint"], + } + + const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = definition + commands[namespacedName] = openCodeCompatible as CommandDefinition + + log(`Loaded plugin command: ${namespacedName}`, { path: commandPath }) + } catch (error) { + log(`Failed to load plugin command: ${commandPath}`, error) + } + } + } + + return commands +} diff --git a/src/features/claude-code-plugin-loader/discovery.ts b/src/features/claude-code-plugin-loader/discovery.ts new file mode 100644 index 000000000..c7c46a803 --- /dev/null +++ b/src/features/claude-code-plugin-loader/discovery.ts @@ -0,0 +1,180 @@ +import { existsSync, readFileSync } from "fs" +import { homedir } from "os" +import { join } from "path" +import { log } from "../../shared/logger" +import type { + InstalledPluginsDatabase, + PluginInstallation, + PluginManifest, + LoadedPlugin, + PluginLoadResult, + PluginLoadError, + PluginScope, + ClaudeSettings, + PluginLoaderOptions, +} from "./types" + +function getPluginsBaseDir(): string { + if (process.env.CLAUDE_PLUGINS_HOME) { + return process.env.CLAUDE_PLUGINS_HOME + } + return join(homedir(), ".claude", "plugins") +} + +function getInstalledPluginsPath(): string { + return join(getPluginsBaseDir(), "installed_plugins.json") +} + +function loadInstalledPlugins(): InstalledPluginsDatabase | null { + const dbPath = getInstalledPluginsPath() + if (!existsSync(dbPath)) { + return null + } + + try { + const content = readFileSync(dbPath, "utf-8") + return JSON.parse(content) as InstalledPluginsDatabase + } catch (error) { + log("Failed to load installed plugins database", error) + return null + } +} + +function getClaudeSettingsPath(): string { + if (process.env.CLAUDE_SETTINGS_PATH) { + return process.env.CLAUDE_SETTINGS_PATH + } + return join(homedir(), ".claude", "settings.json") +} + +function loadClaudeSettings(): ClaudeSettings | null { + const settingsPath = getClaudeSettingsPath() + if (!existsSync(settingsPath)) { + return null + } + + try { + const content = readFileSync(settingsPath, "utf-8") + return JSON.parse(content) as ClaudeSettings + } catch (error) { + log("Failed to load Claude settings", error) + return null + } +} + +function loadPluginManifest(installPath: string): PluginManifest | null { + const manifestPath = join(installPath, ".claude-plugin", "plugin.json") + if (!existsSync(manifestPath)) { + return null + } + + try { + const content = readFileSync(manifestPath, "utf-8") + return JSON.parse(content) as PluginManifest + } catch (error) { + log(`Failed to load plugin manifest from ${manifestPath}`, error) + return null + } +} + +function derivePluginNameFromKey(pluginKey: string): string { + const atIndex = pluginKey.indexOf("@") + return atIndex > 0 ? pluginKey.substring(0, atIndex) : pluginKey +} + +function isPluginEnabled( + pluginKey: string, + settingsEnabledPlugins: Record | undefined, + overrideEnabledPlugins: Record | undefined, +): boolean { + if (overrideEnabledPlugins && pluginKey in overrideEnabledPlugins) { + return overrideEnabledPlugins[pluginKey] + } + if (settingsEnabledPlugins && pluginKey in settingsEnabledPlugins) { + return settingsEnabledPlugins[pluginKey] + } + return true +} + +function extractPluginEntries( + db: InstalledPluginsDatabase, +): Array<[string, PluginInstallation | undefined]> { + if (db.version === 1) { + return Object.entries(db.plugins).map(([key, installation]) => [key, installation]) + } + return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]]) +} + +export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult { + const db = loadInstalledPlugins() + const settings = loadClaudeSettings() + const plugins: LoadedPlugin[] = [] + const errors: PluginLoadError[] = [] + + if (!db || !db.plugins) { + return { plugins, errors } + } + + const settingsEnabledPlugins = settings?.enabledPlugins + const overrideEnabledPlugins = options?.enabledPluginsOverride + + for (const [pluginKey, installation] of extractPluginEntries(db)) { + if (!installation) continue + + if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) { + log(`Plugin disabled: ${pluginKey}`) + continue + } + + const { installPath, scope, version } = installation + + if (!existsSync(installPath)) { + errors.push({ + pluginKey, + installPath, + error: "Plugin installation path does not exist", + }) + continue + } + + const manifest = loadPluginManifest(installPath) + const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey) + + const loadedPlugin: LoadedPlugin = { + name: pluginName, + version: version || manifest?.version || "unknown", + scope: scope as PluginScope, + installPath, + pluginKey, + manifest: manifest ?? undefined, + } + + if (existsSync(join(installPath, "commands"))) { + loadedPlugin.commandsDir = join(installPath, "commands") + } + if (existsSync(join(installPath, "agents"))) { + loadedPlugin.agentsDir = join(installPath, "agents") + } + if (existsSync(join(installPath, "skills"))) { + loadedPlugin.skillsDir = join(installPath, "skills") + } + + const hooksPath = join(installPath, "hooks", "hooks.json") + if (existsSync(hooksPath)) { + loadedPlugin.hooksPath = hooksPath + } + + const mcpPath = join(installPath, ".mcp.json") + if (existsSync(mcpPath)) { + loadedPlugin.mcpPath = mcpPath + } + + plugins.push(loadedPlugin) + log(`Discovered plugin: ${pluginName}@${version} (${scope})`, { + installPath, + hasManifest: !!manifest, + }) + } + + return { plugins, errors } +} diff --git a/src/features/claude-code-plugin-loader/hook-loader.ts b/src/features/claude-code-plugin-loader/hook-loader.ts new file mode 100644 index 000000000..8f2a8c4c0 --- /dev/null +++ b/src/features/claude-code-plugin-loader/hook-loader.ts @@ -0,0 +1,26 @@ +import { existsSync, readFileSync } from "fs" +import { log } from "../../shared/logger" +import type { HooksConfig, LoadedPlugin } from "./types" +import { resolvePluginPaths } from "./plugin-path-resolver" + +export function loadPluginHooksConfigs(plugins: LoadedPlugin[]): HooksConfig[] { + const configs: HooksConfig[] = [] + + for (const plugin of plugins) { + if (!plugin.hooksPath || !existsSync(plugin.hooksPath)) continue + + try { + const content = readFileSync(plugin.hooksPath, "utf-8") + let config = JSON.parse(content) as HooksConfig + + config = resolvePluginPaths(config, plugin.installPath) + + configs.push(config) + log(`Loaded plugin hooks config from ${plugin.name}`, { path: plugin.hooksPath }) + } catch (error) { + log(`Failed to load plugin hooks config: ${plugin.hooksPath}`, error) + } + } + + return configs +} diff --git a/src/features/claude-code-plugin-loader/index.ts b/src/features/claude-code-plugin-loader/index.ts index e95b6a4e3..142986fee 100644 --- a/src/features/claude-code-plugin-loader/index.ts +++ b/src/features/claude-code-plugin-loader/index.ts @@ -1,3 +1,10 @@ export * from "./types" export * from "./loader" +export * from "./discovery" +export * from "./plugin-path-resolver" +export * from "./command-loader" +export * from "./skill-loader" +export * from "./agent-loader" +export * from "./mcp-server-loader" +export * from "./hook-loader" export type { PluginLoaderOptions, ClaudeSettings } from "./types" diff --git a/src/features/claude-code-plugin-loader/loader.ts b/src/features/claude-code-plugin-loader/loader.ts index 16771ad94..7c027cf39 100644 --- a/src/features/claude-code-plugin-loader/loader.ts +++ b/src/features/claude-code-plugin-loader/loader.ts @@ -1,455 +1,21 @@ -import { existsSync, readdirSync, readFileSync } from "fs" -import { homedir } from "os" -import { join, basename } from "path" -import type { AgentConfig } from "@opencode-ai/sdk" -import { parseFrontmatter } from "../../shared/frontmatter" -import { sanitizeModelField } from "../../shared/model-sanitizer" -import { isMarkdownFile, resolveSymlink } from "../../shared/file-utils" import { log } from "../../shared/logger" -import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" -import { transformMcpServer } from "../claude-code-mcp-loader/transformer" -import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types" -import type { SkillMetadata } from "../opencode-skill-loader/types" -import type { AgentFrontmatter } from "../claude-code-agent-loader/types" -import type { ClaudeCodeMcpConfig, McpServerConfig } from "../claude-code-mcp-loader/types" -import type { - InstalledPluginsDatabase, - PluginInstallation, - PluginManifest, - LoadedPlugin, - PluginLoadResult, - PluginLoadError, - PluginScope, - HooksConfig, - ClaudeSettings, - PluginLoaderOptions, -} from "./types" - -const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}" - -function getPluginsBaseDir(): string { - // Allow override for testing - if (process.env.CLAUDE_PLUGINS_HOME) { - return process.env.CLAUDE_PLUGINS_HOME - } - return join(homedir(), ".claude", "plugins") -} - -function getInstalledPluginsPath(): string { - return join(getPluginsBaseDir(), "installed_plugins.json") -} - -function resolvePluginPath(path: string, pluginRoot: string): string { - return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot) -} - -function resolvePluginPaths(obj: T, pluginRoot: string): T { - if (obj === null || obj === undefined) return obj - if (typeof obj === "string") { - return resolvePluginPath(obj, pluginRoot) as T - } - if (Array.isArray(obj)) { - return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T - } - if (typeof obj === "object") { - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - result[key] = resolvePluginPaths(value, pluginRoot) - } - return result as T - } - return obj -} - -function loadInstalledPlugins(): InstalledPluginsDatabase | null { - const dbPath = getInstalledPluginsPath() - if (!existsSync(dbPath)) { - return null - } - - try { - const content = readFileSync(dbPath, "utf-8") - return JSON.parse(content) as InstalledPluginsDatabase - } catch (error) { - log("Failed to load installed plugins database", error) - return null - } -} - -function getClaudeSettingsPath(): string { - if (process.env.CLAUDE_SETTINGS_PATH) { - return process.env.CLAUDE_SETTINGS_PATH - } - return join(homedir(), ".claude", "settings.json") -} - -function loadClaudeSettings(): ClaudeSettings | null { - const settingsPath = getClaudeSettingsPath() - if (!existsSync(settingsPath)) { - return null - } - - try { - const content = readFileSync(settingsPath, "utf-8") - return JSON.parse(content) as ClaudeSettings - } catch (error) { - log("Failed to load Claude settings", error) - return null - } -} - -function loadPluginManifest(installPath: string): PluginManifest | null { - const manifestPath = join(installPath, ".claude-plugin", "plugin.json") - if (!existsSync(manifestPath)) { - return null - } - - try { - const content = readFileSync(manifestPath, "utf-8") - return JSON.parse(content) as PluginManifest - } catch (error) { - log(`Failed to load plugin manifest from ${manifestPath}`, error) - return null - } -} - -function derivePluginNameFromKey(pluginKey: string): string { - const atIndex = pluginKey.indexOf("@") - if (atIndex > 0) { - return pluginKey.substring(0, atIndex) - } - return pluginKey -} - -function isPluginEnabled( - pluginKey: string, - settingsEnabledPlugins: Record | undefined, - overrideEnabledPlugins: Record | undefined -): boolean { - if (overrideEnabledPlugins && pluginKey in overrideEnabledPlugins) { - return overrideEnabledPlugins[pluginKey] - } - if (settingsEnabledPlugins && pluginKey in settingsEnabledPlugins) { - return settingsEnabledPlugins[pluginKey] - } - return true -} - -function extractPluginEntries( - db: InstalledPluginsDatabase -): Array<[string, PluginInstallation | undefined]> { - if (db.version === 1) { - return Object.entries(db.plugins).map(([key, installation]) => [key, installation]) - } - return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]]) -} - -export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult { - const db = loadInstalledPlugins() - const settings = loadClaudeSettings() - const plugins: LoadedPlugin[] = [] - const errors: PluginLoadError[] = [] - - if (!db || !db.plugins) { - return { plugins, errors } - } - - const settingsEnabledPlugins = settings?.enabledPlugins - const overrideEnabledPlugins = options?.enabledPluginsOverride - - for (const [pluginKey, installation] of extractPluginEntries(db)) { - if (!installation) continue - - if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) { - log(`Plugin disabled: ${pluginKey}`) - continue - } - - const { installPath, scope, version } = installation - - if (!existsSync(installPath)) { - errors.push({ - pluginKey, - installPath, - error: "Plugin installation path does not exist", - }) - continue - } - - const manifest = loadPluginManifest(installPath) - const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey) - - const loadedPlugin: LoadedPlugin = { - name: pluginName, - version: version || manifest?.version || "unknown", - scope: scope as PluginScope, - installPath, - pluginKey, - manifest: manifest ?? undefined, - } - - if (existsSync(join(installPath, "commands"))) { - loadedPlugin.commandsDir = join(installPath, "commands") - } - if (existsSync(join(installPath, "agents"))) { - loadedPlugin.agentsDir = join(installPath, "agents") - } - if (existsSync(join(installPath, "skills"))) { - loadedPlugin.skillsDir = join(installPath, "skills") - } - - const hooksPath = join(installPath, "hooks", "hooks.json") - if (existsSync(hooksPath)) { - loadedPlugin.hooksPath = hooksPath - } - - const mcpPath = join(installPath, ".mcp.json") - if (existsSync(mcpPath)) { - loadedPlugin.mcpPath = mcpPath - } - - plugins.push(loadedPlugin) - log(`Discovered plugin: ${pluginName}@${version} (${scope})`, { installPath, hasManifest: !!manifest }) - } - - return { plugins, errors } -} - -export function loadPluginCommands( - plugins: LoadedPlugin[] -): Record { - const commands: Record = {} - - for (const plugin of plugins) { - if (!plugin.commandsDir || !existsSync(plugin.commandsDir)) continue - - const entries = readdirSync(plugin.commandsDir, { withFileTypes: true }) - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const commandPath = join(plugin.commandsDir, entry.name) - const commandName = basename(entry.name, ".md") - const namespacedName = `${plugin.name}:${commandName}` - - try { - const content = readFileSync(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const wrappedTemplate = ` -${body.trim()} - - - -$ARGUMENTS -` - - const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}` - - const definition = { - name: namespacedName, - description: formattedDescription, - template: wrappedTemplate, - agent: data.agent, - model: sanitizeModelField(data.model, "claude-code"), - subtask: data.subtask, - argumentHint: data["argument-hint"], - } - const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = definition - commands[namespacedName] = openCodeCompatible as CommandDefinition - - log(`Loaded plugin command: ${namespacedName}`, { path: commandPath }) - } catch (error) { - log(`Failed to load plugin command: ${commandPath}`, error) - } - } - } - - return commands -} - -export function loadPluginSkillsAsCommands( - plugins: LoadedPlugin[] -): Record { - const skills: Record = {} - - for (const plugin of plugins) { - if (!plugin.skillsDir || !existsSync(plugin.skillsDir)) continue - - const entries = readdirSync(plugin.skillsDir, { withFileTypes: true }) - - for (const entry of entries) { - if (entry.name.startsWith(".")) continue - - const skillPath = join(plugin.skillsDir, entry.name) - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue - - const resolvedPath = resolveSymlink(skillPath) - const skillMdPath = join(resolvedPath, "SKILL.md") - if (!existsSync(skillMdPath)) continue - - try { - const content = readFileSync(skillMdPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const skillName = data.name || entry.name - const namespacedName = `${plugin.name}:${skillName}` - const originalDescription = data.description || "" - const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}` - - const wrappedTemplate = ` -Base directory for this skill: ${resolvedPath}/ -File references (@path) in this skill are relative to this directory. - -${body.trim()} - - - -$ARGUMENTS -` - - const definition = { - name: namespacedName, - description: formattedDescription, - template: wrappedTemplate, - model: sanitizeModelField(data.model), - } - const { name: _name, ...openCodeCompatible } = definition - skills[namespacedName] = openCodeCompatible as CommandDefinition - - log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath }) - } catch (error) { - log(`Failed to load plugin skill: ${skillPath}`, error) - } - } - } - - return skills -} - -function parseToolsConfig(toolsStr?: string): Record | undefined { - if (!toolsStr) return undefined - - const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} - -export function loadPluginAgents( - plugins: LoadedPlugin[] -): Record { - const agents: Record = {} - - for (const plugin of plugins) { - if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue - - const entries = readdirSync(plugin.agentsDir, { withFileTypes: true }) - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const agentPath = join(plugin.agentsDir, entry.name) - const agentName = basename(entry.name, ".md") - const namespacedName = `${plugin.name}:${agentName}` - - try { - const content = readFileSync(agentPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const name = data.name || agentName - const originalDescription = data.description || "" - const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}` - - const config: AgentConfig = { - description: formattedDescription, - mode: "subagent", - prompt: body.trim(), - } - - const toolsConfig = parseToolsConfig(data.tools) - if (toolsConfig) { - config.tools = toolsConfig - } - - agents[namespacedName] = config - log(`Loaded plugin agent: ${namespacedName}`, { path: agentPath }) - } catch (error) { - log(`Failed to load plugin agent: ${agentPath}`, error) - } - } - } - - return agents -} - -export async function loadPluginMcpServers( - plugins: LoadedPlugin[] -): Promise> { - const servers: Record = {} - - for (const plugin of plugins) { - if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue - - try { - const content = await Bun.file(plugin.mcpPath).text() - let config = JSON.parse(content) as ClaudeCodeMcpConfig - - config = resolvePluginPaths(config, plugin.installPath) - config = expandEnvVarsInObject(config) - - if (!config.mcpServers) continue - - for (const [name, serverConfig] of Object.entries(config.mcpServers)) { - if (serverConfig.disabled) { - log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`) - continue - } - - try { - const transformed = transformMcpServer(name, serverConfig) - const namespacedName = `${plugin.name}:${name}` - servers[namespacedName] = transformed - log(`Loaded plugin MCP server: ${namespacedName}`, { path: plugin.mcpPath }) - } catch (error) { - log(`Failed to transform plugin MCP server "${name}"`, error) - } - } - } catch (error) { - log(`Failed to load plugin MCP config: ${plugin.mcpPath}`, error) - } - } - - return servers -} - -export function loadPluginHooksConfigs( - plugins: LoadedPlugin[] -): HooksConfig[] { - const configs: HooksConfig[] = [] - - for (const plugin of plugins) { - if (!plugin.hooksPath || !existsSync(plugin.hooksPath)) continue - - try { - const content = readFileSync(plugin.hooksPath, "utf-8") - let config = JSON.parse(content) as HooksConfig - - config = resolvePluginPaths(config, plugin.installPath) - - configs.push(config) - log(`Loaded plugin hooks config from ${plugin.name}`, { path: plugin.hooksPath }) - } catch (error) { - log(`Failed to load plugin hooks config: ${plugin.hooksPath}`, error) - } - } - - return configs -} +import type { AgentConfig } from "@opencode-ai/sdk" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import type { McpServerConfig } from "../claude-code-mcp-loader/types" +import type { HooksConfig, LoadedPlugin, PluginLoadError, PluginLoaderOptions } from "./types" +import { discoverInstalledPlugins } from "./discovery" +import { loadPluginCommands } from "./command-loader" +import { loadPluginSkillsAsCommands } from "./skill-loader" +import { loadPluginAgents } from "./agent-loader" +import { loadPluginMcpServers } from "./mcp-server-loader" +import { loadPluginHooksConfigs } from "./hook-loader" + +export { discoverInstalledPlugins } from "./discovery" +export { loadPluginCommands } from "./command-loader" +export { loadPluginSkillsAsCommands } from "./skill-loader" +export { loadPluginAgents } from "./agent-loader" +export { loadPluginMcpServers } from "./mcp-server-loader" +export { loadPluginHooksConfigs } from "./hook-loader" export interface PluginComponentsResult { commands: Record diff --git a/src/features/claude-code-plugin-loader/mcp-server-loader.ts b/src/features/claude-code-plugin-loader/mcp-server-loader.ts new file mode 100644 index 000000000..9fcfba231 --- /dev/null +++ b/src/features/claude-code-plugin-loader/mcp-server-loader.ts @@ -0,0 +1,48 @@ +import { existsSync } from "fs" +import type { McpServerConfig } from "../claude-code-mcp-loader/types" +import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" +import { transformMcpServer } from "../claude-code-mcp-loader/transformer" +import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types" +import { log } from "../../shared/logger" +import type { LoadedPlugin } from "./types" +import { resolvePluginPaths } from "./plugin-path-resolver" + +export async function loadPluginMcpServers( + plugins: LoadedPlugin[], +): Promise> { + const servers: Record = {} + + for (const plugin of plugins) { + if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue + + try { + const content = await Bun.file(plugin.mcpPath).text() + let config = JSON.parse(content) as ClaudeCodeMcpConfig + + config = resolvePluginPaths(config, plugin.installPath) + config = expandEnvVarsInObject(config) + + if (!config.mcpServers) continue + + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + if (serverConfig.disabled) { + log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`) + continue + } + + try { + const transformed = transformMcpServer(name, serverConfig) + const namespacedName = `${plugin.name}:${name}` + servers[namespacedName] = transformed + log(`Loaded plugin MCP server: ${namespacedName}`, { path: plugin.mcpPath }) + } catch (error) { + log(`Failed to transform plugin MCP server "${name}"`, error) + } + } + } catch (error) { + log(`Failed to load plugin MCP config: ${plugin.mcpPath}`, error) + } + } + + return servers +} diff --git a/src/features/claude-code-plugin-loader/plugin-path-resolver.ts b/src/features/claude-code-plugin-loader/plugin-path-resolver.ts new file mode 100644 index 000000000..c8806aa58 --- /dev/null +++ b/src/features/claude-code-plugin-loader/plugin-path-resolver.ts @@ -0,0 +1,23 @@ +const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}" + +export function resolvePluginPath(path: string, pluginRoot: string): string { + return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot) +} + +export function resolvePluginPaths(obj: T, pluginRoot: string): T { + if (obj === null || obj === undefined) return obj + if (typeof obj === "string") { + return resolvePluginPath(obj, pluginRoot) as T + } + if (Array.isArray(obj)) { + return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T + } + if (typeof obj === "object") { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = resolvePluginPaths(value, pluginRoot) + } + return result as T + } + return obj +} diff --git a/src/features/claude-code-plugin-loader/skill-loader.ts b/src/features/claude-code-plugin-loader/skill-loader.ts new file mode 100644 index 000000000..391f07601 --- /dev/null +++ b/src/features/claude-code-plugin-loader/skill-loader.ts @@ -0,0 +1,60 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { join } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { resolveSymlink } from "../../shared/file-utils" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import { log } from "../../shared/logger" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import type { SkillMetadata } from "../opencode-skill-loader/types" +import type { LoadedPlugin } from "./types" + +export function loadPluginSkillsAsCommands( + plugins: LoadedPlugin[], +): Record { + const skills: Record = {} + + for (const plugin of plugins) { + if (!plugin.skillsDir || !existsSync(plugin.skillsDir)) continue + + const entries = readdirSync(plugin.skillsDir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue + + const skillPath = join(plugin.skillsDir, entry.name) + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue + + const resolvedPath = resolveSymlink(skillPath) + const skillMdPath = join(resolvedPath, "SKILL.md") + if (!existsSync(skillMdPath)) continue + + try { + const content = readFileSync(skillMdPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const skillName = data.name || entry.name + const namespacedName = `${plugin.name}:${skillName}` + const originalDescription = data.description || "" + const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}` + + const wrappedTemplate = `\nBase directory for this skill: ${resolvedPath}/\nFile references (@path) in this skill are relative to this directory.\n\n${body.trim()}\n\n\n\n$ARGUMENTS\n` + + const definition = { + name: namespacedName, + description: formattedDescription, + template: wrappedTemplate, + model: sanitizeModelField(data.model), + } + + const { name: _name, ...openCodeCompatible } = definition + skills[namespacedName] = openCodeCompatible as CommandDefinition + + log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath }) + } catch (error) { + log(`Failed to load plugin skill: ${skillPath}`, error) + } + } + } + + return skills +}