diff --git a/src/tools/skill/constants.ts b/src/tools/skill/constants.ts new file mode 100644 index 0000000..538dc09 --- /dev/null +++ b/src/tools/skill/constants.ts @@ -0,0 +1,8 @@ +export const TOOL_NAME = "skill" as const + +export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill to get detailed instructions for a specific task. No skills are currently available." + +export const TOOL_DESCRIPTION_PREFIX = `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.` diff --git a/src/tools/skill/index.ts b/src/tools/skill/index.ts new file mode 100644 index 0000000..3c32b1c --- /dev/null +++ b/src/tools/skill/index.ts @@ -0,0 +1,3 @@ +export * from "./constants" +export * from "./types" +export { skill, createSkillTool } from "./tools" diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts new file mode 100644 index 0000000..da6f217 --- /dev/null +++ b/src/tools/skill/tools.ts @@ -0,0 +1,79 @@ +import { dirname } from "node:path" +import { readFileSync } from "node:fs" +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants" +import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types" +import { discoverSkills, getSkillByName, type LoadedSkill } from "../../features/opencode-skill-loader" +import { parseFrontmatter } from "../../shared/frontmatter" + +function loadedSkillToInfo(skill: LoadedSkill): SkillInfo { + return { + name: skill.name, + description: skill.definition.description || "", + location: skill.path, + scope: skill.scope, + license: skill.license, + compatibility: skill.compatibility, + metadata: skill.metadata, + allowedTools: skill.allowedTools, + } +} + +function formatSkillsXml(skills: SkillInfo[]): string { + if (skills.length === 0) return "" + + const skillsXml = skills.map(skill => { + const lines = [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ] + if (skill.compatibility) { + lines.push(` ${skill.compatibility}`) + } + lines.push(" ") + return lines.join("\n") + }).join("\n") + + return `\n\n\n${skillsXml}\n` +} + +export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition { + const skills = discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly }) + const skillInfos = skills.map(loadedSkillToInfo) + + const description = skillInfos.length === 0 + ? TOOL_DESCRIPTION_NO_SKILLS + : TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos) + + return tool({ + description, + args: { + name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), + }, + async execute(args: SkillArgs) { + const skill = getSkillByName(args.name, { includeClaudeCodePaths: !options.opencodeOnly }) + + if (!skill) { + const available = skills.map(s => s.name).join(", ") + throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`) + } + + const content = readFileSync(skill.path, "utf-8") + const { body } = parseFrontmatter(content) + const dir = dirname(skill.path) + + const output = [ + `## Skill: ${skill.name}`, + "", + `**Base directory**: ${dir}`, + "", + body.trim(), + ].join("\n") + + return output + }, + }) +} + +export const skill = createSkillTool() diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts new file mode 100644 index 0000000..9534086 --- /dev/null +++ b/src/tools/skill/types.ts @@ -0,0 +1,19 @@ +export interface SkillArgs { + name: string +} + +export interface SkillInfo { + name: string + description: string + location: string + scope: "opencode-project" | "project" | "opencode" | "user" + license?: string + compatibility?: string + metadata?: Record + allowedTools?: string[] +} + +export interface SkillLoadOptions { + /** When true, only load from OpenCode paths (.opencode/skill/, ~/.config/opencode/skill/) */ + opencodeOnly?: boolean +}