From 51ced65b5f22893985d58af181b2c59319b383c5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:19 +0900 Subject: [PATCH] refactor(opencode-skill-loader): split loader and merger into focused modules Extract skill loading pipeline into single-responsibility modules: - skill-discovery.ts, skill-directory-loader.ts, skill-deduplication.ts - loaded-skill-from-path.ts, loaded-skill-template-extractor.ts - skill-template-resolver.ts, skill-definition-record.ts - git-master-template-injection.ts, allowed-tools-parser.ts - skill-mcp-config.ts, skill-resolution-options.ts - merger/ directory for skill merging logic --- .../allowed-tools-parser.ts | 9 + .../git-master-template-injection.ts | 81 ++++++ src/features/opencode-skill-loader/index.ts | 12 + .../loaded-skill-from-path.ts | 71 +++++ .../loaded-skill-template-extractor.ts | 12 + src/features/opencode-skill-loader/loader.ts | 254 ++--------------- src/features/opencode-skill-loader/merger.ts | 201 +------------- .../merger/builtin-skill-converter.ts | 26 ++ .../merger/config-skill-entry-loader.ts | 103 +++++++ .../merger/scope-priority.ts | 10 + .../merger/skill-definition-merger.ts | 31 +++ .../merger/skills-config-normalizer.ts | 19 ++ .../opencode-skill-loader/skill-content.ts | 259 +----------------- .../skill-deduplication.ts | 13 + .../skill-definition-record.ts | 11 + .../skill-directory-loader.ts | 106 +++++++ .../opencode-skill-loader/skill-discovery.ts | 76 +++++ .../opencode-skill-loader/skill-mcp-config.ts | 45 +++ .../skill-resolution-options.ts | 7 + .../skill-template-resolver.ts | 97 +++++++ 20 files changed, 769 insertions(+), 674 deletions(-) create mode 100644 src/features/opencode-skill-loader/allowed-tools-parser.ts create mode 100644 src/features/opencode-skill-loader/git-master-template-injection.ts create mode 100644 src/features/opencode-skill-loader/loaded-skill-from-path.ts create mode 100644 src/features/opencode-skill-loader/loaded-skill-template-extractor.ts create mode 100644 src/features/opencode-skill-loader/merger/builtin-skill-converter.ts create mode 100644 src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts create mode 100644 src/features/opencode-skill-loader/merger/scope-priority.ts create mode 100644 src/features/opencode-skill-loader/merger/skill-definition-merger.ts create mode 100644 src/features/opencode-skill-loader/merger/skills-config-normalizer.ts create mode 100644 src/features/opencode-skill-loader/skill-deduplication.ts create mode 100644 src/features/opencode-skill-loader/skill-definition-record.ts create mode 100644 src/features/opencode-skill-loader/skill-directory-loader.ts create mode 100644 src/features/opencode-skill-loader/skill-discovery.ts create mode 100644 src/features/opencode-skill-loader/skill-mcp-config.ts create mode 100644 src/features/opencode-skill-loader/skill-resolution-options.ts create mode 100644 src/features/opencode-skill-loader/skill-template-resolver.ts diff --git a/src/features/opencode-skill-loader/allowed-tools-parser.ts b/src/features/opencode-skill-loader/allowed-tools-parser.ts new file mode 100644 index 000000000..0bf1354d0 --- /dev/null +++ b/src/features/opencode-skill-loader/allowed-tools-parser.ts @@ -0,0 +1,9 @@ +export function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined { + if (!allowedTools) return undefined + + if (Array.isArray(allowedTools)) { + return allowedTools.map((tool) => tool.trim()).filter(Boolean) + } + + return allowedTools.split(/\s+/).filter(Boolean) +} diff --git a/src/features/opencode-skill-loader/git-master-template-injection.ts b/src/features/opencode-skill-loader/git-master-template-injection.ts new file mode 100644 index 000000000..f6815798c --- /dev/null +++ b/src/features/opencode-skill-loader/git-master-template-injection.ts @@ -0,0 +1,81 @@ +import type { GitMasterConfig } from "../../config/schema" + +export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string { + const commitFooter = config?.commit_footer ?? true + const includeCoAuthoredBy = config?.include_co_authored_by ?? true + + if (!commitFooter && !includeCoAuthoredBy) { + return template + } + + const sections: string[] = [] + + sections.push("### 5.5 Commit Footer & Co-Author") + sections.push("") + sections.push("Add Sisyphus attribution to EVERY commit:") + sections.push("") + + if (commitFooter) { + const footerText = + typeof commitFooter === "string" + ? commitFooter + : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" + sections.push("1. **Footer in commit body:**") + sections.push("```") + sections.push(footerText) + sections.push("```") + sections.push("") + } + + if (includeCoAuthoredBy) { + sections.push(`${commitFooter ? "2" : "1"}. **Co-authored-by trailer:**`) + sections.push("```") + sections.push("Co-authored-by: Sisyphus ") + sections.push("```") + sections.push("") + } + + if (commitFooter && includeCoAuthoredBy) { + const footerText = + typeof commitFooter === "string" + ? commitFooter + : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" + sections.push("**Example (both enabled):**") + sections.push("```bash") + sections.push( + `git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus "` + ) + sections.push("```") + } else if (commitFooter) { + const footerText = + typeof commitFooter === "string" + ? commitFooter + : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" + sections.push("**Example:**") + sections.push("```bash") + sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`) + sections.push("```") + } else if (includeCoAuthoredBy) { + sections.push("**Example:**") + sections.push("```bash") + sections.push( + "git commit -m \"{Commit Message}\" -m \"Co-authored-by: Sisyphus \"" + ) + sections.push("```") + } + + const injection = sections.join("\n") + + const insertionPoint = template.indexOf("```\n") + if (insertionPoint !== -1) { + return ( + template.slice(0, insertionPoint) + + "```\n\n" + + injection + + "\n" + + template.slice(insertionPoint + "```\n".length) + ) + } + + return template + "\n\n" + injection +} diff --git a/src/features/opencode-skill-loader/index.ts b/src/features/opencode-skill-loader/index.ts index cb4646289..68c556245 100644 --- a/src/features/opencode-skill-loader/index.ts +++ b/src/features/opencode-skill-loader/index.ts @@ -2,3 +2,15 @@ export * from "./types" export * from "./loader" export * from "./merger" export * from "./skill-content" + +export * from "./skill-directory-loader" +export * from "./loaded-skill-from-path" +export * from "./skill-mcp-config" +export * from "./skill-deduplication" +export * from "./skill-definition-record" + +export * from "./git-master-template-injection" +export * from "./skill-discovery" +export * from "./skill-resolution-options" +export * from "./loaded-skill-template-extractor" +export * from "./skill-template-resolver" diff --git a/src/features/opencode-skill-loader/loaded-skill-from-path.ts b/src/features/opencode-skill-loader/loaded-skill-from-path.ts new file mode 100644 index 000000000..7d3f646e2 --- /dev/null +++ b/src/features/opencode-skill-loader/loaded-skill-from-path.ts @@ -0,0 +1,71 @@ +import { promises as fs } from "fs" +import { basename } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import { parseAllowedTools } from "./allowed-tools-parser" +import { loadMcpJsonFromDir, parseSkillMcpConfigFromFrontmatter } from "./skill-mcp-config" +import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types" + +export async function loadSkillFromPath(options: { + skillPath: string + resolvedPath: string + defaultName: string + scope: SkillScope + namePrefix?: string +}): Promise { + const namePrefix = options.namePrefix ?? "" + + try { + const content = await fs.readFile(options.skillPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) + const mcpJsonMcp = await loadMcpJsonFromDir(options.resolvedPath) + const mcpConfig = mcpJsonMcp || frontmatterMcp + + const baseName = data.name || options.defaultName + const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName + const originalDescription = data.description || "" + const isOpencodeSource = options.scope === "opencode" || options.scope === "opencode-project" + const formattedDescription = `(${options.scope} - Skill) ${originalDescription}` + + const templateContent = `\nBase directory for this skill: ${options.resolvedPath}/\nFile references (@path) in this skill are relative to this directory.\n\n${body.trim()}\n\n\n\n$ARGUMENTS\n` + + 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: options.skillPath, + resolvedPath: options.resolvedPath, + definition, + scope: options.scope, + license: data.license, + compatibility: data.compatibility, + metadata: data.metadata, + allowedTools: parseAllowedTools(data["allowed-tools"]), + mcpConfig, + lazyContent: eagerLoader, + } + } catch { + return null + } +} + +export function inferSkillNameFromFileName(filePath: string): string { + return basename(filePath, ".md") +} diff --git a/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts b/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts new file mode 100644 index 000000000..ba20552e5 --- /dev/null +++ b/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts @@ -0,0 +1,12 @@ +import { readFileSync } from "node:fs" +import { parseFrontmatter } from "../../shared/frontmatter" +import type { LoadedSkill } from "./types" + +export function extractSkillTemplate(skill: LoadedSkill): string { + if (skill.path) { + const content = readFileSync(skill.path, "utf-8") + const { body } = parseFrontmatter(content) + return body.trim() + } + return skill.definition.template || "" +} diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 595224751..6caf4e73d 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -1,255 +1,41 @@ -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 { join } from "path" 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 -} +import type { LoadedSkill } from "./types" +import { skillsToCommandDefinitionRecord } from "./skill-definition-record" +import { deduplicateSkillsByName } from "./skill-deduplication" +import { loadSkillsFromDir } from "./skill-directory-loader" export async function loadUserSkills(): Promise> { const userSkillsDir = join(getClaudeConfigDir(), "skills") - const skills = await loadSkillsFromDir(userSkillsDir, "user") - return skillsToRecord(skills) + const skills = await loadSkillsFromDir({ skillsDir: userSkillsDir, scope: "user" }) + return skillsToCommandDefinitionRecord(skills) } export async function loadProjectSkills(): Promise> { const projectSkillsDir = join(process.cwd(), ".claude", "skills") - const skills = await loadSkillsFromDir(projectSkillsDir, "project") - return skillsToRecord(skills) + const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) + return skillsToCommandDefinitionRecord(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) + const skills = await loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" }) + return skillsToCommandDefinitionRecord(skills) } export async function loadOpencodeProjectSkills(): Promise> { const opencodeProjectDir = join(process.cwd(), ".opencode", "skills") - const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project") - return skillsToRecord(skills) + const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) + return skillsToCommandDefinitionRecord(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(), @@ -259,7 +45,7 @@ export async function discoverAllSkills(): Promise { ]) // Priority: opencode-project > opencode > project > user - return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) + return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) } export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise { @@ -272,7 +58,7 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi if (!includeClaudeCodePaths) { // Priority: opencode-project > opencode - return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills]) + return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills]) } const [projectSkills, userSkills] = await Promise.all([ @@ -281,7 +67,7 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi ]) // Priority: opencode-project > opencode > project > user - return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) + return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) } export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise { @@ -291,21 +77,21 @@ export async function getSkillByName(name: string, options: DiscoverSkillsOption export async function discoverUserClaudeSkills(): Promise { const userSkillsDir = join(getClaudeConfigDir(), "skills") - return loadSkillsFromDir(userSkillsDir, "user") + return loadSkillsFromDir({ skillsDir: userSkillsDir, scope: "user" }) } export async function discoverProjectClaudeSkills(): Promise { const projectSkillsDir = join(process.cwd(), ".claude", "skills") - return loadSkillsFromDir(projectSkillsDir, "project") + return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) } export async function discoverOpencodeGlobalSkills(): Promise { const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const opencodeSkillsDir = join(configDir, "skills") - return loadSkillsFromDir(opencodeSkillsDir, "opencode") + return loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" }) } export async function discoverOpencodeProjectSkills(): Promise { const opencodeProjectDir = join(process.cwd(), ".opencode", "skills") - return loadSkillsFromDir(opencodeProjectDir, "opencode-project") + return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) } diff --git a/src/features/opencode-skill-loader/merger.ts b/src/features/opencode-skill-loader/merger.ts index cace1a22a..c5598ca5d 100644 --- a/src/features/opencode-skill-loader/merger.ts +++ b/src/features/opencode-skill-loader/merger.ts @@ -1,192 +1,11 @@ -import type { LoadedSkill, SkillScope, SkillMetadata } from "./types" -import type { SkillsConfig, SkillDefinition } from "../../config/schema" +import type { LoadedSkill } from "./types" +import type { SkillsConfig } 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" - -function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined { - if (!allowedTools) return undefined - if (Array.isArray(allowedTools)) { - return allowedTools.map(t => t.trim()).filter(Boolean) - } - return allowedTools.split(/\s+/).filter(Boolean) -} - -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"] ? parseAllowedToolsFromMetadata(fileMetadata["allowed-tools"]) : 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, - } -} +import { builtinToLoadedSkill } from "./merger/builtin-skill-converter" +import { configEntryToLoadedSkill } from "./merger/config-skill-entry-loader" +import { mergeSkillDefinitions } from "./merger/skill-definition-merger" +import { normalizeSkillsConfig } from "./merger/skills-config-normalizer" +import { SCOPE_PRIORITY } from "./merger/scope-priority" export interface MergeSkillsOptions { configDir?: string @@ -204,11 +23,11 @@ export function mergeSkills( const skillMap = new Map() for (const builtin of builtinSkills) { - const loaded = builtinToLoaded(builtin) + const loaded = builtinToLoadedSkill(builtin) skillMap.set(loaded.name, loaded) } - const normalizedConfig = normalizeConfig(config) + const normalizedConfig = normalizeSkillsConfig(config) for (const [name, entry] of Object.entries(normalizedConfig.entries)) { if (entry === false) continue @@ -216,7 +35,7 @@ export function mergeSkills( if (entry.disable) continue - const loaded = configEntryToLoaded(name, entry, options.configDir) + const loaded = configEntryToLoadedSkill(name, entry, options.configDir) if (loaded) { const existing = skillMap.get(name) if (existing && !entry.template && !entry.from) { diff --git a/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts b/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts new file mode 100644 index 000000000..da445c5d4 --- /dev/null +++ b/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts @@ -0,0 +1,26 @@ +import type { BuiltinSkill } from "../../builtin-skills/types" +import type { CommandDefinition } from "../../claude-code-command-loader/types" +import type { LoadedSkill } from "../types" + +export function builtinToLoadedSkill(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, + } +} diff --git a/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts b/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts new file mode 100644 index 000000000..74d9772b8 --- /dev/null +++ b/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts @@ -0,0 +1,103 @@ +import type { LoadedSkill, SkillMetadata } from "../types" +import type { SkillDefinition } from "../../../config/schema" +import type { CommandDefinition } from "../../claude-code-command-loader/types" +import { existsSync, readFileSync } from "fs" +import { dirname, isAbsolute, resolve } from "path" +import { homedir } from "os" +import { parseFrontmatter } from "../../../shared/frontmatter" +import { sanitizeModelField } from "../../../shared/model-sanitizer" +import { parseAllowedTools } from "../allowed-tools-parser" + +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 + } +} + +export function configEntryToLoadedSkill( + 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"] || parseAllowedTools(fileMetadata["allowed-tools"]) + + 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, + } +} diff --git a/src/features/opencode-skill-loader/merger/scope-priority.ts b/src/features/opencode-skill-loader/merger/scope-priority.ts new file mode 100644 index 000000000..c665d700c --- /dev/null +++ b/src/features/opencode-skill-loader/merger/scope-priority.ts @@ -0,0 +1,10 @@ +import type { SkillScope } from "../types" + +export const SCOPE_PRIORITY: Record = { + builtin: 1, + config: 2, + user: 3, + opencode: 4, + project: 5, + "opencode-project": 6, +} diff --git a/src/features/opencode-skill-loader/merger/skill-definition-merger.ts b/src/features/opencode-skill-loader/merger/skill-definition-merger.ts new file mode 100644 index 000000000..691be1c3b --- /dev/null +++ b/src/features/opencode-skill-loader/merger/skill-definition-merger.ts @@ -0,0 +1,31 @@ +import type { LoadedSkill } from "../types" +import type { SkillDefinition } from "../../../config/schema" +import { deepMerge } from "../../../shared/deep-merge" + +export 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, + } +} diff --git a/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts b/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts new file mode 100644 index 000000000..94043521b --- /dev/null +++ b/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts @@ -0,0 +1,19 @@ +import type { SkillsConfig, SkillDefinition } from "../../../config/schema" + +export function normalizeSkillsConfig(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 } +} diff --git a/src/features/opencode-skill-loader/skill-content.ts b/src/features/opencode-skill-loader/skill-content.ts index 5441e10a6..27a8e0f0b 100644 --- a/src/features/opencode-skill-loader/skill-content.ts +++ b/src/features/opencode-skill-loader/skill-content.ts @@ -1,250 +1,11 @@ -import { createBuiltinSkills } from "../builtin-skills/skills" -import { discoverSkills } from "./loader" -import type { LoadedSkill } from "./types" -import { parseFrontmatter } from "../../shared/frontmatter" -import { readFileSync } from "node:fs" -import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema" +export type { SkillResolutionOptions } from "./skill-resolution-options" -export interface SkillResolutionOptions { - gitMasterConfig?: GitMasterConfig - browserProvider?: BrowserAutomationProvider - disabledSkills?: Set -} - -const cachedSkillsByProvider = new Map() - -function clearSkillCache(): void { - cachedSkillsByProvider.clear() -} - -async function getAllSkills(options?: SkillResolutionOptions): Promise { - const cacheKey = options?.browserProvider ?? "playwright" - const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0 - - // Skip cache if disabledSkills is provided (varies between calls) - if (!hasDisabledSkills) { - const cached = cachedSkillsByProvider.get(cacheKey) - if (cached) return cached - } - - const [discoveredSkills, builtinSkillDefs] = await Promise.all([ - discoverSkills({ includeClaudeCodePaths: true }), - Promise.resolve( - createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - ), - ]) - - const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefs.map((skill) => ({ - name: skill.name, - definition: { - name: skill.name, - description: skill.description, - template: skill.template, - model: skill.model, - agent: skill.agent, - subtask: skill.subtask, - }, - scope: "builtin" as const, - license: skill.license, - compatibility: skill.compatibility, - metadata: skill.metadata as Record | undefined, - allowedTools: skill.allowedTools, - mcpConfig: skill.mcpConfig, - })) - - // Provider-gated skill names that should be filtered based on browserProvider - const providerGatedSkillNames = new Set(["agent-browser", "playwright"]) - const browserProvider = options?.browserProvider ?? "playwright" - - // Filter discovered skills to exclude provider-gated names that don't match the selected provider - const filteredDiscoveredSkills = discoveredSkills.filter((skill) => { - if (!providerGatedSkillNames.has(skill.name)) { - return true - } - // For provider-gated skills, only include if it matches the selected provider - return skill.name === browserProvider - }) - - const discoveredNames = new Set(filteredDiscoveredSkills.map((s) => s.name)) - const uniqueBuiltins = builtinSkillsAsLoaded.filter((s) => !discoveredNames.has(s.name)) - - let allSkills = [...filteredDiscoveredSkills, ...uniqueBuiltins] - - // Filter discovered skills by disabledSkills (builtin skills are already filtered by createBuiltinSkills) - if (hasDisabledSkills) { - allSkills = allSkills.filter((s) => !options!.disabledSkills!.has(s.name)) - } else { - cachedSkillsByProvider.set(cacheKey, allSkills) - } - - return allSkills -} - -async function extractSkillTemplate(skill: LoadedSkill): Promise { - if (skill.path) { - const content = readFileSync(skill.path, "utf-8") - const { body } = parseFrontmatter(content) - return body.trim() - } - return skill.definition.template || "" -} - -export { clearSkillCache, getAllSkills, extractSkillTemplate } - -export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string { - const commitFooter = config?.commit_footer ?? true - const includeCoAuthoredBy = config?.include_co_authored_by ?? true - - if (!commitFooter && !includeCoAuthoredBy) { - return template - } - - const sections: string[] = [] - - sections.push(`### 5.5 Commit Footer & Co-Author`) - sections.push(``) - sections.push(`Add Sisyphus attribution to EVERY commit:`) - sections.push(``) - - if (commitFooter) { - const footerText = typeof commitFooter === "string" ? commitFooter : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" - sections.push(`1. **Footer in commit body:**`) - sections.push("```") - sections.push(footerText) - sections.push("```") - sections.push(``) - } - - if (includeCoAuthoredBy) { - sections.push(`${commitFooter ? "2" : "1"}. **Co-authored-by trailer:**`) - sections.push("```") - sections.push(`Co-authored-by: Sisyphus `) - sections.push("```") - sections.push(``) - } - - if (commitFooter && includeCoAuthoredBy) { - const footerText = typeof commitFooter === "string" ? commitFooter : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" - sections.push(`**Example (both enabled):**`) - sections.push("```bash") - sections.push(`git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus "`) - sections.push("```") - } else if (commitFooter) { - const footerText = typeof commitFooter === "string" ? commitFooter : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" - sections.push(`**Example:**`) - sections.push("```bash") - sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`) - sections.push("```") - } else if (includeCoAuthoredBy) { - sections.push(`**Example:**`) - sections.push("```bash") - sections.push(`git commit -m "{Commit Message}" -m "Co-authored-by: Sisyphus "`) - sections.push("```") - } - - const injection = sections.join("\n") - - const insertionPoint = template.indexOf("```\n") - if (insertionPoint !== -1) { - return template.slice(0, insertionPoint) + "```\n\n" + injection + "\n" + template.slice(insertionPoint + "```\n".length) - } - - return template + "\n\n" + injection -} - -export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null { - const skills = createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - const skill = skills.find((s) => s.name === skillName) - if (!skill) return null - - if (skillName === "git-master") { - return injectGitMasterConfig(skill.template, options?.gitMasterConfig) - } - - return skill.template -} - -export function resolveMultipleSkills(skillNames: string[], options?: SkillResolutionOptions): { - resolved: Map - notFound: string[] -} { - const skills = createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - const skillMap = new Map(skills.map((s) => [s.name, s.template])) - - const resolved = new Map() - const notFound: string[] = [] - - for (const name of skillNames) { - const template = skillMap.get(name) - if (template) { - if (name === "git-master") { - resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) - } else { - resolved.set(name, template) - } - } else { - notFound.push(name) - } - } - - return { resolved, notFound } -} - -export async function resolveSkillContentAsync( - skillName: string, - options?: SkillResolutionOptions -): Promise { - const allSkills = await getAllSkills(options) - const skill = allSkills.find((s) => s.name === skillName) - if (!skill) return null - - const template = await extractSkillTemplate(skill) - - if (skillName === "git-master") { - return injectGitMasterConfig(template, options?.gitMasterConfig) - } - - return template -} - -export async function resolveMultipleSkillsAsync( - skillNames: string[], - options?: SkillResolutionOptions -): Promise<{ - resolved: Map - notFound: string[] -}> { - const allSkills = await getAllSkills(options) - const skillMap = new Map() - for (const skill of allSkills) { - skillMap.set(skill.name, skill) - } - - const resolved = new Map() - const notFound: string[] = [] - - for (const name of skillNames) { - const skill = skillMap.get(name) - if (skill) { - const template = await extractSkillTemplate(skill) - if (name === "git-master") { - resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) - } else { - resolved.set(name, template) - } - } else { - notFound.push(name) - } - } - - return { resolved, notFound } -} +export { clearSkillCache, getAllSkills } from "./skill-discovery" +export { extractSkillTemplate } from "./loaded-skill-template-extractor" +export { injectGitMasterConfig } from "./git-master-template-injection" +export { + resolveSkillContent, + resolveMultipleSkills, + resolveSkillContentAsync, + resolveMultipleSkillsAsync, +} from "./skill-template-resolver" diff --git a/src/features/opencode-skill-loader/skill-deduplication.ts b/src/features/opencode-skill-loader/skill-deduplication.ts new file mode 100644 index 000000000..1c3c6a405 --- /dev/null +++ b/src/features/opencode-skill-loader/skill-deduplication.ts @@ -0,0 +1,13 @@ +import type { LoadedSkill } from "./types" + +export function deduplicateSkillsByName(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 +} diff --git a/src/features/opencode-skill-loader/skill-definition-record.ts b/src/features/opencode-skill-loader/skill-definition-record.ts new file mode 100644 index 000000000..71b811c2a --- /dev/null +++ b/src/features/opencode-skill-loader/skill-definition-record.ts @@ -0,0 +1,11 @@ +import type { CommandDefinition } from "../claude-code-command-loader/types" +import type { LoadedSkill } from "./types" + +export function skillsToCommandDefinitionRecord(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 +} diff --git a/src/features/opencode-skill-loader/skill-directory-loader.ts b/src/features/opencode-skill-loader/skill-directory-loader.ts new file mode 100644 index 000000000..13c859d6b --- /dev/null +++ b/src/features/opencode-skill-loader/skill-directory-loader.ts @@ -0,0 +1,106 @@ +import { promises as fs } from "fs" +import { join } from "path" +import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils" +import type { LoadedSkill, SkillScope } from "./types" +import { inferSkillNameFromFileName, loadSkillFromPath } from "./loaded-skill-from-path" + +export async function loadSkillsFromDir(options: { + skillsDir: string + scope: SkillScope + namePrefix?: string + depth?: number + maxDepth?: number +}): Promise { + const namePrefix = options.namePrefix ?? "" + const depth = options.depth ?? 0 + const maxDepth = options.maxDepth ?? 2 + + const entries = await fs.readdir(options.skillsDir, { withFileTypes: true }).catch(() => []) + const skillMap = new Map() + + const directories = entries.filter( + (entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()) + ) + const files = entries.filter( + (entry) => + !entry.name.startsWith(".") && + !entry.isDirectory() && + !entry.isSymbolicLink() && + isMarkdownFile(entry) + ) + + for (const entry of directories) { + const entryPath = join(options.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({ + skillPath: skillMdPath, + resolvedPath, + defaultName: dirName, + scope: options.scope, + namePrefix, + }) + if (skill && !skillMap.has(skill.name)) { + skillMap.set(skill.name, skill) + } + continue + } catch { + // no SKILL.md + } + + const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) + try { + await fs.access(namedSkillMdPath) + const skill = await loadSkillFromPath({ + skillPath: namedSkillMdPath, + resolvedPath, + defaultName: dirName, + scope: options.scope, + namePrefix, + }) + if (skill && !skillMap.has(skill.name)) { + skillMap.set(skill.name, skill) + } + continue + } catch { + // no named md + } + + if (depth < maxDepth) { + const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName + const nestedSkills = await loadSkillsFromDir({ + skillsDir: resolvedPath, + scope: options.scope, + namePrefix: newPrefix, + depth: 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(options.skillsDir, entry.name) + const baseName = inferSkillNameFromFileName(entryPath) + const skill = await loadSkillFromPath({ + skillPath: entryPath, + resolvedPath: options.skillsDir, + defaultName: baseName, + scope: options.scope, + namePrefix, + }) + if (skill && !skillMap.has(skill.name)) { + skillMap.set(skill.name, skill) + } + } + + return Array.from(skillMap.values()) +} diff --git a/src/features/opencode-skill-loader/skill-discovery.ts b/src/features/opencode-skill-loader/skill-discovery.ts new file mode 100644 index 000000000..2154b06ea --- /dev/null +++ b/src/features/opencode-skill-loader/skill-discovery.ts @@ -0,0 +1,76 @@ +import { createBuiltinSkills } from "../builtin-skills/skills" +import { discoverSkills } from "./loader" +import type { LoadedSkill } from "./types" +import type { SkillResolutionOptions } from "./skill-resolution-options" + +const cachedSkillsByProvider = new Map() + +export function clearSkillCache(): void { + cachedSkillsByProvider.clear() +} + +export async function getAllSkills(options?: SkillResolutionOptions): Promise { + const cacheKey = options?.browserProvider ?? "playwright" + const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0 + + // Skip cache if disabledSkills is provided (varies between calls) + if (!hasDisabledSkills) { + const cached = cachedSkillsByProvider.get(cacheKey) + if (cached) return cached + } + + const [discoveredSkills, builtinSkillDefinitions] = await Promise.all([ + discoverSkills({ includeClaudeCodePaths: true }), + Promise.resolve( + createBuiltinSkills({ + browserProvider: options?.browserProvider, + disabledSkills: options?.disabledSkills, + }) + ), + ]) + + const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefinitions.map((skill) => ({ + name: skill.name, + definition: { + name: skill.name, + description: skill.description, + template: skill.template, + model: skill.model, + agent: skill.agent, + subtask: skill.subtask, + }, + scope: "builtin" as const, + license: skill.license, + compatibility: skill.compatibility, + metadata: skill.metadata as Record | undefined, + allowedTools: skill.allowedTools, + mcpConfig: skill.mcpConfig, + })) + + // Provider-gated skill names that should be filtered based on browserProvider + const providerGatedSkillNames = new Set(["agent-browser", "playwright"]) + const browserProvider = options?.browserProvider ?? "playwright" + + // Filter discovered skills to exclude provider-gated names that don't match the selected provider + const filteredDiscoveredSkills = discoveredSkills.filter((skill) => { + if (!providerGatedSkillNames.has(skill.name)) { + return true + } + // For provider-gated skills, only include if it matches the selected provider + return skill.name === browserProvider + }) + + const discoveredNames = new Set(filteredDiscoveredSkills.map((skill) => skill.name)) + const uniqueBuiltins = builtinSkillsAsLoaded.filter((skill) => !discoveredNames.has(skill.name)) + + let allSkills = [...filteredDiscoveredSkills, ...uniqueBuiltins] + + // Filter discovered skills by disabledSkills (builtin skills are already filtered by createBuiltinSkills) + if (hasDisabledSkills) { + allSkills = allSkills.filter((skill) => !options!.disabledSkills!.has(skill.name)) + } else { + cachedSkillsByProvider.set(cacheKey, allSkills) + } + + return allSkills +} diff --git a/src/features/opencode-skill-loader/skill-mcp-config.ts b/src/features/opencode-skill-loader/skill-mcp-config.ts new file mode 100644 index 000000000..211940f46 --- /dev/null +++ b/src/features/opencode-skill-loader/skill-mcp-config.ts @@ -0,0 +1,45 @@ +import { promises as fs } from "fs" +import { join } from "path" +import yaml from "js-yaml" +import type { SkillMcpConfig } from "../skill-mcp-manager/types" + +export 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 +} + +export 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( + (value) => value && typeof value === "object" && "command" in (value as Record) + ) + if (hasCommandField) { + return parsed as SkillMcpConfig + } + } + } catch { + return undefined + } + + return undefined +} diff --git a/src/features/opencode-skill-loader/skill-resolution-options.ts b/src/features/opencode-skill-loader/skill-resolution-options.ts new file mode 100644 index 000000000..3955aa5ff --- /dev/null +++ b/src/features/opencode-skill-loader/skill-resolution-options.ts @@ -0,0 +1,7 @@ +import type { BrowserAutomationProvider, GitMasterConfig } from "../../config/schema" + +export interface SkillResolutionOptions { + gitMasterConfig?: GitMasterConfig + browserProvider?: BrowserAutomationProvider + disabledSkills?: Set +} diff --git a/src/features/opencode-skill-loader/skill-template-resolver.ts b/src/features/opencode-skill-loader/skill-template-resolver.ts new file mode 100644 index 000000000..046256c37 --- /dev/null +++ b/src/features/opencode-skill-loader/skill-template-resolver.ts @@ -0,0 +1,97 @@ +import { createBuiltinSkills } from "../builtin-skills/skills" +import type { LoadedSkill } from "./types" +import type { SkillResolutionOptions } from "./skill-resolution-options" +import { injectGitMasterConfig } from "./git-master-template-injection" +import { getAllSkills } from "./skill-discovery" +import { extractSkillTemplate } from "./loaded-skill-template-extractor" + +export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null { + const skills = createBuiltinSkills({ + browserProvider: options?.browserProvider, + disabledSkills: options?.disabledSkills, + }) + const skill = skills.find((builtinSkill) => builtinSkill.name === skillName) + if (!skill) return null + + if (skillName === "git-master") { + return injectGitMasterConfig(skill.template, options?.gitMasterConfig) + } + + return skill.template +} + +export function resolveMultipleSkills( + skillNames: string[], + options?: SkillResolutionOptions +): { resolved: Map; notFound: string[] } { + const skills = createBuiltinSkills({ + browserProvider: options?.browserProvider, + disabledSkills: options?.disabledSkills, + }) + const skillMap = new Map(skills.map((skill) => [skill.name, skill.template])) + + const resolved = new Map() + const notFound: string[] = [] + + for (const name of skillNames) { + const template = skillMap.get(name) + if (template) { + if (name === "git-master") { + resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) + } else { + resolved.set(name, template) + } + } else { + notFound.push(name) + } + } + + return { resolved, notFound } +} + +export async function resolveSkillContentAsync( + skillName: string, + options?: SkillResolutionOptions +): Promise { + const allSkills = await getAllSkills(options) + const skill = allSkills.find((loadedSkill) => loadedSkill.name === skillName) + if (!skill) return null + + const template = await extractSkillTemplate(skill) + + if (skillName === "git-master") { + return injectGitMasterConfig(template, options?.gitMasterConfig) + } + + return template +} + +export async function resolveMultipleSkillsAsync( + skillNames: string[], + options?: SkillResolutionOptions +): Promise<{ resolved: Map; notFound: string[] }> { + const allSkills = await getAllSkills(options) + const skillMap = new Map() + for (const skill of allSkills) { + skillMap.set(skill.name, skill) + } + + const resolved = new Map() + const notFound: string[] = [] + + for (const name of skillNames) { + const skill = skillMap.get(name) + if (skill) { + const template = await extractSkillTemplate(skill) + if (name === "git-master") { + resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) + } else { + resolved.set(name, template) + } + } else { + notFound.push(name) + } + } + + return { resolved, notFound } +}