From 3c6ffe5d9c44bfd5a2110eb4b128e7ac8a065e78 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 9 Dec 2025 15:48:20 +0900 Subject: [PATCH] feat(skill-loader): add skill loader that converts skills to commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills are loaded from: - ~/.claude/skills/ (user scope) - .claude/skills/ (project scope) Each skill directory contains SKILL.md with frontmatter metadata. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/features/skill-loader/index.ts | 2 + src/features/skill-loader/loader.ts | 85 +++++++++++++++++++++++++++++ src/features/skill-loader/types.ts | 16 ++++++ 3 files changed, 103 insertions(+) create mode 100644 src/features/skill-loader/index.ts create mode 100644 src/features/skill-loader/loader.ts create mode 100644 src/features/skill-loader/types.ts diff --git a/src/features/skill-loader/index.ts b/src/features/skill-loader/index.ts new file mode 100644 index 0000000..644158c --- /dev/null +++ b/src/features/skill-loader/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./loader" diff --git a/src/features/skill-loader/loader.ts b/src/features/skill-loader/loader.ts new file mode 100644 index 0000000..257d4e2 --- /dev/null +++ b/src/features/skill-loader/loader.ts @@ -0,0 +1,85 @@ +import { existsSync, readdirSync, readFileSync, statSync, readlinkSync } from "fs" +import { homedir } from "os" +import { join, resolve } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import type { CommandDefinition } from "../command-loader/types" +import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types" + +function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] { + if (!existsSync(skillsDir)) { + return [] + } + + const entries = readdirSync(skillsDir, { withFileTypes: true }) + const skills: LoadedSkillAsCommand[] = [] + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue + + const skillPath = join(skillsDir, entry.name) + + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue + + let resolvedPath = skillPath + if (statSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) { + resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath)) + } + + const skillMdPath = join(resolvedPath, "SKILL.md") + if (!existsSync(skillMdPath)) continue + + try { + const content = readFileSync(skillMdPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const skillName = data.name || entry.name + const originalDescription = data.description || "" + const formattedDescription = `(${scope} - Skill) ${originalDescription}` + + const wrappedTemplate = ` +${body.trim()} + + + +$ARGUMENTS +` + + const definition: CommandDefinition = { + name: skillName, + description: formattedDescription, + template: wrappedTemplate, + model: sanitizeModelField(data.model), + } + + skills.push({ + name: skillName, + path: resolvedPath, + definition, + scope, + }) + } catch { + continue + } + } + + return skills +} + +export function loadUserSkillsAsCommands(): Record { + const userSkillsDir = join(homedir(), ".claude", "skills") + const skills = loadSkillsFromDir(userSkillsDir, "user") + return skills.reduce((acc, skill) => { + acc[skill.name] = skill.definition + return acc + }, {} as Record) +} + +export function loadProjectSkillsAsCommands(): Record { + const projectSkillsDir = join(process.cwd(), ".claude", "skills") + const skills = loadSkillsFromDir(projectSkillsDir, "project") + return skills.reduce((acc, skill) => { + acc[skill.name] = skill.definition + return acc + }, {} as Record) +} diff --git a/src/features/skill-loader/types.ts b/src/features/skill-loader/types.ts new file mode 100644 index 0000000..03e4c9e --- /dev/null +++ b/src/features/skill-loader/types.ts @@ -0,0 +1,16 @@ +import type { CommandDefinition } from "../command-loader/types" + +export type SkillScope = "user" | "project" + +export interface SkillMetadata { + name: string + description: string + model?: string +} + +export interface LoadedSkillAsCommand { + name: string + path: string + definition: CommandDefinition + scope: SkillScope +}