feat(skill): add builtin skill infrastructure and improve tool descriptions (#340)
* feat(skill): add builtin skill types and schemas with priority-based merging support - Add BuiltinSkill interface for programmatic skill definitions - Create builtin-skills module with createBuiltinSkills factory function - Add SkillScope expansion to include 'builtin' and 'config' scopes - Create SkillsConfig and SkillDefinition Zod schemas for config validation - Add merger.ts utility with mergeSkills function for priority-based skill merging - Update skill and command types to support optional paths for builtin/config skills - Priority order: builtin < config < user < opencode < project < opencode-project 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(skill): integrate programmatic skill discovery and merged skill support - Add discovery functions for Claude and OpenCode skill directories - Add discoverUserClaudeSkills, discoverProjectClaudeSkills functions - Add discoverOpencodeGlobalSkills, discoverOpencodeProjectSkills functions - Update createSkillTool to support pre-merged skills via options - Add extractSkillBody utility to handle both file and programmatic skills - Integrate mergeSkills in plugin initialization to apply priority-based merging - Support optional path/resolvedPath for builtin and config-sourced skills 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * chore(slashcommand): support optional path for builtin and config command scopes - Update CommandInfo type to make path and content optional properties - Prepare command tool for builtin and config sourced commands - Maintain backward compatibility with file-based command loading 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * docs(tools): improve tool descriptions for interactive-bash and slashcommand - Added use case clarification to interactive-bash tool description (server processes, long-running tasks, background jobs, interactive CLI tools) - Simplified slashcommand description to emphasize 'loading' skills concept and removed verbose documentation 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor(skill-loader): simplify redundant condition in skill merging logic Remove redundant 'else if (loaded)' condition that was always true since we're already inside the 'if (loaded)' block. Simplify to 'else' for clarity. Addresses code review feedback on PR #340 for the skill infrastructure feature. 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
2
src/features/builtin-skills/index.ts
Normal file
2
src/features/builtin-skills/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { createBuiltinSkills } from "./skills"
|
||||
5
src/features/builtin-skills/skills.ts
Normal file
5
src/features/builtin-skills/skills.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BuiltinSkill } from "./types"
|
||||
|
||||
export function createBuiltinSkills(): BuiltinSkill[] {
|
||||
return []
|
||||
}
|
||||
13
src/features/builtin-skills/types.ts
Normal file
13
src/features/builtin-skills/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface BuiltinSkill {
|
||||
name: string
|
||||
description: string
|
||||
template: string
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: Record<string, unknown>
|
||||
allowedTools?: string[]
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
export * from "./merger"
|
||||
|
||||
@@ -224,3 +224,23 @@ export function getSkillByName(name: string, options: DiscoverSkillsOptions = {}
|
||||
const skills = discoverSkills(options)
|
||||
return skills.find(s => s.name === name)
|
||||
}
|
||||
|
||||
export function discoverUserClaudeSkills(): LoadedSkill[] {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
return loadSkillsFromDir(userSkillsDir, "user")
|
||||
}
|
||||
|
||||
export function discoverProjectClaudeSkills(): LoadedSkill[] {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDir(projectSkillsDir, "project")
|
||||
}
|
||||
|
||||
export function discoverOpencodeGlobalSkills(): LoadedSkill[] {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
||||
}
|
||||
|
||||
export function discoverOpencodeProjectSkills(): LoadedSkill[] {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
266
src/features/opencode-skill-loader/merger.ts
Normal file
266
src/features/opencode-skill-loader/merger.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { LoadedSkill, SkillScope, SkillMetadata } from "./types"
|
||||
import type { SkillsConfig, SkillDefinition } from "../../config/schema"
|
||||
import type { BuiltinSkill } from "../builtin-skills/types"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import { readFileSync, existsSync } from "fs"
|
||||
import { dirname, resolve, isAbsolute } from "path"
|
||||
import { homedir } from "os"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { deepMerge } from "../../shared/deep-merge"
|
||||
|
||||
const SCOPE_PRIORITY: Record<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: `(builtin - 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,
|
||||
}
|
||||
}
|
||||
|
||||
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"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined)
|
||||
|
||||
return {
|
||||
name,
|
||||
path: entry.from ? resolveFilePath(entry.from, configDir) : undefined,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope: "config",
|
||||
license: entry.license || fileMetadata.license,
|
||||
compatibility: entry.compatibility || fileMetadata.compatibility,
|
||||
metadata: entry.metadata as Record<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 {
|
||||
configDir?: string
|
||||
}
|
||||
|
||||
export function mergeSkills(
|
||||
builtinSkills: BuiltinSkill[],
|
||||
config: SkillsConfig | undefined,
|
||||
userClaudeSkills: LoadedSkill[],
|
||||
userOpencodeSkills: LoadedSkill[],
|
||||
projectClaudeSkills: LoadedSkill[],
|
||||
projectOpencodeSkills: LoadedSkill[],
|
||||
options: MergeSkillsOptions = {}
|
||||
): LoadedSkill[] {
|
||||
const skillMap = new Map<string, LoadedSkill>()
|
||||
|
||||
for (const builtin of builtinSkills) {
|
||||
const loaded = builtinToLoaded(builtin)
|
||||
skillMap.set(loaded.name, loaded)
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizeConfig(config)
|
||||
|
||||
for (const [name, entry] of Object.entries(normalizedConfig.entries)) {
|
||||
if (entry === false) continue
|
||||
if (entry === true) continue
|
||||
|
||||
if (entry.disable) continue
|
||||
|
||||
const loaded = configEntryToLoaded(name, entry, options.configDir)
|
||||
if (loaded) {
|
||||
const existing = skillMap.get(name)
|
||||
if (existing && !entry.template && !entry.from) {
|
||||
skillMap.set(name, mergeSkillDefinitions(existing, entry))
|
||||
} else {
|
||||
skillMap.set(name, loaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileSystemSkills = [
|
||||
...userClaudeSkills,
|
||||
...userOpencodeSkills,
|
||||
...projectClaudeSkills,
|
||||
...projectOpencodeSkills,
|
||||
]
|
||||
|
||||
for (const skill of fileSystemSkills) {
|
||||
const existing = skillMap.get(skill.name)
|
||||
if (!existing || SCOPE_PRIORITY[skill.scope] > SCOPE_PRIORITY[existing.scope]) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, entry] of Object.entries(normalizedConfig.entries)) {
|
||||
if (entry === true) continue
|
||||
if (entry === false) {
|
||||
skillMap.delete(name)
|
||||
continue
|
||||
}
|
||||
if (entry.disable) {
|
||||
skillMap.delete(name)
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = skillMap.get(name)
|
||||
if (existing && !entry.template && !entry.from) {
|
||||
skillMap.set(name, mergeSkillDefinitions(existing, entry))
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of normalizedConfig.disable) {
|
||||
skillMap.delete(name)
|
||||
}
|
||||
|
||||
if (normalizedConfig.enable.length > 0) {
|
||||
const enableSet = new Set(normalizedConfig.enable)
|
||||
for (const name of skillMap.keys()) {
|
||||
if (!enableSet.has(name)) {
|
||||
skillMap.delete(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skillMap.values())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
|
||||
export type SkillScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
export interface SkillMetadata {
|
||||
name?: string
|
||||
@@ -17,8 +17,8 @@ export interface SkillMetadata {
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string
|
||||
path: string
|
||||
resolvedPath: string
|
||||
path?: string
|
||||
resolvedPath?: string
|
||||
definition: CommandDefinition
|
||||
scope: SkillScope
|
||||
license?: string
|
||||
|
||||
Reference in New Issue
Block a user