From 17e8746eff098e47eceb175dd6a70cd527c73e76 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Tue, 30 Dec 2025 10:42:05 +0900 Subject: [PATCH] feat: add opencode-skill-loader with 4-source priority system (#331) * feat: add opencode-skill-loader with 4-source priority system - Create new opencode-skill-loader feature module independent from Claude Code - Support 4 source paths with priority: opencode-project > project > opencode > user - .opencode/skill/ (opencode-project) - .claude/skills/ (project) - ~/.config/opencode/skill/ (opencode) - ~/.claude/skills/ (user) - Support both SKILL.md and {SKILLNAME}.md file patterns - Maintain path awareness for file references (@path syntax) * feat: integrate opencode-skill-loader into main plugin - Import and use new skill loader functions - Load skills from all 4 sources and merge into config.command - Also merge pluginComponents.skills (previously loaded but never used) * feat: add skill discovery to slashcommand tool - Import and use discoverAllSkills from opencode-skill-loader - Display skills alongside commands in tool description and execution - Update formatCommandList to handle combined commands and skills * refactor: remove old claude-code-skill-loader - Delete src/features/claude-code-skill-loader/ directory (was never integrated into main plugin) - Update plugin loader import to use new opencode-skill-loader types * docs: update AGENTS.md for new skill loader - Update structure to show opencode-skill-loader instead of claude-code-skill-loader - Update Skills priority order to include all 4 sources --------- Co-authored-by: sisyphus-dev-ai --- src/features/AGENTS.md | 4 +- .../claude-code-plugin-loader/loader.ts | 2 +- .../claude-code-skill-loader/loader.ts | 86 --------- .../claude-code-skill-loader/types.ts | 16 -- .../index.ts | 0 src/features/opencode-skill-loader/loader.ts | 179 ++++++++++++++++++ src/features/opencode-skill-loader/types.ts | 20 ++ src/index.ts | 17 ++ src/tools/slashcommand/tools.ts | 64 +++++-- 9 files changed, 268 insertions(+), 120 deletions(-) delete mode 100644 src/features/claude-code-skill-loader/loader.ts delete mode 100644 src/features/claude-code-skill-loader/types.ts rename src/features/{claude-code-skill-loader => opencode-skill-loader}/index.ts (100%) create mode 100644 src/features/opencode-skill-loader/loader.ts create mode 100644 src/features/opencode-skill-loader/types.ts diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 8997104..a70292e 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -19,7 +19,7 @@ features/ │ └── env-expander.ts # ${VAR} expansion ├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json ├── claude-code-session-state/ # Session state persistence -├── claude-code-skill-loader/ # Load skills from ~/.claude/skills/*/SKILL.md +├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths └── hook-message-injector/ # Inject messages into conversation ``` @@ -30,7 +30,7 @@ Each loader reads from multiple directories (highest priority first): | Loader | Priority Order | |--------|---------------| | Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` | -| Skills | `.claude/skills/` > `~/.claude/skills/` | +| Skills | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` | | Agents | `.claude/agents/` > `~/.claude/agents/` | | MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` | diff --git a/src/features/claude-code-plugin-loader/loader.ts b/src/features/claude-code-plugin-loader/loader.ts index 72eacc3..9042ac1 100644 --- a/src/features/claude-code-plugin-loader/loader.ts +++ b/src/features/claude-code-plugin-loader/loader.ts @@ -9,7 +9,7 @@ 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 "../claude-code-skill-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 { diff --git a/src/features/claude-code-skill-loader/loader.ts b/src/features/claude-code-skill-loader/loader.ts deleted file mode 100644 index 51e3bd6..0000000 --- a/src/features/claude-code-skill-loader/loader.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "fs" -import { join } from "path" -import { parseFrontmatter } from "../../shared/frontmatter" -import { sanitizeModelField } from "../../shared/model-sanitizer" -import { resolveSymlink } from "../../shared/file-utils" -import { getClaudeConfigDir } from "../../shared" -import type { CommandDefinition } from "../claude-code-command-loader/types" -import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types" - -function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] { - if (!existsSync(skillsDir)) { - return [] - } - - const entries = readdirSync(skillsDir, { withFileTypes: true }) - const skills: LoadedSkillAsCommand[] = [] - - for (const entry of entries) { - if (entry.name.startsWith(".")) continue - - const skillPath = join(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 originalDescription = data.description || "" - const formattedDescription = `(${scope} - 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: CommandDefinition = { - name: skillName, - description: formattedDescription, - template: wrappedTemplate, - model: sanitizeModelField(data.model), - } - - skills.push({ - name: skillName, - path: resolvedPath, - definition, - scope, - }) - } catch { - continue - } - } - - return skills -} - -export function loadUserSkillsAsCommands(): Record { - const userSkillsDir = join(getClaudeConfigDir(), "skills") - const skills = loadSkillsFromDir(userSkillsDir, "user") - return skills.reduce((acc, skill) => { - acc[skill.name] = skill.definition - return acc - }, {} as Record) -} - -export function loadProjectSkillsAsCommands(): Record { - const projectSkillsDir = join(process.cwd(), ".claude", "skills") - const skills = loadSkillsFromDir(projectSkillsDir, "project") - return skills.reduce((acc, skill) => { - acc[skill.name] = skill.definition - return acc - }, {} as Record) -} diff --git a/src/features/claude-code-skill-loader/types.ts b/src/features/claude-code-skill-loader/types.ts deleted file mode 100644 index 4b7b555..0000000 --- a/src/features/claude-code-skill-loader/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CommandDefinition } from "../claude-code-command-loader/types" - -export type SkillScope = "user" | "project" - -export interface SkillMetadata { - name: string - description: string - model?: string -} - -export interface LoadedSkillAsCommand { - name: string - path: string - definition: CommandDefinition - scope: SkillScope -} diff --git a/src/features/claude-code-skill-loader/index.ts b/src/features/opencode-skill-loader/index.ts similarity index 100% rename from src/features/claude-code-skill-loader/index.ts rename to src/features/opencode-skill-loader/index.ts diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts new file mode 100644 index 0000000..9c06614 --- /dev/null +++ b/src/features/opencode-skill-loader/loader.ts @@ -0,0 +1,179 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { join, basename, dirname } from "path" +import { homedir } from "os" +import { parseFrontmatter } from "../../shared/frontmatter" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils" +import { getClaudeConfigDir } from "../../shared" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import type { SkillScope, SkillMetadata, LoadedSkill } from "./types" + +/** + * Load a skill from a markdown file path. + * + * @param skillPath - Path to the skill file (SKILL.md or {name}.md) + * @param resolvedPath - Directory for file reference resolution (@path references) + * @param defaultName - Fallback name if not specified in frontmatter + * @param scope - Source scope for priority ordering + */ +function loadSkillFromPath( + skillPath: string, + resolvedPath: string, + defaultName: string, + scope: SkillScope +): LoadedSkill | null { + try { + const content = readFileSync(skillPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const skillName = data.name || defaultName + const originalDescription = data.description || "" + const isOpencodeSource = scope === "opencode" || scope === "opencode-project" + const formattedDescription = `(${scope} - 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: CommandDefinition = { + name: skillName, + description: formattedDescription, + template: wrappedTemplate, + model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), + agent: data.agent, + subtask: data.subtask, + argumentHint: data["argument-hint"], + } + + return { + name: skillName, + path: skillPath, + resolvedPath, + definition, + scope, + } + } catch { + return null + } +} + +/** + * Load skills from a directory, supporting BOTH patterns: + * - Directory with SKILL.md: skill-name/SKILL.md + * - Directory with {SKILLNAME}.md: skill-name/{SKILLNAME}.md + * - Direct markdown file: skill-name.md + */ +function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[] { + if (!existsSync(skillsDir)) { + return [] + } + + const entries = readdirSync(skillsDir, { withFileTypes: true }) + const skills: LoadedSkill[] = [] + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue + + const entryPath = join(skillsDir, entry.name) + + if (entry.isDirectory() || entry.isSymbolicLink()) { + const resolvedPath = resolveSymlink(entryPath) + const dirName = entry.name + + const skillMdPath = join(resolvedPath, "SKILL.md") + if (existsSync(skillMdPath)) { + const skill = loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope) + if (skill) skills.push(skill) + continue + } + + const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) + if (existsSync(namedSkillMdPath)) { + const skill = loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope) + if (skill) skills.push(skill) + continue + } + + continue + } + + if (isMarkdownFile(entry)) { + const skillName = basename(entry.name, ".md") + const skill = loadSkillFromPath(entryPath, skillsDir, skillName, scope) + if (skill) skills.push(skill) + } + } + + return skills +} + +function skillsToRecord(skills: LoadedSkill[]): Record { + const result: Record = {} + for (const skill of skills) { + result[skill.name] = skill.definition + } + return result +} + +/** + * Load skills from Claude Code user directory (~/.claude/skills/) + */ +export function loadUserSkills(): Record { + const userSkillsDir = join(getClaudeConfigDir(), "skills") + const skills = loadSkillsFromDir(userSkillsDir, "user") + return skillsToRecord(skills) +} + +/** + * Load skills from Claude Code project directory (.claude/skills/) + */ +export function loadProjectSkills(): Record { + const projectSkillsDir = join(process.cwd(), ".claude", "skills") + const skills = loadSkillsFromDir(projectSkillsDir, "project") + return skillsToRecord(skills) +} + +/** + * Load skills from OpenCode global directory (~/.config/opencode/skill/) + */ +export function loadOpencodeGlobalSkills(): Record { + const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill") + const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode") + return skillsToRecord(skills) +} + +/** + * Load skills from OpenCode project directory (.opencode/skill/) + */ +export function loadOpencodeProjectSkills(): Record { + const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") + const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project") + return skillsToRecord(skills) +} + +/** + * Discover all skills from all sources with priority ordering. + * Priority order: opencode-project > project > opencode > user + * + * @returns Array of LoadedSkill objects for use in slashcommand discovery + */ +export function discoverAllSkills(): LoadedSkill[] { + const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") + const projectDir = join(process.cwd(), ".claude", "skills") + const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill") + const userDir = join(getClaudeConfigDir(), "skills") + + const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project") + const projectSkills = loadSkillsFromDir(projectDir, "project") + const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode") + const userSkills = loadSkillsFromDir(userDir, "user") + + return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] +} diff --git a/src/features/opencode-skill-loader/types.ts b/src/features/opencode-skill-loader/types.ts new file mode 100644 index 0000000..4142bd8 --- /dev/null +++ b/src/features/opencode-skill-loader/types.ts @@ -0,0 +1,20 @@ +import type { CommandDefinition } from "../claude-code-command-loader/types" + +export type SkillScope = "user" | "project" | "opencode" | "opencode-project" + +export interface SkillMetadata { + name?: string + description?: string + model?: string + "argument-hint"?: string + agent?: string + subtask?: boolean +} + +export interface LoadedSkill { + name: string + path: string + resolvedPath: string + definition: CommandDefinition + scope: SkillScope +} diff --git a/src/index.ts b/src/index.ts index eec80e6..0e2ad51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,12 @@ import { loadOpencodeProjectCommands, } from "./features/claude-code-command-loader"; import { loadBuiltinCommands } from "./features/builtin-commands"; +import { + loadUserSkills, + loadProjectSkills, + loadOpencodeGlobalSkills, + loadOpencodeProjectSkills, +} from "./features/opencode-skill-loader"; import { loadUserAgents, @@ -523,14 +529,25 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const systemCommands = config.command ?? {}; const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {}; const opencodeProjectCommands = loadOpencodeProjectCommands(); + + const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkills() : {}; + const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkills() : {}; + const opencodeGlobalSkills = loadOpencodeGlobalSkills(); + const opencodeProjectSkills = loadOpencodeProjectSkills(); + config.command = { ...builtinCommands, ...userCommands, + ...userSkills, ...opencodeGlobalCommands, + ...opencodeGlobalSkills, ...systemCommands, ...projectCommands, + ...projectSkills, ...opencodeProjectCommands, + ...opencodeProjectSkills, ...pluginComponents.commands, + ...pluginComponents.skills, }; }, diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 3328ce8..3631aed 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -4,6 +4,7 @@ import { join, basename, dirname } from "path" import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" +import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" import type { CommandScope, CommandMetadata, CommandInfo } from "./types" function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { @@ -64,8 +65,30 @@ function discoverCommandsSync(): CommandInfo[] { return [...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands] } +function skillToCommandInfo(skill: LoadedSkill): CommandInfo { + return { + name: skill.name, + path: skill.path, + metadata: { + name: skill.name, + description: skill.definition.description || "", + argumentHint: skill.definition.argumentHint, + model: skill.definition.model, + agent: skill.definition.agent, + subtask: skill.definition.subtask, + }, + content: skill.definition.template, + scope: skill.scope, + } +} + const availableCommands = discoverCommandsSync() -const commandListForDescription = availableCommands +const availableSkills = discoverAllSkills() +const availableItems = [ + ...availableCommands, + ...availableSkills.map(skillToCommandInfo), +] +const commandListForDescription = availableItems .map((cmd) => { const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})` @@ -109,21 +132,21 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise { return sections.join("\n") } -function formatCommandList(commands: CommandInfo[]): string { - if (commands.length === 0) { - return "No commands found." +function formatCommandList(items: CommandInfo[]): string { + if (items.length === 0) { + return "No commands or skills found." } - const lines = ["# Available Commands\n"] + const lines = ["# Available Commands & Skills\n"] - for (const cmd of commands) { + for (const cmd of items) { const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" lines.push( `- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})` ) } - lines.push(`\n**Total**: ${commands.length} commands`) + lines.push(`\n**Total**: ${items.length} items`) return lines.join("\n") } @@ -148,7 +171,13 @@ Commands are loaded from (priority order, highest wins): - ~/.config/opencode/command/ (opencode - OpenCode global commands) - $CLAUDE_CONFIG_DIR/commands/ or ~/.claude/commands/ (user - Claude Code global commands) -Each command is a markdown file with: +Skills are loaded from (priority order, highest wins): +- .opencode/skill/ (opencode-project - OpenCode project-specific skills) +- ./.claude/skills/ (project - Claude Code project-specific skills) +- ~/.config/opencode/skill/ (opencode - OpenCode global skills) +- $CLAUDE_CONFIG_DIR/skills/ or ~/.claude/skills/ (user - Claude Code global skills) + +Each command/skill is a markdown file with: - YAML frontmatter: description, argument-hint, model, agent, subtask (optional) - Markdown body: The command instructions/prompt - File references: @path/to/file (relative to command file location) @@ -167,14 +196,19 @@ ${commandListForDescription}`, async execute(args) { const commands = discoverCommandsSync() + const skills = discoverAllSkills() + const allItems = [ + ...commands, + ...skills.map(skillToCommandInfo), + ] if (!args.command) { - return formatCommandList(commands) + "\n\nProvide a command name to execute." + return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute." } const cmdName = args.command.replace(/^\//, "") - const exactMatch = commands.find( + const exactMatch = allItems.find( (cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase() ) @@ -182,7 +216,7 @@ ${commandListForDescription}`, return await formatLoadedCommand(exactMatch) } - const partialMatches = commands.filter((cmd) => + const partialMatches = allItems.filter((cmd) => cmd.name.toLowerCase().includes(cmdName.toLowerCase()) ) @@ -190,14 +224,14 @@ ${commandListForDescription}`, const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ") return ( `No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` + - formatCommandList(commands) + formatCommandList(allItems) ) } return ( - `Command "/${cmdName}" not found.\n\n` + - formatCommandList(commands) + - "\n\nTry a different command name." + `Command or skill "/${cmdName}" not found.\n\n` + + formatCommandList(allItems) + + "\n\nTry a different name." ) }, })