import { promises as fs } from "fs" import { join, basename } from "path" import yaml from "js-yaml" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import type { CommandDefinition } from "../claude-code-command-loader/types" import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types" import type { SkillMcpConfig } from "../skill-mcp-manager/types" function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined { const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) if (!frontmatterMatch) return undefined try { const parsed = yaml.load(frontmatterMatch[1]) as Record if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) { return parsed.mcp as SkillMcpConfig } } catch { return undefined } return undefined } async function loadMcpJsonFromDir(skillDir: string): Promise { const mcpJsonPath = join(skillDir, "mcp.json") try { const content = await fs.readFile(mcpJsonPath, "utf-8") const parsed = JSON.parse(content) as Record if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) { return parsed.mcpServers as SkillMcpConfig } if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) { const hasCommandField = Object.values(parsed).some( (v) => v && typeof v === "object" && "command" in (v as Record) ) if (hasCommandField) { return parsed as SkillMcpConfig } } } catch { return undefined } return undefined } function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined { if (!allowedTools) return undefined // Handle YAML array format: already parsed as string[] if (Array.isArray(allowedTools)) { return allowedTools.map(t => t.trim()).filter(Boolean) } // Handle space-separated string format: "Read Write Edit Bash" return allowedTools.split(/\s+/).filter(Boolean) } async function loadSkillFromPath( skillPath: string, resolvedPath: string, defaultName: string, scope: SkillScope, namePrefix: string = "" ): Promise { try { const content = await fs.readFile(skillPath, "utf-8") const { data, body } = parseFrontmatter(content) const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath) const mcpConfig = mcpJsonMcp || frontmatterMcp // For nested skills, use the full path as the name (e.g., "superpowers/brainstorming") // For flat skills, use frontmatter name or directory name const baseName = data.name || defaultName const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName const originalDescription = data.description || "" const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const formattedDescription = `(${scope} - Skill) ${originalDescription}` const templateContent = ` Base directory for this skill: ${resolvedPath}/ File references (@path) in this skill are relative to this directory. ${body.trim()} $ARGUMENTS ` // RATIONALE: We read the file eagerly to ensure atomic consistency between // metadata and body. We maintain the LazyContentLoader interface for // compatibility, but the state is effectively eager. const eagerLoader: LazyContentLoader = { loaded: true, content: templateContent, load: async () => templateContent, } const definition: CommandDefinition = { name: skillName, description: formattedDescription, template: templateContent, model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), agent: data.agent, subtask: data.subtask, argumentHint: data["argument-hint"], } return { name: skillName, path: skillPath, resolvedPath, definition, scope, license: data.license, compatibility: data.compatibility, metadata: data.metadata, allowedTools: parseAllowedTools(data["allowed-tools"]), mcpConfig, lazyContent: eagerLoader, } } catch { return null } } async function loadSkillsFromDir( skillsDir: string, scope: SkillScope, namePrefix: string = "", depth: number = 0, maxDepth: number = 2 ): Promise { const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) const skillMap = new Map() const directories = entries.filter(e => !e.name.startsWith(".") && (e.isDirectory() || e.isSymbolicLink())) const files = entries.filter(e => !e.name.startsWith(".") && !e.isDirectory() && !e.isSymbolicLink() && isMarkdownFile(e)) for (const entry of directories) { const entryPath = join(skillsDir, entry.name) const resolvedPath = await resolveSymlinkAsync(entryPath) const dirName = entry.name const skillMdPath = join(resolvedPath, "SKILL.md") try { await fs.access(skillMdPath) const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope, namePrefix) if (skill && !skillMap.has(skill.name)) { skillMap.set(skill.name, skill) } continue } catch { } const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) try { await fs.access(namedSkillMdPath) const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix) if (skill && !skillMap.has(skill.name)) { skillMap.set(skill.name, skill) } continue } catch { } if (depth < maxDepth) { const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName const nestedSkills = await loadSkillsFromDir(resolvedPath, scope, newPrefix, depth + 1, maxDepth) for (const nestedSkill of nestedSkills) { if (!skillMap.has(nestedSkill.name)) { skillMap.set(nestedSkill.name, nestedSkill) } } } } for (const entry of files) { const entryPath = join(skillsDir, entry.name) const baseName = basename(entry.name, ".md") const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix) if (skill && !skillMap.has(skill.name)) { skillMap.set(skill.name, skill) } } return Array.from(skillMap.values()) } function skillsToRecord(skills: LoadedSkill[]): Record { const result: Record = {} for (const skill of skills) { const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = skill.definition result[skill.name] = openCodeCompatible as CommandDefinition } return result } export async function loadUserSkills(): Promise> { const userSkillsDir = join(getClaudeConfigDir(), "skills") const skills = await loadSkillsFromDir(userSkillsDir, "user") return skillsToRecord(skills) } export async function loadProjectSkills(): Promise> { const projectSkillsDir = join(process.cwd(), ".claude", "skills") const skills = await loadSkillsFromDir(projectSkillsDir, "project") return skillsToRecord(skills) } export async function loadOpencodeGlobalSkills(): Promise> { const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const opencodeSkillsDir = join(configDir, "skills") const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode") return skillsToRecord(skills) } export async function loadOpencodeProjectSkills(): Promise> { const opencodeProjectDir = join(process.cwd(), ".opencode", "skills") const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project") return skillsToRecord(skills) } export interface DiscoverSkillsOptions { includeClaudeCodePaths?: boolean } /** * Deduplicates skills by name, keeping the first occurrence (higher priority). * Priority order: opencode-project > opencode > project > user * (OpenCode Global skills take precedence over legacy Claude project skills) */ function deduplicateSkills(skills: LoadedSkill[]): LoadedSkill[] { const seen = new Set() const result: LoadedSkill[] = [] for (const skill of skills) { if (!seen.has(skill.name)) { seen.add(skill.name) result.push(skill) } } return result } export async function discoverAllSkills(): Promise { const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([ discoverOpencodeProjectSkills(), discoverOpencodeGlobalSkills(), discoverProjectClaudeSkills(), discoverUserClaudeSkills(), ]) // Priority: opencode-project > opencode > project > user return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) } export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise { const { includeClaudeCodePaths = true } = options const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([ discoverOpencodeProjectSkills(), discoverOpencodeGlobalSkills(), ]) if (!includeClaudeCodePaths) { // Priority: opencode-project > opencode return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills]) } const [projectSkills, userSkills] = await Promise.all([ discoverProjectClaudeSkills(), discoverUserClaudeSkills(), ]) // Priority: opencode-project > opencode > project > user return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) } export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise { const skills = await discoverSkills(options) return skills.find(s => s.name === name) } export async function discoverUserClaudeSkills(): Promise { const userSkillsDir = join(getClaudeConfigDir(), "skills") return loadSkillsFromDir(userSkillsDir, "user") } export async function discoverProjectClaudeSkills(): Promise { const projectSkillsDir = join(process.cwd(), ".claude", "skills") return loadSkillsFromDir(projectSkillsDir, "project") } export async function discoverOpencodeGlobalSkills(): Promise { const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const opencodeSkillsDir = join(configDir, "skills") return loadSkillsFromDir(opencodeSkillsDir, "opencode") } export async function discoverOpencodeProjectSkills(): Promise { const opencodeProjectDir = join(process.cwd(), ".opencode", "skills") return loadSkillsFromDir(opencodeProjectDir, "opencode-project") }