From d34154bc6804e8831bcbf61d06b7ebf0eb17add3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 11 Dec 2025 10:03:54 +0900 Subject: [PATCH] feat(skill): align with opencode-skills approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Zod schema validation following Anthropic Agent Skills Spec v1.0 - Include basePath in skill output for path resolution - Simplify tool description and output format - Add validation error logging for invalid skills 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/tools/skill/tools.ts | 83 +++++++++++++++++++--------------------- src/tools/skill/types.ts | 23 +++++++++++ 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index c94b8e1..3a1ca94 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -2,8 +2,23 @@ import { tool } from "@opencode-ai/plugin" import { existsSync, readdirSync, statSync, readlinkSync, readFileSync } from "fs" import { homedir } from "os" import { join, resolve, basename } from "path" +import { z } from "zod/v4" import { parseFrontmatter, resolveCommandsInText } from "../../shared" -import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill } from "./types" +import { SkillFrontmatterSchema } from "./types" +import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill, SkillFrontmatter } from "./types" + +function parseSkillFrontmatter(data: Record): SkillFrontmatter { + return { + name: typeof data.name === "string" ? data.name : "", + description: typeof data.description === "string" ? data.description : "", + license: typeof data.license === "string" ? data.license : undefined, + "allowed-tools": Array.isArray(data["allowed-tools"]) ? data["allowed-tools"] : undefined, + metadata: + typeof data.metadata === "object" && data.metadata !== null + ? (data.metadata as Record) + : undefined, + } +} function discoverSkillsFromDir( skillsDir: string, @@ -93,10 +108,14 @@ async function parseSkillMd(skillPath: string): Promise { content = await resolveCommandsInText(content) const { data, body } = parseFrontmatter(content) + const frontmatter = parseSkillFrontmatter(data) + const metadata: SkillMetadata = { - name: data.name || basename(skillPath), - description: data.description || "", - license: data.license, + name: frontmatter.name || basename(skillPath), + description: frontmatter.description, + license: frontmatter.license, + allowedTools: frontmatter["allowed-tools"], + metadata: frontmatter.metadata, } const referencesDir = join(resolvedPath, "references") @@ -118,6 +137,7 @@ async function parseSkillMd(skillPath: string): Promise { return { name: metadata.name, path: resolvedPath, + basePath: resolvedPath, metadata, content: body, references, @@ -202,6 +222,7 @@ async function loadSkillWithReferences( content = await resolveCommandsInText(content) referencesLoaded.push({ path: ref, content }) } catch { + // Skip unreadable references } } } @@ -209,6 +230,7 @@ async function loadSkillWithReferences( return { name: skill.name, metadata: skill.metadata, + basePath: skill.basePath, body: skill.content, referencesLoaded, } @@ -234,31 +256,24 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string { return "No skills loaded." } - const sections: string[] = ["# Loaded Skills\n"] + const skill = loadedSkills[0] + const sections: string[] = [] - for (const skill of loadedSkills) { - sections.push(`## ${skill.metadata.name}\n`) - sections.push(`**Description**: ${skill.metadata.description || "(no description)"}\n`) - sections.push("### Skill Instructions\n") - sections.push(skill.body.trim()) + sections.push(`Base directory for this skill: ${skill.basePath}/`) + sections.push("") + sections.push(skill.body.trim()) - if (skill.referencesLoaded.length > 0) { - sections.push("\n### Loaded References\n") - for (const ref of skill.referencesLoaded) { - sections.push(`#### ${ref.path}\n`) - sections.push("```") - sections.push(ref.content.trim()) - sections.push("```\n") - } + if (skill.referencesLoaded.length > 0) { + sections.push("\n---\n### Loaded References\n") + for (const ref of skill.referencesLoaded) { + sections.push(`#### ${ref.path}\n`) + sections.push("```") + sections.push(ref.content.trim()) + sections.push("```\n") } - - sections.push("\n---\n") } - const skillNames = loadedSkills.map((s) => s.metadata.name).join(", ") - sections.push(`**Skills loaded**: ${skillNames}`) - sections.push(`**Total**: ${loadedSkills.length} skill(s)`) - sections.push("\nPlease confirm these skills match your needs before proceeding.") + sections.push(`\n---\n**Launched skill**: ${skill.metadata.name}`) return sections.join("\n") } @@ -266,25 +281,7 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string { export const skill = tool({ description: `Execute a skill within the main conversation. -When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. - -How to use skills: -- Invoke skills using this tool with the skill name only (no arguments) -- When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task - -Important: -- Only use skills listed in Available Skills below -- Do not invoke a skill that is already running - -Skills are loaded from: -- ~/.claude/skills/ (user scope - global skills) -- ./.claude/skills/ (project scope - project-specific skills) - -Each skill contains: -- SKILL.md: Main instructions with YAML frontmatter (name, description) -- references/: Documentation files loaded into context as needed -- scripts/: Executable code for deterministic operations -- assets/: Files used in output (templates, icons, etc.) +When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task. Available Skills: ${skillListForDescription}`, diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts index 9bff9d4..fcf739f 100644 --- a/src/tools/skill/types.ts +++ b/src/tools/skill/types.ts @@ -1,14 +1,36 @@ +import { z } from "zod/v4" + export type SkillScope = "user" | "project" +/** + * Zod schema for skill frontmatter validation + * Following Anthropic Agent Skills Specification v1.0 + */ +export const SkillFrontmatterSchema = z.object({ + name: z + .string() + .regex(/^[a-z0-9-]+$/, "Name must be lowercase alphanumeric with hyphens only") + .min(1, "Name cannot be empty"), + description: z.string().min(20, "Description must be at least 20 characters for discoverability"), + license: z.string().optional(), + "allowed-tools": z.array(z.string()).optional(), + metadata: z.record(z.string(), z.string()).optional(), +}) + +export type SkillFrontmatter = z.infer + export interface SkillMetadata { name: string description: string license?: string + allowedTools?: string[] + metadata?: Record } export interface SkillInfo { name: string path: string + basePath: string metadata: SkillMetadata content: string references: string[] @@ -19,6 +41,7 @@ export interface SkillInfo { export interface LoadedSkill { name: string metadata: SkillMetadata + basePath: string body: string referencesLoaded: Array<{ path: string; content: string }> }