import { dirname } from "node:path" import { tool, type ToolDefinition } from "@opencode-ai/plugin" import type { ToolContext } from "@opencode-ai/plugin/tool" import { TOOL_DESCRIPTION_PREFIX } from "./constants" import { shouldInvalidateSkillCacheForSession } from "./session-skill-cache" import type { SkillArgs, SkillLoadOptions } from "./types" import type { LoadedSkill } from "../../features/opencode-skill-loader" import { clearSkillCache, getAllSkills } from "../../features/opencode-skill-loader/skill-content" import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content" import * as commandDiscovery from "../slashcommand/command-discovery" import type { CommandInfo } from "../slashcommand/types" import { formatLoadedCommand } from "../slashcommand/command-output-formatter" import { formatCombinedDescription } from "./description-formatter" import { formatMcpCapabilities } from "./mcp-capability-formatter" import { findPartialMatches, matchCommandByName, matchSkillByName, } from "./skill-matcher" import { extractSkillBody } from "./skill-body" import { isPromiseLike, loadedSkillToInfo, mergeNativeSkillInfos, mergeNativeSkills, } from "./native-skills" export function createSkillTool(options: SkillLoadOptions): ToolDefinition { let cachedDescription: string | null = null const getSkills = async (context?: ToolContext): Promise => { if (shouldInvalidateSkillCacheForSession(context?.sessionID)) { clearSkillCache() } const discovered = (await getAllSkills({ disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider, teamModeEnabled: options?.teamModeEnabled, directory: options.directory, })) ?? [] const allSkills = options.skills ? [...options.skills] : discovered if (options.nativeSkills) { try { const nativeAll = await options.nativeSkills.all() mergeNativeSkills(allSkills, nativeAll) } catch { } } return allSkills } const getCommands = (): CommandInfo[] => { if (options.commands) return [...options.commands] return commandDiscovery.discoverCommandsSync(undefined, { pluginsEnabled: options.pluginsEnabled, enabledPluginsOverride: options.enabledPluginsOverride, }) ?? [] } const buildDescription = async (force = false): Promise => { if (!force && cachedDescription) return cachedDescription const skills = await getSkills() const commands = getCommands() // Exclude agent-restricted skills from the description: they must not be // visible to agents that are not their designated owner. The execute-time // check already enforces the restriction at call time. const publicSkills = skills.filter((s) => !s.definition.agent) const skillInfos = publicSkills.map(loadedSkillToInfo) cachedDescription = formatCombinedDescription(skillInfos, commands) return cachedDescription } if (options.skills !== undefined) { const publicSkills = options.skills.filter((s) => !s.definition.agent) const skillInfos = publicSkills.map(loadedSkillToInfo) const commandsForDescription = options.commands ?? [] let needsAsyncRefresh = false if (options.nativeSkills) { try { const nativeAll = options.nativeSkills.all() if (isPromiseLike(nativeAll)) { needsAsyncRefresh = true } else { mergeNativeSkillInfos(skillInfos, nativeAll) } } catch { } } cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription) if (needsAsyncRefresh) { void buildDescription(true) } } else if (options.commands !== undefined) { cachedDescription = formatCombinedDescription([], options.commands) } return tool({ get description() { if (cachedDescription === null) { void buildDescription() } return cachedDescription ?? TOOL_DESCRIPTION_PREFIX }, args: { name: tool.schema.string().describe("The skill or command name (e.g., 'review-work' or 'publish'). Use without leading slash for commands."), user_message: tool.schema .string() .optional() .describe("Optional arguments or context for command invocation. Example: name='publish', user_message='patch'"), }, async execute(args: SkillArgs, ctx?: ToolContext) { const skills = await getSkills(ctx) const commands = getCommands() cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands) const requestedName = args.name.replace(/^\//, "") const matchedSkill = matchSkillByName(skills, requestedName) if (matchedSkill) { await ctx?.ask({ permission: "skill", patterns: [matchedSkill.name], always: [matchedSkill.name], metadata: { skill: matchedSkill.name, }, }) if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) { throw new Error(`Skill "${matchedSkill.name}" is restricted to agent "${matchedSkill.definition.agent}"`) } let body = await extractSkillBody(matchedSkill) if (matchedSkill.name === "git-master") { body = injectGitMasterConfig(body, options.gitMasterConfig) } const dir = matchedSkill.path ? dirname(matchedSkill.path) : matchedSkill.resolvedPath || process.cwd() const output = [ `## Skill: ${matchedSkill.name}`, "", `**Base directory**: ${dir}`, "", body, ] if (options.mcpManager && matchedSkill.mcpConfig) { const sessionID = ctx?.sessionID || options.getSessionID?.() if (!sessionID) { return output.join("\n") } const mcpInfo = await formatMcpCapabilities( matchedSkill, options.mcpManager, sessionID ) if (mcpInfo) { output.push(mcpInfo) } } return output.join("\n") } const matchedCommand = matchCommandByName(commands, requestedName) if (matchedCommand) { return await formatLoadedCommand(matchedCommand, args.user_message) } const partialMatches = findPartialMatches(skills, commands, requestedName) if (partialMatches.length > 0) { throw new Error( `Skill or command "${args.name}" not found. Did you mean: ${partialMatches.join(", ")}?` ) } const available = [ ...skills.map((skill) => skill.name), ...commands.map((command) => `/${command.name}`), ].join(", ") throw new Error( `Skill or command "${args.name}" not found. Available: ${available || "none"}` ) }, }) } export const skill: ToolDefinition = createSkillTool({ directory: process.cwd() })