Files
oh-my-openagent/src/features/claude-code-plugin-loader/loader.ts
YeonGyu-Kim dea17dc3ba fix(command-loader): strip incompatible fields before registering with OpenCode
Slash commands with arguments were silently failing in OpenCode TUI because
command definitions included 'name' and 'argumentHint' fields that don't exist
in OpenCode's Command schema. Strip these fields before registration across
all command/skill loaders to ensure compatibility.

Affected loaders:
- builtin commands
- claude-code command loader
- opencode skill loader
- claude-code plugin loader

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:14:59 +09:00

485 lines
15 KiB
TypeScript

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<T>(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<string, unknown> = {}
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<string, boolean> | undefined,
overrideEnabledPlugins: Record<string, boolean> | 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<string, CommandDefinition> {
const commands: Record<string, CommandDefinition> = {}
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<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
${body.trim()}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`
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<string, CommandDefinition> {
const skills: Record<string, CommandDefinition> = {}
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<SkillMetadata>(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 = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
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<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
result[tool.toLowerCase()] = true
}
return result
}
export function loadPluginAgents(
plugins: LoadedPlugin[]
): Record<string, AgentConfig> {
const agents: Record<string, AgentConfig> = {}
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<AgentFrontmatter>(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<Record<string, McpServerConfig>> {
const servers: Record<string, McpServerConfig> = {}
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
}
export interface PluginComponentsResult {
commands: Record<string, CommandDefinition>
skills: Record<string, CommandDefinition>
agents: Record<string, AgentConfig>
mcpServers: Record<string, McpServerConfig>
hooksConfigs: HooksConfig[]
plugins: LoadedPlugin[]
errors: PluginLoadError[]
}
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
const { plugins, errors } = discoverInstalledPlugins(options)
const commands = loadPluginCommands(plugins)
const skills = loadPluginSkillsAsCommands(plugins)
const agents = loadPluginAgents(plugins)
const mcpServers = await loadPluginMcpServers(plugins)
const hooksConfigs = loadPluginHooksConfigs(plugins)
log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`)
return {
commands,
skills,
agents,
mcpServers,
hooksConfigs,
plugins,
errors,
}
}