Files
oh-my-openagent/src/features/opencode-skill-loader/loader.ts
2026-02-05 09:45:35 +09:00

312 lines
11 KiB
TypeScript

import { promises as fs } from "fs"
import { join, basename } from "path"
import yaml from "js-yaml"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!frontmatterMatch) return undefined
try {
const parsed = yaml.load(frontmatterMatch[1]) as Record<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>> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = await loadSkillsFromDir(userSkillsDir, "user")
return skillsToRecord(skills)
}
export async function loadProjectSkills(): Promise<Record<string, CommandDefinition>> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = await loadSkillsFromDir(projectSkillsDir, "project")
return skillsToRecord(skills)
}
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeSkillsDir = join(configDir, "skills")
const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode")
return skillsToRecord(skills)
}
export async function loadOpencodeProjectSkills(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skills")
const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project")
return skillsToRecord(skills)
}
export interface DiscoverSkillsOptions {
includeClaudeCodePaths?: boolean
}
/**
* Deduplicates skills by name, keeping the first occurrence (higher priority).
* Priority order: opencode-project > opencode > project > user
* (OpenCode Global skills take precedence over legacy Claude project skills)
*/
function deduplicateSkills(skills: LoadedSkill[]): LoadedSkill[] {
const seen = new Set<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[]> {
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
discoverOpencodeProjectSkills(),
discoverOpencodeGlobalSkills(),
discoverProjectClaudeSkills(),
discoverUserClaudeSkills(),
])
// Priority: opencode-project > opencode > project > user
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
}
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
const { includeClaudeCodePaths = true } = options
const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([
discoverOpencodeProjectSkills(),
discoverOpencodeGlobalSkills(),
])
if (!includeClaudeCodePaths) {
// Priority: opencode-project > opencode
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills])
}
const [projectSkills, userSkills] = await Promise.all([
discoverProjectClaudeSkills(),
discoverUserClaudeSkills(),
])
// Priority: opencode-project > opencode > project > user
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
}
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
const skills = await discoverSkills(options)
return skills.find(s => s.name === name)
}
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDir(userSkillsDir, "user")
}
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDir(projectSkillsDir, "project")
}
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeSkillsDir = join(configDir, "skills")
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
}
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skills")
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
}