feat(skill): align with opencode-skills approach
- 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)
This commit is contained in:
@@ -2,8 +2,23 @@ import { tool } from "@opencode-ai/plugin"
|
|||||||
import { existsSync, readdirSync, statSync, readlinkSync, readFileSync } from "fs"
|
import { existsSync, readdirSync, statSync, readlinkSync, readFileSync } from "fs"
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
import { join, resolve, basename } from "path"
|
import { join, resolve, basename } from "path"
|
||||||
|
import { z } from "zod/v4"
|
||||||
import { parseFrontmatter, resolveCommandsInText } from "../../shared"
|
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<string, unknown>): 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<string, string>)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function discoverSkillsFromDir(
|
function discoverSkillsFromDir(
|
||||||
skillsDir: string,
|
skillsDir: string,
|
||||||
@@ -93,10 +108,14 @@ async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
|
|||||||
content = await resolveCommandsInText(content)
|
content = await resolveCommandsInText(content)
|
||||||
const { data, body } = parseFrontmatter(content)
|
const { data, body } = parseFrontmatter(content)
|
||||||
|
|
||||||
|
const frontmatter = parseSkillFrontmatter(data)
|
||||||
|
|
||||||
const metadata: SkillMetadata = {
|
const metadata: SkillMetadata = {
|
||||||
name: data.name || basename(skillPath),
|
name: frontmatter.name || basename(skillPath),
|
||||||
description: data.description || "",
|
description: frontmatter.description,
|
||||||
license: data.license,
|
license: frontmatter.license,
|
||||||
|
allowedTools: frontmatter["allowed-tools"],
|
||||||
|
metadata: frontmatter.metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
const referencesDir = join(resolvedPath, "references")
|
const referencesDir = join(resolvedPath, "references")
|
||||||
@@ -118,6 +137,7 @@ async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
|
|||||||
return {
|
return {
|
||||||
name: metadata.name,
|
name: metadata.name,
|
||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
|
basePath: resolvedPath,
|
||||||
metadata,
|
metadata,
|
||||||
content: body,
|
content: body,
|
||||||
references,
|
references,
|
||||||
@@ -202,6 +222,7 @@ async function loadSkillWithReferences(
|
|||||||
content = await resolveCommandsInText(content)
|
content = await resolveCommandsInText(content)
|
||||||
referencesLoaded.push({ path: ref, content })
|
referencesLoaded.push({ path: ref, content })
|
||||||
} catch {
|
} catch {
|
||||||
|
// Skip unreadable references
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +230,7 @@ async function loadSkillWithReferences(
|
|||||||
return {
|
return {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
metadata: skill.metadata,
|
metadata: skill.metadata,
|
||||||
|
basePath: skill.basePath,
|
||||||
body: skill.content,
|
body: skill.content,
|
||||||
referencesLoaded,
|
referencesLoaded,
|
||||||
}
|
}
|
||||||
@@ -234,16 +256,15 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
|
|||||||
return "No skills loaded."
|
return "No skills loaded."
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections: string[] = ["# Loaded Skills\n"]
|
const skill = loadedSkills[0]
|
||||||
|
const sections: string[] = []
|
||||||
|
|
||||||
for (const skill of loadedSkills) {
|
sections.push(`Base directory for this skill: ${skill.basePath}/`)
|
||||||
sections.push(`## ${skill.metadata.name}\n`)
|
sections.push("")
|
||||||
sections.push(`**Description**: ${skill.metadata.description || "(no description)"}\n`)
|
|
||||||
sections.push("### Skill Instructions\n")
|
|
||||||
sections.push(skill.body.trim())
|
sections.push(skill.body.trim())
|
||||||
|
|
||||||
if (skill.referencesLoaded.length > 0) {
|
if (skill.referencesLoaded.length > 0) {
|
||||||
sections.push("\n### Loaded References\n")
|
sections.push("\n---\n### Loaded References\n")
|
||||||
for (const ref of skill.referencesLoaded) {
|
for (const ref of skill.referencesLoaded) {
|
||||||
sections.push(`#### ${ref.path}\n`)
|
sections.push(`#### ${ref.path}\n`)
|
||||||
sections.push("```")
|
sections.push("```")
|
||||||
@@ -252,13 +273,7 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sections.push("\n---\n")
|
sections.push(`\n---\n**Launched skill**: ${skill.metadata.name}`)
|
||||||
}
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
return sections.join("\n")
|
return sections.join("\n")
|
||||||
}
|
}
|
||||||
@@ -266,25 +281,7 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
|
|||||||
export const skill = tool({
|
export const skill = tool({
|
||||||
description: `Execute a skill within the main conversation.
|
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.
|
When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task.
|
||||||
|
|
||||||
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.)
|
|
||||||
|
|
||||||
Available Skills:
|
Available Skills:
|
||||||
${skillListForDescription}`,
|
${skillListForDescription}`,
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
export type SkillScope = "user" | "project"
|
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<typeof SkillFrontmatterSchema>
|
||||||
|
|
||||||
export interface SkillMetadata {
|
export interface SkillMetadata {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
license?: string
|
license?: string
|
||||||
|
allowedTools?: string[]
|
||||||
|
metadata?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillInfo {
|
export interface SkillInfo {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
|
basePath: string
|
||||||
metadata: SkillMetadata
|
metadata: SkillMetadata
|
||||||
content: string
|
content: string
|
||||||
references: string[]
|
references: string[]
|
||||||
@@ -19,6 +41,7 @@ export interface SkillInfo {
|
|||||||
export interface LoadedSkill {
|
export interface LoadedSkill {
|
||||||
name: string
|
name: string
|
||||||
metadata: SkillMetadata
|
metadata: SkillMetadata
|
||||||
|
basePath: string
|
||||||
body: string
|
body: string
|
||||||
referencesLoaded: Array<{ path: string; content: string }>
|
referencesLoaded: Array<{ path: string; content: string }>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user