import type { LoadedSkill, SkillScope, SkillMetadata } from "./types" import type { SkillsConfig, SkillDefinition } from "../../config/schema" import type { BuiltinSkill } from "../builtin-skills/types" import type { CommandDefinition } from "../claude-code-command-loader/types" import { readFileSync, existsSync } from "fs" import { dirname, resolve, isAbsolute } from "path" import { homedir } from "os" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { deepMerge } from "../../shared/deep-merge" const SCOPE_PRIORITY: Record = { builtin: 1, config: 2, user: 3, opencode: 4, project: 5, "opencode-project": 6, } function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill { const definition: CommandDefinition = { name: builtin.name, description: `(opencode - Skill) ${builtin.description}`, template: builtin.template, model: builtin.model, agent: builtin.agent, subtask: builtin.subtask, argumentHint: builtin.argumentHint, } return { name: builtin.name, definition, scope: "builtin", license: builtin.license, compatibility: builtin.compatibility, metadata: builtin.metadata as Record | undefined, allowedTools: builtin.allowedTools, mcpConfig: builtin.mcpConfig, } } function resolveFilePath(from: string, configDir?: string): string { let filePath = from if (filePath.startsWith("{file:") && filePath.endsWith("}")) { filePath = filePath.slice(6, -1) } if (filePath.startsWith("~/")) { return resolve(homedir(), filePath.slice(2)) } if (isAbsolute(filePath)) { return filePath } const baseDir = configDir || process.cwd() return resolve(baseDir, filePath) } function loadSkillFromFile(filePath: string): { template: string; metadata: SkillMetadata } | null { try { if (!existsSync(filePath)) return null const content = readFileSync(filePath, "utf-8") const { data, body } = parseFrontmatter(content) return { template: body, metadata: data } } catch { return null } } function configEntryToLoaded( name: string, entry: SkillDefinition, configDir?: string ): LoadedSkill | null { let template = entry.template || "" let fileMetadata: SkillMetadata = {} if (entry.from) { const filePath = resolveFilePath(entry.from, configDir) const loaded = loadSkillFromFile(filePath) if (loaded) { template = loaded.template fileMetadata = loaded.metadata } else { return null } } if (!template && !entry.from) { return null } const description = entry.description || fileMetadata.description || "" const resolvedPath = entry.from ? dirname(resolveFilePath(entry.from, configDir)) : configDir || process.cwd() const wrappedTemplate = ` Base directory for this skill: ${resolvedPath}/ File references (@path) in this skill are relative to this directory. ${template.trim()} $ARGUMENTS ` const definition: CommandDefinition = { name, description: `(config - Skill) ${description}`, template: wrappedTemplate, model: sanitizeModelField(entry.model || fileMetadata.model, "opencode"), agent: entry.agent || fileMetadata.agent, subtask: entry.subtask ?? fileMetadata.subtask, argumentHint: entry["argument-hint"] || fileMetadata["argument-hint"], } const allowedTools = entry["allowed-tools"] || (fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined) return { name, path: entry.from ? resolveFilePath(entry.from, configDir) : undefined, resolvedPath, definition, scope: "config", license: entry.license || fileMetadata.license, compatibility: entry.compatibility || fileMetadata.compatibility, metadata: entry.metadata as Record | undefined || fileMetadata.metadata, allowedTools, } } function normalizeConfig(config: SkillsConfig | undefined): { sources: Array enable: string[] disable: string[] entries: Record } { if (!config) { return { sources: [], enable: [], disable: [], entries: {} } } if (Array.isArray(config)) { return { sources: [], enable: config, disable: [], entries: {} } } const { sources = [], enable = [], disable = [], ...entries } = config return { sources, enable, disable, entries } } function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): LoadedSkill { const mergedMetadata = base.metadata || patch.metadata ? deepMerge(base.metadata || {}, (patch.metadata as Record) || {}) : undefined const mergedTools = base.allowedTools || patch["allowed-tools"] ? [...(base.allowedTools || []), ...(patch["allowed-tools"] || [])] : undefined const description = patch.description || base.definition.description?.replace(/^\([^)]+\) /, "") return { ...base, definition: { ...base.definition, description: `(${base.scope} - Skill) ${description}`, model: patch.model || base.definition.model, agent: patch.agent || base.definition.agent, subtask: patch.subtask ?? base.definition.subtask, argumentHint: patch["argument-hint"] || base.definition.argumentHint, }, license: patch.license || base.license, compatibility: patch.compatibility || base.compatibility, metadata: mergedMetadata as Record | undefined, allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined, } } export interface MergeSkillsOptions { configDir?: string } export function mergeSkills( builtinSkills: BuiltinSkill[], config: SkillsConfig | undefined, userClaudeSkills: LoadedSkill[], userOpencodeSkills: LoadedSkill[], projectClaudeSkills: LoadedSkill[], projectOpencodeSkills: LoadedSkill[], options: MergeSkillsOptions = {} ): LoadedSkill[] { const skillMap = new Map() for (const builtin of builtinSkills) { const loaded = builtinToLoaded(builtin) skillMap.set(loaded.name, loaded) } const normalizedConfig = normalizeConfig(config) for (const [name, entry] of Object.entries(normalizedConfig.entries)) { if (entry === false) continue if (entry === true) continue if (entry.disable) continue const loaded = configEntryToLoaded(name, entry, options.configDir) if (loaded) { const existing = skillMap.get(name) if (existing && !entry.template && !entry.from) { skillMap.set(name, mergeSkillDefinitions(existing, entry)) } else { skillMap.set(name, loaded) } } } const fileSystemSkills = [ ...userClaudeSkills, ...userOpencodeSkills, ...projectClaudeSkills, ...projectOpencodeSkills, ] for (const skill of fileSystemSkills) { const existing = skillMap.get(skill.name) if (!existing || SCOPE_PRIORITY[skill.scope] > SCOPE_PRIORITY[existing.scope]) { skillMap.set(skill.name, skill) } } for (const [name, entry] of Object.entries(normalizedConfig.entries)) { if (entry === true) continue if (entry === false) { skillMap.delete(name) continue } if (entry.disable) { skillMap.delete(name) continue } const existing = skillMap.get(name) if (existing && !entry.template && !entry.from) { skillMap.set(name, mergeSkillDefinitions(existing, entry)) } } for (const name of normalizedConfig.disable) { skillMap.delete(name) } if (normalizedConfig.enable.length > 0) { const enableSet = new Set(normalizedConfig.enable) for (const name of skillMap.keys()) { if (!enableSet.has(name)) { skillMap.delete(name) } } } return Array.from(skillMap.values()) }