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
This commit is contained in:
parent
f8b5771443
commit
51ced65b5f
@ -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)
|
||||||
|
}
|
||||||
@ -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 <clio-agent@sisyphuslabs.ai>")
|
||||||
|
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 <clio-agent@sisyphuslabs.ai>"`
|
||||||
|
)
|
||||||
|
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 <clio-agent@sisyphuslabs.ai>\""
|
||||||
|
)
|
||||||
|
sections.push("```")
|
||||||
|
}
|
||||||
|
|
||||||
|
const injection = sections.join("\n")
|
||||||
|
|
||||||
|
const insertionPoint = template.indexOf("```\n</execution>")
|
||||||
|
if (insertionPoint !== -1) {
|
||||||
|
return (
|
||||||
|
template.slice(0, insertionPoint) +
|
||||||
|
"```\n\n" +
|
||||||
|
injection +
|
||||||
|
"\n</execution>" +
|
||||||
|
template.slice(insertionPoint + "```\n</execution>".length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return template + "\n\n" + injection
|
||||||
|
}
|
||||||
@ -2,3 +2,15 @@ export * from "./types"
|
|||||||
export * from "./loader"
|
export * from "./loader"
|
||||||
export * from "./merger"
|
export * from "./merger"
|
||||||
export * from "./skill-content"
|
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"
|
||||||
|
|||||||
71
src/features/opencode-skill-loader/loaded-skill-from-path.ts
Normal file
71
src/features/opencode-skill-loader/loaded-skill-from-path.ts
Normal file
@ -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<LoadedSkill | null> {
|
||||||
|
const namePrefix = options.namePrefix ?? ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(options.skillPath, "utf-8")
|
||||||
|
const { data, body } = parseFrontmatter<SkillMetadata>(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 = `<skill-instruction>\nBase directory for this skill: ${options.resolvedPath}/\nFile references (@path) in this skill are relative to this directory.\n\n${body.trim()}\n</skill-instruction>\n\n<user-request>\n$ARGUMENTS\n</user-request>`
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@ -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 || ""
|
||||||
|
}
|
||||||
@ -1,255 +1,41 @@
|
|||||||
import { promises as fs } from "fs"
|
import { join } from "path"
|
||||||
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 { getClaudeConfigDir } from "../../shared"
|
||||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||||
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
|
import type { LoadedSkill } from "./types"
|
||||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
import { skillsToCommandDefinitionRecord } from "./skill-definition-record"
|
||||||
|
import { deduplicateSkillsByName } from "./skill-deduplication"
|
||||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
import { loadSkillsFromDir } from "./skill-directory-loader"
|
||||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
||||||
if (!frontmatterMatch) return undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
|
|
||||||
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<SkillMcpConfig | undefined> {
|
|
||||||
const mcpJsonPath = join(skillDir, "mcp.json")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(mcpJsonPath, "utf-8")
|
|
||||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
|
||||||
|
|
||||||
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<string, unknown>)
|
|
||||||
)
|
|
||||||
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<LoadedSkill | null> {
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(skillPath, "utf-8")
|
|
||||||
const { data, body } = parseFrontmatter<SkillMetadata>(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 = `<skill-instruction>
|
|
||||||
Base directory for this skill: ${resolvedPath}/
|
|
||||||
File references (@path) in this skill are relative to this directory.
|
|
||||||
|
|
||||||
${body.trim()}
|
|
||||||
</skill-instruction>
|
|
||||||
|
|
||||||
<user-request>
|
|
||||||
$ARGUMENTS
|
|
||||||
</user-request>`
|
|
||||||
|
|
||||||
// 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<LoadedSkill[]> {
|
|
||||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
|
||||||
const skillMap = new Map<string, LoadedSkill>()
|
|
||||||
|
|
||||||
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<string, CommandDefinition> {
|
|
||||||
const result: Record<string, CommandDefinition> = {}
|
|
||||||
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<Record<string, CommandDefinition>> {
|
export async function loadUserSkills(): Promise<Record<string, CommandDefinition>> {
|
||||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||||
const skills = await loadSkillsFromDir(userSkillsDir, "user")
|
const skills = await loadSkillsFromDir({ skillsDir: userSkillsDir, scope: "user" })
|
||||||
return skillsToRecord(skills)
|
return skillsToCommandDefinitionRecord(skills)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadProjectSkills(): Promise<Record<string, CommandDefinition>> {
|
export async function loadProjectSkills(): Promise<Record<string, CommandDefinition>> {
|
||||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||||
const skills = await loadSkillsFromDir(projectSkillsDir, "project")
|
const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
|
||||||
return skillsToRecord(skills)
|
return skillsToCommandDefinitionRecord(skills)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
|
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
|
||||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||||
const opencodeSkillsDir = join(configDir, "skills")
|
const opencodeSkillsDir = join(configDir, "skills")
|
||||||
const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
const skills = await loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" })
|
||||||
return skillsToRecord(skills)
|
return skillsToCommandDefinitionRecord(skills)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOpencodeProjectSkills(): Promise<Record<string, CommandDefinition>> {
|
export async function loadOpencodeProjectSkills(): Promise<Record<string, CommandDefinition>> {
|
||||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skills")
|
const opencodeProjectDir = join(process.cwd(), ".opencode", "skills")
|
||||||
const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
|
||||||
return skillsToRecord(skills)
|
return skillsToCommandDefinitionRecord(skills)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoverSkillsOptions {
|
export interface DiscoverSkillsOptions {
|
||||||
includeClaudeCodePaths?: boolean
|
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<string>()
|
|
||||||
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<LoadedSkill[]> {
|
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
||||||
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
|
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
|
||||||
discoverOpencodeProjectSkills(),
|
discoverOpencodeProjectSkills(),
|
||||||
@ -259,7 +45,7 @@ export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Priority: opencode-project > opencode > project > user
|
// 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<LoadedSkill[]> {
|
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||||
@ -272,7 +58,7 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
|
|||||||
|
|
||||||
if (!includeClaudeCodePaths) {
|
if (!includeClaudeCodePaths) {
|
||||||
// Priority: opencode-project > opencode
|
// Priority: opencode-project > opencode
|
||||||
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills])
|
return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills])
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projectSkills, userSkills] = await Promise.all([
|
const [projectSkills, userSkills] = await Promise.all([
|
||||||
@ -281,7 +67,7 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Priority: opencode-project > opencode > project > user
|
// 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<LoadedSkill | undefined> {
|
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
|
||||||
@ -291,21 +77,21 @@ export async function getSkillByName(name: string, options: DiscoverSkillsOption
|
|||||||
|
|
||||||
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
|
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
|
||||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||||
return loadSkillsFromDir(userSkillsDir, "user")
|
return loadSkillsFromDir({ skillsDir: userSkillsDir, scope: "user" })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
|
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
|
||||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||||
return loadSkillsFromDir(projectSkillsDir, "project")
|
return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
||||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||||
const opencodeSkillsDir = join(configDir, "skills")
|
const opencodeSkillsDir = join(configDir, "skills")
|
||||||
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
return loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
|
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
|
||||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skills")
|
const opencodeProjectDir = join(process.cwd(), ".opencode", "skills")
|
||||||
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,192 +1,11 @@
|
|||||||
import type { LoadedSkill, SkillScope, SkillMetadata } from "./types"
|
import type { LoadedSkill } from "./types"
|
||||||
import type { SkillsConfig, SkillDefinition } from "../../config/schema"
|
import type { SkillsConfig } from "../../config/schema"
|
||||||
import type { BuiltinSkill } from "../builtin-skills/types"
|
import type { BuiltinSkill } from "../builtin-skills/types"
|
||||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
import { builtinToLoadedSkill } from "./merger/builtin-skill-converter"
|
||||||
import { readFileSync, existsSync } from "fs"
|
import { configEntryToLoadedSkill } from "./merger/config-skill-entry-loader"
|
||||||
import { dirname, resolve, isAbsolute } from "path"
|
import { mergeSkillDefinitions } from "./merger/skill-definition-merger"
|
||||||
import { homedir } from "os"
|
import { normalizeSkillsConfig } from "./merger/skills-config-normalizer"
|
||||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
import { SCOPE_PRIORITY } from "./merger/scope-priority"
|
||||||
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<SkillScope, number> = {
|
|
||||||
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<string, string> | 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<SkillMetadata>(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 = `<skill-instruction>
|
|
||||||
Base directory for this skill: ${resolvedPath}/
|
|
||||||
File references (@path) in this skill are relative to this directory.
|
|
||||||
|
|
||||||
${template.trim()}
|
|
||||||
</skill-instruction>
|
|
||||||
|
|
||||||
<user-request>
|
|
||||||
$ARGUMENTS
|
|
||||||
</user-request>`
|
|
||||||
|
|
||||||
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<string, string> | undefined || fileMetadata.metadata,
|
|
||||||
allowedTools,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConfig(config: SkillsConfig | undefined): {
|
|
||||||
sources: Array<string | { path: string; recursive?: boolean; glob?: string }>
|
|
||||||
enable: string[]
|
|
||||||
disable: string[]
|
|
||||||
entries: Record<string, boolean | SkillDefinition>
|
|
||||||
} {
|
|
||||||
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<string, string>) || {})
|
|
||||||
: 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<string, string> | undefined,
|
|
||||||
allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MergeSkillsOptions {
|
export interface MergeSkillsOptions {
|
||||||
configDir?: string
|
configDir?: string
|
||||||
@ -204,11 +23,11 @@ export function mergeSkills(
|
|||||||
const skillMap = new Map<string, LoadedSkill>()
|
const skillMap = new Map<string, LoadedSkill>()
|
||||||
|
|
||||||
for (const builtin of builtinSkills) {
|
for (const builtin of builtinSkills) {
|
||||||
const loaded = builtinToLoaded(builtin)
|
const loaded = builtinToLoadedSkill(builtin)
|
||||||
skillMap.set(loaded.name, loaded)
|
skillMap.set(loaded.name, loaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedConfig = normalizeConfig(config)
|
const normalizedConfig = normalizeSkillsConfig(config)
|
||||||
|
|
||||||
for (const [name, entry] of Object.entries(normalizedConfig.entries)) {
|
for (const [name, entry] of Object.entries(normalizedConfig.entries)) {
|
||||||
if (entry === false) continue
|
if (entry === false) continue
|
||||||
@ -216,7 +35,7 @@ export function mergeSkills(
|
|||||||
|
|
||||||
if (entry.disable) continue
|
if (entry.disable) continue
|
||||||
|
|
||||||
const loaded = configEntryToLoaded(name, entry, options.configDir)
|
const loaded = configEntryToLoadedSkill(name, entry, options.configDir)
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
const existing = skillMap.get(name)
|
const existing = skillMap.get(name)
|
||||||
if (existing && !entry.template && !entry.from) {
|
if (existing && !entry.template && !entry.from) {
|
||||||
|
|||||||
@ -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<string, string> | undefined,
|
||||||
|
allowedTools: builtin.allowedTools,
|
||||||
|
mcpConfig: builtin.mcpConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<SkillMetadata>(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 = `<skill-instruction>
|
||||||
|
Base directory for this skill: ${resolvedPath}/
|
||||||
|
File references (@path) in this skill are relative to this directory.
|
||||||
|
|
||||||
|
${template.trim()}
|
||||||
|
</skill-instruction>
|
||||||
|
|
||||||
|
<user-request>
|
||||||
|
$ARGUMENTS
|
||||||
|
</user-request>`
|
||||||
|
|
||||||
|
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<string, string> | undefined) || fileMetadata.metadata,
|
||||||
|
allowedTools,
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/features/opencode-skill-loader/merger/scope-priority.ts
Normal file
10
src/features/opencode-skill-loader/merger/scope-priority.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { SkillScope } from "../types"
|
||||||
|
|
||||||
|
export const SCOPE_PRIORITY: Record<SkillScope, number> = {
|
||||||
|
builtin: 1,
|
||||||
|
config: 2,
|
||||||
|
user: 3,
|
||||||
|
opencode: 4,
|
||||||
|
project: 5,
|
||||||
|
"opencode-project": 6,
|
||||||
|
}
|
||||||
@ -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<string, string>) || {})
|
||||||
|
: 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<string, string> | undefined,
|
||||||
|
allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import type { SkillsConfig, SkillDefinition } from "../../../config/schema"
|
||||||
|
|
||||||
|
export function normalizeSkillsConfig(config: SkillsConfig | undefined): {
|
||||||
|
sources: Array<string | { path: string; recursive?: boolean; glob?: string }>
|
||||||
|
enable: string[]
|
||||||
|
disable: string[]
|
||||||
|
entries: Record<string, boolean | SkillDefinition>
|
||||||
|
} {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@ -1,250 +1,11 @@
|
|||||||
import { createBuiltinSkills } from "../builtin-skills/skills"
|
export type { SkillResolutionOptions } from "./skill-resolution-options"
|
||||||
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 interface SkillResolutionOptions {
|
export { clearSkillCache, getAllSkills } from "./skill-discovery"
|
||||||
gitMasterConfig?: GitMasterConfig
|
export { extractSkillTemplate } from "./loaded-skill-template-extractor"
|
||||||
browserProvider?: BrowserAutomationProvider
|
export { injectGitMasterConfig } from "./git-master-template-injection"
|
||||||
disabledSkills?: Set<string>
|
export {
|
||||||
}
|
resolveSkillContent,
|
||||||
|
resolveMultipleSkills,
|
||||||
const cachedSkillsByProvider = new Map<string, LoadedSkill[]>()
|
resolveSkillContentAsync,
|
||||||
|
resolveMultipleSkillsAsync,
|
||||||
function clearSkillCache(): void {
|
} from "./skill-template-resolver"
|
||||||
cachedSkillsByProvider.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSkill[]> {
|
|
||||||
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<string, string> | 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<string> {
|
|
||||||
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 <clio-agent@sisyphuslabs.ai>`)
|
|
||||||
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 <clio-agent@sisyphuslabs.ai>"`)
|
|
||||||
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 <clio-agent@sisyphuslabs.ai>"`)
|
|
||||||
sections.push("```")
|
|
||||||
}
|
|
||||||
|
|
||||||
const injection = sections.join("\n")
|
|
||||||
|
|
||||||
const insertionPoint = template.indexOf("```\n</execution>")
|
|
||||||
if (insertionPoint !== -1) {
|
|
||||||
return template.slice(0, insertionPoint) + "```\n\n" + injection + "\n</execution>" + template.slice(insertionPoint + "```\n</execution>".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<string, string>
|
|
||||||
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<string, string>()
|
|
||||||
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<string | null> {
|
|
||||||
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<string, string>
|
|
||||||
notFound: string[]
|
|
||||||
}> {
|
|
||||||
const allSkills = await getAllSkills(options)
|
|
||||||
const skillMap = new Map<string, LoadedSkill>()
|
|
||||||
for (const skill of allSkills) {
|
|
||||||
skillMap.set(skill.name, skill)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = new Map<string, string>()
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
13
src/features/opencode-skill-loader/skill-deduplication.ts
Normal file
13
src/features/opencode-skill-loader/skill-deduplication.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { LoadedSkill } from "./types"
|
||||||
|
|
||||||
|
export function deduplicateSkillsByName(skills: LoadedSkill[]): LoadedSkill[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: LoadedSkill[] = []
|
||||||
|
for (const skill of skills) {
|
||||||
|
if (!seen.has(skill.name)) {
|
||||||
|
seen.add(skill.name)
|
||||||
|
result.push(skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||||
|
import type { LoadedSkill } from "./types"
|
||||||
|
|
||||||
|
export function skillsToCommandDefinitionRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
|
||||||
|
const result: Record<string, CommandDefinition> = {}
|
||||||
|
for (const skill of skills) {
|
||||||
|
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = skill.definition
|
||||||
|
result[skill.name] = openCodeCompatible as CommandDefinition
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
106
src/features/opencode-skill-loader/skill-directory-loader.ts
Normal file
106
src/features/opencode-skill-loader/skill-directory-loader.ts
Normal file
@ -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<LoadedSkill[]> {
|
||||||
|
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<string, LoadedSkill>()
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
76
src/features/opencode-skill-loader/skill-discovery.ts
Normal file
76
src/features/opencode-skill-loader/skill-discovery.ts
Normal file
@ -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<string, LoadedSkill[]>()
|
||||||
|
|
||||||
|
export function clearSkillCache(): void {
|
||||||
|
cachedSkillsByProvider.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSkill[]> {
|
||||||
|
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<string, string> | 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
|
||||||
|
}
|
||||||
45
src/features/opencode-skill-loader/skill-mcp-config.ts
Normal file
45
src/features/opencode-skill-loader/skill-mcp-config.ts
Normal file
@ -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<string, unknown>
|
||||||
|
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<SkillMcpConfig | undefined> {
|
||||||
|
const mcpJsonPath = join(skillDir, "mcp.json")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(mcpJsonPath, "utf-8")
|
||||||
|
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||||
|
|
||||||
|
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<string, unknown>)
|
||||||
|
)
|
||||||
|
if (hasCommandField) {
|
||||||
|
return parsed as SkillMcpConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import type { BrowserAutomationProvider, GitMasterConfig } from "../../config/schema"
|
||||||
|
|
||||||
|
export interface SkillResolutionOptions {
|
||||||
|
gitMasterConfig?: GitMasterConfig
|
||||||
|
browserProvider?: BrowserAutomationProvider
|
||||||
|
disabledSkills?: Set<string>
|
||||||
|
}
|
||||||
@ -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<string, string>; 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<string, string>()
|
||||||
|
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<string | null> {
|
||||||
|
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<string, string>; notFound: string[] }> {
|
||||||
|
const allSkills = await getAllSkills(options)
|
||||||
|
const skillMap = new Map<string, LoadedSkill>()
|
||||||
|
for (const skill of allSkills) {
|
||||||
|
skillMap.set(skill.name, skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = new Map<string, string>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user