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 <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
Sisyphus
2025-12-30 10:42:05 +09:00
committed by GitHub
parent 7324b6c6b5
commit 17e8746eff
9 changed files with 268 additions and 120 deletions

View File

@@ -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` |

View File

@@ -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 {

View File

@@ -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<SkillMetadata>(content)
const skillName = data.name || entry.name
const originalDescription = data.description || ""
const formattedDescription = `(${scope} - 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: 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<string, CommandDefinition> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition
return acc
}, {} as Record<string, CommandDefinition>)
}
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
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<string, CommandDefinition>)
}

View File

@@ -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
}

View File

@@ -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<SkillMetadata>(content)
const skillName = data.name || defaultName
const originalDescription = data.description || ""
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const formattedDescription = `(${scope} - 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: 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<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
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<string, CommandDefinition> {
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<string, CommandDefinition> {
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<string, CommandDefinition> {
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<string, CommandDefinition> {
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]
}

View File

@@ -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
}

View File

@@ -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,
};
},

View File

@@ -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<string> {
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."
)
},
})