diff --git a/src/config/schema.ts b/src/config/schema.ts index aea9af7..05f1936 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -175,6 +175,44 @@ export const ExperimentalConfigSchema = z.object({ dcp_for_compaction: z.boolean().optional(), }) +export const SkillSourceSchema = z.union([ + z.string(), + z.object({ + path: z.string(), + recursive: z.boolean().optional(), + glob: z.string().optional(), + }), +]) + +export const SkillDefinitionSchema = z.object({ + description: z.string().optional(), + template: z.string().optional(), + from: z.string().optional(), + model: z.string().optional(), + agent: z.string().optional(), + subtask: z.boolean().optional(), + "argument-hint": z.string().optional(), + license: z.string().optional(), + compatibility: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + "allowed-tools": z.array(z.string()).optional(), + disable: z.boolean().optional(), +}) + +export const SkillEntrySchema = z.union([ + z.boolean(), + SkillDefinitionSchema, +]) + +export const SkillsConfigSchema = z.union([ + z.array(z.string()), + z.record(z.string(), SkillEntrySchema).and(z.object({ + sources: z.array(SkillSourceSchema).optional(), + enable: z.array(z.string()).optional(), + disable: z.array(z.string()).optional(), + }).partial()), +]) + export const OhMyOpenCodeConfigSchema = z.object({ $schema: z.string().optional(), disabled_mcps: z.array(McpNameSchema).optional(), @@ -188,6 +226,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ comment_checker: CommentCheckerConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), auto_update: z.boolean().optional(), + skills: SkillsConfigSchema.optional(), }) export type OhMyOpenCodeConfig = z.infer @@ -200,5 +239,7 @@ export type SisyphusAgentConfig = z.infer export type CommentCheckerConfig = z.infer export type ExperimentalConfig = z.infer export type DynamicContextPruningConfig = z.infer +export type SkillsConfig = z.infer +export type SkillDefinition = z.infer export { McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/features/builtin-skills/index.ts b/src/features/builtin-skills/index.ts new file mode 100644 index 0000000..7ca1fac --- /dev/null +++ b/src/features/builtin-skills/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export { createBuiltinSkills } from "./skills" diff --git a/src/features/builtin-skills/skills.ts b/src/features/builtin-skills/skills.ts new file mode 100644 index 0000000..c29d212 --- /dev/null +++ b/src/features/builtin-skills/skills.ts @@ -0,0 +1,5 @@ +import type { BuiltinSkill } from "./types" + +export function createBuiltinSkills(): BuiltinSkill[] { + return [] +} diff --git a/src/features/builtin-skills/types.ts b/src/features/builtin-skills/types.ts new file mode 100644 index 0000000..095e846 --- /dev/null +++ b/src/features/builtin-skills/types.ts @@ -0,0 +1,13 @@ +export interface BuiltinSkill { + name: string + description: string + template: string + license?: string + compatibility?: string + metadata?: Record + allowedTools?: string[] + agent?: string + model?: string + subtask?: boolean + argumentHint?: string +} diff --git a/src/features/opencode-skill-loader/index.ts b/src/features/opencode-skill-loader/index.ts index 644158c..027427a 100644 --- a/src/features/opencode-skill-loader/index.ts +++ b/src/features/opencode-skill-loader/index.ts @@ -1,2 +1,3 @@ export * from "./types" export * from "./loader" +export * from "./merger" diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index dd1449a..32ffad4 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -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") +} diff --git a/src/features/opencode-skill-loader/merger.ts b/src/features/opencode-skill-loader/merger.ts new file mode 100644 index 0000000..bc166e4 --- /dev/null +++ b/src/features/opencode-skill-loader/merger.ts @@ -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 = { + 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 | 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(content) + return { template: body, metadata: data } + } catch { + return null + } +} + +function configEntryToLoaded( + name: string, + entry: SkillDefinition, + configDir?: string +): LoadedSkill | null { + let template = entry.template || "" + let fileMetadata: SkillMetadata = {} + + if (entry.from) { + const filePath = resolveFilePath(entry.from, configDir) + const loaded = loadSkillFromFile(filePath) + if (loaded) { + template = loaded.template + fileMetadata = loaded.metadata + } else { + return null + } + } + + if (!template && !entry.from) { + return null + } + + const description = entry.description || fileMetadata.description || "" + const resolvedPath = entry.from ? dirname(resolveFilePath(entry.from, configDir)) : configDir || process.cwd() + + const wrappedTemplate = ` +Base directory for this skill: ${resolvedPath}/ +File references (@path) in this skill are relative to this directory. + +${template.trim()} + + + +$ARGUMENTS +` + + const definition: CommandDefinition = { + name, + description: `(config - Skill) ${description}`, + template: wrappedTemplate, + model: sanitizeModelField(entry.model || fileMetadata.model, "opencode"), + agent: entry.agent || fileMetadata.agent, + subtask: entry.subtask ?? fileMetadata.subtask, + argumentHint: entry["argument-hint"] || fileMetadata["argument-hint"], + } + + const allowedTools = entry["allowed-tools"] || + (fileMetadata["allowed-tools"] ? 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 | undefined || fileMetadata.metadata, + allowedTools, + } +} + +function normalizeConfig(config: SkillsConfig | undefined): { + sources: Array + enable: string[] + disable: string[] + entries: Record +} { + if (!config) { + return { sources: [], enable: [], disable: [], entries: {} } + } + + if (Array.isArray(config)) { + return { sources: [], enable: config, disable: [], entries: {} } + } + + const { sources = [], enable = [], disable = [], ...entries } = config + return { sources, enable, disable, entries } +} + +function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): LoadedSkill { + const mergedMetadata = base.metadata || patch.metadata + ? deepMerge(base.metadata || {}, (patch.metadata as Record) || {}) + : undefined + + const mergedTools = base.allowedTools || patch["allowed-tools"] + ? [...(base.allowedTools || []), ...(patch["allowed-tools"] || [])] + : undefined + + const description = patch.description || base.definition.description?.replace(/^\([^)]+\) /, "") + + return { + ...base, + definition: { + ...base.definition, + description: `(${base.scope} - Skill) ${description}`, + model: patch.model || base.definition.model, + agent: patch.agent || base.definition.agent, + subtask: patch.subtask ?? base.definition.subtask, + argumentHint: patch["argument-hint"] || base.definition.argumentHint, + }, + license: patch.license || base.license, + compatibility: patch.compatibility || base.compatibility, + metadata: mergedMetadata as Record | undefined, + allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined, + } +} + +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() + + 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()) +} diff --git a/src/features/opencode-skill-loader/types.ts b/src/features/opencode-skill-loader/types.ts index 397bb36..f956922 100644 --- a/src/features/opencode-skill-loader/types.ts +++ b/src/features/opencode-skill-loader/types.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index d7decfc..d16ee7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,13 @@ import { loadProjectSkills, loadOpencodeGlobalSkills, loadOpencodeProjectSkills, + discoverUserClaudeSkills, + discoverProjectClaudeSkills, + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, + mergeSkills, } from "./features/opencode-skill-loader"; +import { createBuiltinSkills } from "./features/builtin-skills"; import { loadUserAgents, @@ -322,9 +328,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); const lookAt = createLookAt(ctx); - const skillTool = createSkillTool({ - opencodeOnly: pluginConfig.claude_code?.skills === false, - }); + const builtinSkills = createBuiltinSkills(); + const includeClaudeSkills = pluginConfig.claude_code?.skills !== false; + const mergedSkills = mergeSkills( + builtinSkills, + pluginConfig.skills, + includeClaudeSkills ? discoverUserClaudeSkills() : [], + discoverOpencodeGlobalSkills(), + includeClaudeSkills ? discoverProjectClaudeSkills() : [], + discoverOpencodeProjectSkills(), + ); + const skillTool = createSkillTool({ skills: mergedSkills }); const googleAuthHooks = pluginConfig.google_auth !== false ? await createGoogleAntigravityAuthPlugin(ctx) diff --git a/src/tools/interactive-bash/constants.ts b/src/tools/interactive-bash/constants.ts index 83470d5..485846f 100644 --- a/src/tools/interactive-bash/constants.ts +++ b/src/tools/interactive-bash/constants.ts @@ -13,4 +13,6 @@ export const BLOCKED_TMUX_SUBCOMMANDS = [ export const INTERACTIVE_BASH_DESCRIPTION = `Execute tmux commands. Use "omo-{name}" session pattern. +For: server processes, long-running tasks, background jobs, interactive CLI tools. + Blocked (use bash instead): capture-pane, save-buffer, show-buffer, pipe-pane.` diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index da6f217..0b4d594 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs" import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants" import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types" -import { discoverSkills, getSkillByName, type LoadedSkill } from "../../features/opencode-skill-loader" +import { discoverSkills, type LoadedSkill } from "../../features/opencode-skill-loader" import { parseFrontmatter } from "../../shared/frontmatter" function loadedSkillToInfo(skill: LoadedSkill): SkillInfo { @@ -38,8 +38,19 @@ function formatSkillsXml(skills: SkillInfo[]): string { return `\n\n\n${skillsXml}\n` } +function extractSkillBody(skill: LoadedSkill): string { + if (skill.path) { + const content = readFileSync(skill.path, "utf-8") + const { body } = parseFrontmatter(content) + return body.trim() + } + + const templateMatch = skill.definition.template?.match(/([\s\S]*?)<\/skill-instruction>/) + return templateMatch ? templateMatch[1].trim() : skill.definition.template || "" +} + export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition { - const skills = discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly }) + const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly }) const skillInfos = skills.map(loadedSkillToInfo) const description = skillInfos.length === 0 @@ -52,26 +63,25 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), }, async execute(args: SkillArgs) { - const skill = getSkillByName(args.name, { includeClaudeCodePaths: !options.opencodeOnly }) + const skill = options.skills + ? skills.find(s => s.name === args.name) + : skills.find(s => s.name === args.name) if (!skill) { const available = skills.map(s => s.name).join(", ") throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`) } - const content = readFileSync(skill.path, "utf-8") - const { body } = parseFrontmatter(content) - const dir = dirname(skill.path) + const body = extractSkillBody(skill) + const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd() - const output = [ + return [ `## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", - body.trim(), + body, ].join("\n") - - return output }, }) } diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts index 9534086..298cbad 100644 --- a/src/tools/skill/types.ts +++ b/src/tools/skill/types.ts @@ -1,3 +1,5 @@ +import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types" + export interface SkillArgs { name: string } @@ -5,8 +7,8 @@ export interface SkillArgs { export interface SkillInfo { name: string description: string - location: string - scope: "opencode-project" | "project" | "opencode" | "user" + location?: string + scope: SkillScope license?: string compatibility?: string metadata?: Record @@ -16,4 +18,6 @@ export interface SkillInfo { export interface SkillLoadOptions { /** When true, only load from OpenCode paths (.opencode/skill/, ~/.config/opencode/skill/) */ opencodeOnly?: boolean + /** Pre-merged skills to use instead of discovering */ + skills?: LoadedSkill[] } diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 3631aed..d99c14b 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -124,8 +124,8 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise { sections.push("---\n") sections.push("## Command Instructions\n") - const commandDir = dirname(cmd.path) - const withFileRefs = await resolveFileReferencesInText(cmd.content, commandDir) + const commandDir = cmd.path ? dirname(cmd.path) : process.cwd() + const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir) const resolvedContent = await resolveCommandsInText(withFileRefs) sections.push(resolvedContent.trim()) @@ -151,40 +151,14 @@ function formatCommandList(items: CommandInfo[]): string { } export const slashcommand: ToolDefinition = tool({ - description: `Execute a slash command within the main conversation. + description: `Load a skill to get detailed instructions for a specific task. -When you use this tool, the slash command gets expanded to a full prompt that provides detailed instructions on how to complete the task. +Skills provide specialized knowledge and step-by-step guidance. +Use this when a task matches an available skill's description. -How slash commands work: -- Invoke commands using this tool with the command name (without arguments) -- The command's prompt will expand and provide detailed instructions -- Arguments from user input should be passed separately - -Important: -- Only use commands listed in Available Commands below -- Do not invoke a command that is already running -- **CRITICAL**: When user's message starts with '/' (e.g., "/commit", "/plan"), you MUST immediately invoke this tool with that command. Do NOT attempt to handle the command manually. - -Commands are loaded from (priority order, highest wins): -- .opencode/command/ (opencode-project - OpenCode project-specific commands) -- ./.claude/commands/ (project - Claude Code project-specific commands) -- ~/.config/opencode/command/ (opencode - OpenCode global commands) -- $CLAUDE_CONFIG_DIR/commands/ or ~/.claude/commands/ (user - Claude Code global commands) - -Skills are loaded from (priority order, highest wins): -- .opencode/skill/ (opencode-project - OpenCode project-specific skills) -- ./.claude/skills/ (project - Claude Code project-specific skills) -- ~/.config/opencode/skill/ (opencode - OpenCode global skills) -- $CLAUDE_CONFIG_DIR/skills/ or ~/.claude/skills/ (user - Claude Code global skills) - -Each command/skill is a markdown file with: -- YAML frontmatter: description, argument-hint, model, agent, subtask (optional) -- Markdown body: The command instructions/prompt -- File references: @path/to/file (relative to command file location) -- Shell injection: \`!\`command\`\` (executes and injects output) - -Available Commands: -${commandListForDescription}`, + +${commandListForDescription} +`, args: { command: tool.schema diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts index 41142d0..36437e2 100644 --- a/src/tools/slashcommand/types.ts +++ b/src/tools/slashcommand/types.ts @@ -1,4 +1,4 @@ -export type CommandScope = "user" | "project" | "opencode" | "opencode-project" +export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" export interface CommandMetadata { name: string @@ -11,8 +11,8 @@ export interface CommandMetadata { export interface CommandInfo { name: string - path: string + path?: string metadata: CommandMetadata - content: string + content?: string scope: CommandScope }