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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user