* feat(skill): add builtin skill types and schemas with priority-based merging support - Add BuiltinSkill interface for programmatic skill definitions - Create builtin-skills module with createBuiltinSkills factory function - Add SkillScope expansion to include 'builtin' and 'config' scopes - Create SkillsConfig and SkillDefinition Zod schemas for config validation - Add merger.ts utility with mergeSkills function for priority-based skill merging - Update skill and command types to support optional paths for builtin/config skills - Priority order: builtin < config < user < opencode < project < opencode-project 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(skill): integrate programmatic skill discovery and merged skill support - Add discovery functions for Claude and OpenCode skill directories - Add discoverUserClaudeSkills, discoverProjectClaudeSkills functions - Add discoverOpencodeGlobalSkills, discoverOpencodeProjectSkills functions - Update createSkillTool to support pre-merged skills via options - Add extractSkillBody utility to handle both file and programmatic skills - Integrate mergeSkills in plugin initialization to apply priority-based merging - Support optional path/resolvedPath for builtin and config-sourced skills 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * chore(slashcommand): support optional path for builtin and config command scopes - Update CommandInfo type to make path and content optional properties - Prepare command tool for builtin and config sourced commands - Maintain backward compatibility with file-based command loading 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * docs(tools): improve tool descriptions for interactive-bash and slashcommand - Added use case clarification to interactive-bash tool description (server processes, long-running tasks, background jobs, interactive CLI tools) - Simplified slashcommand description to emphasize 'loading' skills concept and removed verbose documentation 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor(skill-loader): simplify redundant condition in skill merging logic Remove redundant 'else if (loaded)' condition that was always true since we're already inside the 'if (loaded)' block. Simplify to 'else' for clarity. Addresses code review feedback on PR #340 for the skill infrastructure feature. 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
212 lines
6.4 KiB
TypeScript
212 lines
6.4 KiB
TypeScript
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
|
import { existsSync, readdirSync, readFileSync } from "fs"
|
|
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[] {
|
|
if (!existsSync(commandsDir)) {
|
|
return []
|
|
}
|
|
|
|
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
|
const commands: CommandInfo[] = []
|
|
|
|
for (const entry of entries) {
|
|
if (!isMarkdownFile(entry)) continue
|
|
|
|
const commandPath = join(commandsDir, entry.name)
|
|
const commandName = basename(entry.name, ".md")
|
|
|
|
try {
|
|
const content = readFileSync(commandPath, "utf-8")
|
|
const { data, body } = parseFrontmatter(content)
|
|
|
|
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
|
const metadata: CommandMetadata = {
|
|
name: commandName,
|
|
description: data.description || "",
|
|
argumentHint: data["argument-hint"],
|
|
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
|
agent: data.agent,
|
|
subtask: Boolean(data.subtask),
|
|
}
|
|
|
|
commands.push({
|
|
name: commandName,
|
|
path: commandPath,
|
|
metadata,
|
|
content: body,
|
|
scope,
|
|
})
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return commands
|
|
}
|
|
|
|
function discoverCommandsSync(): CommandInfo[] {
|
|
const { homedir } = require("os")
|
|
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
|
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
|
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
|
|
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
|
|
|
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
|
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
|
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
|
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
|
|
|
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 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})`
|
|
})
|
|
.join("\n")
|
|
|
|
async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
|
|
const sections: string[] = []
|
|
|
|
sections.push(`# /${cmd.name} Command\n`)
|
|
|
|
if (cmd.metadata.description) {
|
|
sections.push(`**Description**: ${cmd.metadata.description}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.argumentHint) {
|
|
sections.push(`**Usage**: /${cmd.name} ${cmd.metadata.argumentHint}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.model) {
|
|
sections.push(`**Model**: ${cmd.metadata.model}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.agent) {
|
|
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.subtask) {
|
|
sections.push(`**Subtask**: true\n`)
|
|
}
|
|
|
|
sections.push(`**Scope**: ${cmd.scope}\n`)
|
|
sections.push("---\n")
|
|
sections.push("## Command Instructions\n")
|
|
|
|
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
|
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
|
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
|
sections.push(resolvedContent.trim())
|
|
|
|
return sections.join("\n")
|
|
}
|
|
|
|
function formatCommandList(items: CommandInfo[]): string {
|
|
if (items.length === 0) {
|
|
return "No commands or skills found."
|
|
}
|
|
|
|
const lines = ["# Available Commands & Skills\n"]
|
|
|
|
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**: ${items.length} items`)
|
|
return lines.join("\n")
|
|
}
|
|
|
|
export const slashcommand: ToolDefinition = tool({
|
|
description: `Load a skill to get detailed instructions for a specific task.
|
|
|
|
Skills provide specialized knowledge and step-by-step guidance.
|
|
Use this when a task matches an available skill's description.
|
|
|
|
<available_skills>
|
|
${commandListForDescription}
|
|
</available_skills>`,
|
|
|
|
args: {
|
|
command: tool.schema
|
|
.string()
|
|
.describe(
|
|
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
|
|
),
|
|
},
|
|
|
|
async execute(args) {
|
|
const commands = discoverCommandsSync()
|
|
const skills = discoverAllSkills()
|
|
const allItems = [
|
|
...commands,
|
|
...skills.map(skillToCommandInfo),
|
|
]
|
|
|
|
if (!args.command) {
|
|
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
|
|
}
|
|
|
|
const cmdName = args.command.replace(/^\//, "")
|
|
|
|
const exactMatch = allItems.find(
|
|
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
|
|
)
|
|
|
|
if (exactMatch) {
|
|
return await formatLoadedCommand(exactMatch)
|
|
}
|
|
|
|
const partialMatches = allItems.filter((cmd) =>
|
|
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
|
|
)
|
|
|
|
if (partialMatches.length > 0) {
|
|
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
|
|
return (
|
|
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
|
|
formatCommandList(allItems)
|
|
)
|
|
}
|
|
|
|
return (
|
|
`Command or skill "/${cmdName}" not found.\n\n` +
|
|
formatCommandList(allItems) +
|
|
"\n\nTry a different name."
|
|
)
|
|
},
|
|
})
|