import * as fs from "fs" import * as path from "path" import * as os from "os" import { parseFrontmatter } from "./frontmatter" import { substituteArguments } from "./args" export interface Skill { name: string description: string content: string loadedFrom: "user" | "project" | "managed" userInvocable: boolean allowedTools: string[] argNames: string[] trustLevel: TrustLevel skillRoot: string } export type TrustLevel = "local" | "community" | "verified" | "signed" export interface SkillExecution { systemPrompt: string userMessage: string allowedTools?: string[] } const SKILL_DIRS = () => { const dirs: string[] = [] const primary = path.join(os.homedir(), ".llmtune", "skills") dirs.push(primary) return dirs } export class SkillRegistry { private skills: Map = new Map() loadAll(projectRoot?: string): void { this.skills.clear() for (const dir of SKILL_DIRS()) { this.loadFromDir(dir, "user") } const managedEnv = process.env.LLMTUNE_MANAGED_SKILLS_DIR if (managedEnv) { this.loadFromDir(managedEnv, "managed") } if (projectRoot) { const projectDir = path.join(projectRoot, ".llmtune", "skills") this.loadFromDir(projectDir, "project") const compatDir = path.join(projectRoot, ".claude", "skills") this.loadFromDir(compatDir, "project") } } private loadFromDir(baseDir: string, loadedFrom: Skill["loadedFrom"]): void { const resolved = path.resolve(baseDir) if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) return for (const entry of fs.readdirSync(resolved).sort()) { const skillPath = path.join(resolved, entry) if (!fs.statSync(skillPath).isDirectory()) continue const mdPath = path.join(skillPath, "SKILL.md") if (!fs.existsSync(mdPath)) continue const raw = fs.readFileSync(mdPath, "utf-8") const { frontmatter, body } = parseFrontmatter(raw) const description = String(frontmatter.description ?? extractFirstLine(body) ?? `Skill: ${entry}`) const userInvocable = frontmatter["user-invocable"] !== false const allowedTools = asStrList(frontmatter["allowed-tools"]) const argNames = parseArgNames(frontmatter.arguments) const trustLevel = resolveTrustLevel(frontmatter.trust, loadedFrom) const skill: Skill = { name: entry, description, content: body, loadedFrom, userInvocable, allowedTools, argNames, trustLevel, skillRoot: skillPath, } if (!this.skills.has(entry.toLowerCase())) { this.skills.set(entry.toLowerCase(), skill) } } } get(name: string): Skill | undefined { return this.skills.get(name.toLowerCase()) } list(): Skill[] { return Array.from(this.skills.values()) } listUserInvocable(): Skill[] { return this.list().filter((s) => s.userInvocable) } prepareExecution(name: string, args: string[]): SkillExecution | null { const skill = this.get(name) if (!skill) return null const argMap: Record = {} for (let i = 0; i < Math.min(args.length, skill.argNames.length); i++) { argMap[skill.argNames[i]] = args[i] } const userMessage = substituteArguments(skill.content, argMap) return { systemPrompt: `You are executing the "${skill.name}" skill. ${skill.description}`, userMessage, allowedTools: skill.allowedTools.length > 0 ? skill.allowedTools : undefined, } } } function resolveTrustLevel(raw: unknown, loadedFrom: Skill["loadedFrom"]): TrustLevel { const str = String(raw ?? "").toLowerCase().trim() if (str === "signed") return "signed" if (str === "verified") return "verified" if (str === "community") return "community" if (loadedFrom === "user" || loadedFrom === "project") return "local" return "community" } function extractFirstLine(body: string): string | null { for (const line of body.split("\n")) { const stripped = line.trim() if (!stripped || stripped.startsWith("#")) continue return stripped.slice(0, 200) } return null } function parseArgNames(raw: unknown): string[] { return asStrList(raw) } function asStrList(val: unknown): string[] { if (val == null) return [] if (Array.isArray(val)) return val.map(String).filter(Boolean) const s = String(val).trim() if (!s) return [] if (s.includes(",")) return s.split(",").map((x) => x.trim()).filter(Boolean) return [s] }