nixos/shared/linked-dotfiles/opencode/plugin/skills.js
2025-10-22 15:49:16 -06:00

161 lines
4.9 KiB
JavaScript

/**
* OpenCode Skills Plugin (Local Version)
*
* Implements Anthropic's Agent Skills Specification (v1.0) for OpenCode.
*
* Modified to:
* - Only scan ~/.config/opencode/skills/ directory
* - Provide minimal logging (one-line summary)
*
* Original: https://github.com/malhashemi/opencode-skills
*/
import { tool } from "@opencode-ai/plugin";
import matter from "gray-matter";
import { Glob } from "bun";
import { join, dirname, basename, relative, sep } from "path";
import { z } from "zod";
import os from "os";
const SkillFrontmatterSchema = z.object({
name: z.string()
.regex(/^[a-z0-9-]+$/, "Name must be lowercase alphanumeric with hyphens")
.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()).optional()
});
function generateToolName(skillPath, baseDir) {
const rel = relative(baseDir, skillPath);
const dirPath = dirname(rel);
const components = dirPath.split(sep).filter(c => c !== ".");
return "skills_" + components.join("_").replace(/-/g, "_");
}
async function parseSkill(skillPath, baseDir) {
try {
const content = await Bun.file(skillPath).text();
const { data, content: markdown } = matter(content);
let frontmatter;
try {
frontmatter = SkillFrontmatterSchema.parse(data);
}
catch (error) {
return null;
}
const skillDir = basename(dirname(skillPath));
if (frontmatter.name !== skillDir) {
return null;
}
const toolName = generateToolName(skillPath, baseDir);
return {
name: frontmatter.name,
fullPath: dirname(skillPath),
toolName,
description: frontmatter.description,
allowedTools: frontmatter["allowed-tools"],
metadata: frontmatter.metadata,
license: frontmatter.license,
content: markdown.trim(),
path: skillPath
};
}
catch (error) {
return null;
}
}
async function discoverSkills(basePath) {
const skills = [];
try {
const glob = new Glob("**/SKILL.md");
for await (const match of glob.scan({
cwd: basePath,
absolute: true
})) {
const skill = await parseSkill(match, basePath);
if (skill) {
skills.push(skill);
}
}
}
catch (error) {
// Directory doesn't exist, return empty array
}
return skills;
}
export const SkillsPlugin = async (ctx) => {
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
const configSkillsPath = xdgConfigHome
? join(xdgConfigHome, "opencode/skills")
: join(os.homedir(), ".config/opencode/skills");
const skills = await discoverSkills(configSkillsPath);
if (skills.length > 0) {
console.log(`Skills loaded: ${skills.map(s => s.name).join(", ")}`);
}
const tools = {};
for (const skill of skills) {
tools[skill.toolName] = tool({
description: skill.description,
args: {},
async execute(args, toolCtx) {
return `# ⚠️ SKILL EXECUTION INSTRUCTIONS ⚠️
**SKILL NAME:** ${skill.name}
**SKILL DIRECTORY:** ${skill.fullPath}/
## EXECUTION WORKFLOW:
**STEP 1: PLAN THE WORK**
Before executing this skill, use the \`todowrite\` tool to create a todo list of the main tasks described in the skill content below.
- Parse the skill instructions carefully
- Identify the key tasks and steps required
- Create todos with status "pending" and appropriate priority levels
- This helps track progress and ensures nothing is missed
**STEP 2: EXECUTE THE SKILL**
Follow the skill instructions below, marking todos as "in_progress" when starting a task and "completed" when done.
Use \`todowrite\` to update task statuses as you work through them.
## PATH RESOLUTION RULES (READ CAREFULLY):
All file paths mentioned below are relative to the SKILL DIRECTORY shown above.
**Examples:**
- If the skill mentions \`scripts/init_skill.py\`, the full path is: \`${skill.fullPath}/scripts/init_skill.py\`
- If the skill mentions \`references/docs.md\`, the full path is: \`${skill.fullPath}/references/docs.md\`
- If the skill mentions \`assets/template.html\`, the full path is: \`${skill.fullPath}/assets/template.html\`
**IMPORTANT:** Always prepend \`${skill.fullPath}/\` to any relative path mentioned in the skill content below.
---
# SKILL CONTENT:
${skill.content}
---
**Remember:**
1. All relative paths in the skill content above are relative to: \`${skill.fullPath}/\`
2. Update your todo list as you progress through the skill tasks
`;
}
});
}
return { tool: tools };
};