refactor(tools): decompose skill/tools.ts into focused tool creators
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||||
|
import { sortByScopePriority } from "./scope-priority"
|
||||||
|
import type { SkillInfo } from "./types"
|
||||||
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
|
|
||||||
|
function formatSkillCommand(skill: SkillInfo): string {
|
||||||
|
const lines = [
|
||||||
|
" <command>",
|
||||||
|
` <name>/${skill.name}</name>`,
|
||||||
|
` <description>${skill.description}</description>`,
|
||||||
|
` <scope>${skill.scope}</scope>`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (skill.compatibility) {
|
||||||
|
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" </command>")
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSlashCommand(command: CommandInfo): string {
|
||||||
|
const argumentHint = typeof command.metadata.argumentHint === "string"
|
||||||
|
? command.metadata.argumentHint.trim()
|
||||||
|
: undefined
|
||||||
|
const lines = [
|
||||||
|
" <command>",
|
||||||
|
` <name>/${command.name}</name>`,
|
||||||
|
` <description>${command.metadata.description || "(no description)"}</description>`,
|
||||||
|
` <scope>${command.scope}</scope>`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (argumentHint) {
|
||||||
|
lines.push(` <argument>${argumentHint}</argument>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" </command>")
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
|
||||||
|
if (skills.length === 0 && commands.length === 0) {
|
||||||
|
return TOOL_DESCRIPTION_NO_SKILLS
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableItems = [
|
||||||
|
...sortByScopePriority(skills).map(formatSkillCommand),
|
||||||
|
...sortByScopePriority(commands).map(formatSlashCommand),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (availableItems.length === 0) {
|
||||||
|
return TOOL_DESCRIPTION_PREFIX
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${TOOL_DESCRIPTION_PREFIX}
|
||||||
|
<available_items>
|
||||||
|
Priority: project > user > opencode > builtin/plugin | Skills listed before commands
|
||||||
|
Invoke via: skill(name="item-name") — omit leading slash for commands.
|
||||||
|
${availableItems.join("\n")}
|
||||||
|
</available_items>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"
|
||||||
|
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
|
||||||
|
import type {
|
||||||
|
SkillMcpClientInfo,
|
||||||
|
SkillMcpManager,
|
||||||
|
SkillMcpServerContext,
|
||||||
|
} from "../../features/skill-mcp-manager"
|
||||||
|
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||||
|
|
||||||
|
export async function formatMcpCapabilities(
|
||||||
|
skill: LoadedSkill,
|
||||||
|
manager: SkillMcpManager,
|
||||||
|
sessionID: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: string[] = ["", "## Available MCP Servers", ""]
|
||||||
|
|
||||||
|
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
|
||||||
|
const info: SkillMcpClientInfo = {
|
||||||
|
serverName,
|
||||||
|
skillName: skill.name,
|
||||||
|
sessionID,
|
||||||
|
}
|
||||||
|
const context: SkillMcpServerContext = {
|
||||||
|
config,
|
||||||
|
skillName: skill.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(`### ${serverName}`, "")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [tools, resources, prompts] = await Promise.all([
|
||||||
|
manager.listTools(info, context).catch(() => []),
|
||||||
|
manager.listResources(info, context).catch(() => []),
|
||||||
|
manager.listPrompts(info, context).catch(() => []),
|
||||||
|
])
|
||||||
|
|
||||||
|
appendToolSections(sections, tools as Tool[])
|
||||||
|
appendResourceSection(sections, resources as Resource[])
|
||||||
|
appendPromptSection(sections, prompts as Prompt[])
|
||||||
|
|
||||||
|
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
|
||||||
|
sections.push("*No capabilities discovered*")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push("", `Use \`skill_mcp\` tool with \`mcp_name=\"${serverName}\"\` to invoke.`, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToolSections(sections: string[], tools: Tool[]): void {
|
||||||
|
if (tools.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push("**Tools:**", "")
|
||||||
|
|
||||||
|
for (const toolDefinition of tools) {
|
||||||
|
sections.push(`#### \`${toolDefinition.name}\``)
|
||||||
|
if (toolDefinition.description) {
|
||||||
|
sections.push(toolDefinition.description)
|
||||||
|
}
|
||||||
|
sections.push(
|
||||||
|
"",
|
||||||
|
"**inputSchema:**",
|
||||||
|
"```json",
|
||||||
|
JSON.stringify(sanitizeJsonSchema(toolDefinition.inputSchema), null, 2),
|
||||||
|
"```",
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendResourceSection(sections: string[], resources: Resource[]): void {
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(`**Resources**: ${resources.map((resource) => resource.uri).join(", ")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPromptSection(sections: string[], prompts: Prompt[]): void {
|
||||||
|
if (prompts.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push(`**Prompts**: ${prompts.map((prompt) => prompt.name).join(", ")}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { SkillInfo } from "./types"
|
||||||
|
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||||
|
|
||||||
|
export type NativeSkillEntry = {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
location: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||||
|
return {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.definition.description || "",
|
||||||
|
location: skill.path,
|
||||||
|
scope: skill.scope,
|
||||||
|
license: skill.license,
|
||||||
|
compatibility: skill.compatibility,
|
||||||
|
metadata: skill.metadata,
|
||||||
|
allowedTools: skill.allowedTools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
|
||||||
|
return {
|
||||||
|
name: native.name,
|
||||||
|
path: native.location,
|
||||||
|
definition: {
|
||||||
|
name: native.name,
|
||||||
|
description: native.description,
|
||||||
|
template: native.content,
|
||||||
|
},
|
||||||
|
scope: "config",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
|
||||||
|
const knownNames = new Set(skills.map((skill) => skill.name))
|
||||||
|
for (const native of nativeSkills) {
|
||||||
|
if (knownNames.has(native.name)) continue
|
||||||
|
skills.push(nativeSkillToLoadedSkill(native))
|
||||||
|
knownNames.add(native.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
|
||||||
|
const knownNames = new Set(skillInfos.map((skill) => skill.name))
|
||||||
|
for (const native of nativeSkills) {
|
||||||
|
if (knownNames.has(native.name)) continue
|
||||||
|
skillInfos.push({
|
||||||
|
name: native.name,
|
||||||
|
description: native.description,
|
||||||
|
location: native.location,
|
||||||
|
scope: "config",
|
||||||
|
})
|
||||||
|
knownNames.add(native.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPromiseLike<TValue>(value: TValue | Promise<TValue>): value is Promise<TValue> {
|
||||||
|
return typeof value === "object" && value !== null && "then" in value
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const SCOPE_PRIORITY: Record<string, number> = {
|
||||||
|
project: 4,
|
||||||
|
user: 3,
|
||||||
|
opencode: 2,
|
||||||
|
"opencode-project": 2,
|
||||||
|
plugin: 1,
|
||||||
|
config: 1,
|
||||||
|
builtin: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortByScopePriority<TItem extends { scope: string }>(items: TItem[]): TItem[] {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const leftPriority = SCOPE_PRIORITY[left.scope] || 0
|
||||||
|
const rightPriority = SCOPE_PRIORITY[right.scope] || 0
|
||||||
|
return rightPriority - leftPriority
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||||
|
import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
|
||||||
|
|
||||||
|
const SKILL_INSTRUCTION_PATTERN = /<skill-instruction>([\s\S]*?)<\/skill-instruction>/
|
||||||
|
|
||||||
|
function trimSkillInstruction(template: string): string {
|
||||||
|
const templateMatch = template.match(SKILL_INSTRUCTION_PATTERN)
|
||||||
|
return templateMatch ? templateMatch[1].trim() : template
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||||
|
if (skill.lazyContent) {
|
||||||
|
const fullTemplate = await skill.lazyContent.load()
|
||||||
|
return trimSkillInstruction(fullTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skill.scope === "config" && skill.definition.template) {
|
||||||
|
return trimSkillInstruction(skill.definition.template)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skill.path) {
|
||||||
|
return extractSkillTemplate(skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimSkillInstruction(skill.definition.template || "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { sortByScopePriority } from "./scope-priority"
|
||||||
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
|
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||||
|
|
||||||
|
export function matchSkillByName(skills: LoadedSkill[], requestedName: string): LoadedSkill | undefined {
|
||||||
|
const normalizedName = requestedName.toLowerCase()
|
||||||
|
const exactMatch = skills.find((skill) => skill.name.toLowerCase() === normalizedName)
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortNameMatches = skills.filter((skill) => {
|
||||||
|
const parts = skill.name.split("/")
|
||||||
|
const shortName = parts[parts.length - 1]
|
||||||
|
return parts.length > 1 && shortName?.toLowerCase() === normalizedName
|
||||||
|
})
|
||||||
|
|
||||||
|
if (shortNameMatches.length === 1) {
|
||||||
|
return shortNameMatches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchCommandByName(commands: CommandInfo[], requestedName: string): CommandInfo | undefined {
|
||||||
|
const normalizedName = requestedName.toLowerCase()
|
||||||
|
return sortByScopePriority(commands).find((command) => command.name.toLowerCase() === normalizedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPartialMatches(
|
||||||
|
skills: LoadedSkill[],
|
||||||
|
commands: CommandInfo[],
|
||||||
|
requestedName: string
|
||||||
|
): string[] {
|
||||||
|
const normalizedName = requestedName.toLowerCase()
|
||||||
|
return [
|
||||||
|
...skills.map((skill) => skill.name),
|
||||||
|
...commands.map((command) => `/${command.name}`),
|
||||||
|
].filter((name) => name.toLowerCase().includes(normalizedName))
|
||||||
|
}
|
||||||
+34
-266
@@ -1,258 +1,52 @@
|
|||||||
import { dirname } from "node:path"
|
import { dirname } from "node:path"
|
||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
|
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||||
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
|
import type { SkillArgs, SkillLoadOptions } from "./types"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||||
import { getAllSkills, extractSkillTemplate, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
|
import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
|
||||||
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
|
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
|
||||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
|
||||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
|
||||||
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
|
|
||||||
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
||||||
import type { CommandInfo } from "../slashcommand/types"
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
||||||
|
import { formatCombinedDescription } from "./description-formatter"
|
||||||
type NativeSkillEntry = {
|
import { formatMcpCapabilities } from "./mcp-capability-formatter"
|
||||||
name: string
|
import {
|
||||||
description: string
|
findPartialMatches,
|
||||||
location: string
|
matchCommandByName,
|
||||||
content: string
|
matchSkillByName,
|
||||||
}
|
} from "./skill-matcher"
|
||||||
// Priority: project > user > opencode/opencode-project > builtin/config
|
import { extractSkillBody } from "./skill-body"
|
||||||
const scopePriority: Record<string, number> = {
|
import {
|
||||||
project: 4,
|
isPromiseLike,
|
||||||
user: 3,
|
loadedSkillToInfo,
|
||||||
opencode: 2,
|
mergeNativeSkillInfos,
|
||||||
"opencode-project": 2,
|
mergeNativeSkills,
|
||||||
plugin: 1,
|
} from "./native-skills"
|
||||||
config: 1,
|
|
||||||
builtin: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
|
||||||
return {
|
|
||||||
name: skill.name,
|
|
||||||
description: skill.definition.description || "",
|
|
||||||
location: skill.path,
|
|
||||||
scope: skill.scope,
|
|
||||||
license: skill.license,
|
|
||||||
compatibility: skill.compatibility,
|
|
||||||
metadata: skill.metadata,
|
|
||||||
allowedTools: skill.allowedTools,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
|
|
||||||
return {
|
|
||||||
name: native.name,
|
|
||||||
path: native.location,
|
|
||||||
definition: {
|
|
||||||
name: native.name,
|
|
||||||
description: native.description,
|
|
||||||
template: native.content,
|
|
||||||
},
|
|
||||||
scope: "config",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
|
|
||||||
const knownNames = new Set(skills.map(skill => skill.name))
|
|
||||||
for (const native of nativeSkills) {
|
|
||||||
if (knownNames.has(native.name)) continue
|
|
||||||
skills.push(nativeSkillToLoadedSkill(native))
|
|
||||||
knownNames.add(native.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
|
|
||||||
const knownNames = new Set(skillInfos.map(skill => skill.name))
|
|
||||||
for (const native of nativeSkills) {
|
|
||||||
if (knownNames.has(native.name)) continue
|
|
||||||
skillInfos.push({
|
|
||||||
name: native.name,
|
|
||||||
description: native.description,
|
|
||||||
location: native.location,
|
|
||||||
scope: "config",
|
|
||||||
})
|
|
||||||
knownNames.add(native.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
|
||||||
return typeof value === "object" && value !== null && "then" in value
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
|
|
||||||
if (skills.length === 0 && commands.length === 0) {
|
|
||||||
return TOOL_DESCRIPTION_NO_SKILLS
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uses module-level scopePriority for consistent priority ordering
|
|
||||||
|
|
||||||
const allItems: string[] = []
|
|
||||||
|
|
||||||
// Skills rendered as command items (skills are also slash-invocable)
|
|
||||||
if (skills.length > 0) {
|
|
||||||
const sortedSkills = [...skills].sort((a, b) => {
|
|
||||||
const priorityA = scopePriority[a.scope] || 0
|
|
||||||
const priorityB = scopePriority[b.scope] || 0
|
|
||||||
return priorityB - priorityA
|
|
||||||
})
|
|
||||||
sortedSkills.forEach(skill => {
|
|
||||||
const parts = [
|
|
||||||
" <command>",
|
|
||||||
` <name>/${skill.name}</name>`,
|
|
||||||
` <description>${skill.description}</description>`,
|
|
||||||
` <scope>${skill.scope}</scope>`,
|
|
||||||
]
|
|
||||||
if (skill.compatibility) {
|
|
||||||
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
|
||||||
}
|
|
||||||
parts.push(" </command>")
|
|
||||||
allItems.push(parts.join("\n"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort and add commands second (commands after skills)
|
|
||||||
if (commands.length > 0) {
|
|
||||||
const sortedCommands = [...commands].sort((a, b) => {
|
|
||||||
const priorityA = scopePriority[a.scope] || 0
|
|
||||||
const priorityB = scopePriority[b.scope] || 0
|
|
||||||
return priorityB - priorityA // Higher priority first
|
|
||||||
})
|
|
||||||
sortedCommands.forEach(cmd => {
|
|
||||||
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
|
||||||
const parts = [
|
|
||||||
" <command>",
|
|
||||||
` <name>/${cmd.name}</name>`,
|
|
||||||
` <description>${cmd.metadata.description || "(no description)"}</description>`,
|
|
||||||
` <scope>${cmd.scope}</scope>`,
|
|
||||||
]
|
|
||||||
if (hint) {
|
|
||||||
parts.push(` <argument>${hint.trim()}</argument>`)
|
|
||||||
}
|
|
||||||
parts.push(" </command>")
|
|
||||||
allItems.push(parts.join("\n"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allItems.length > 0) {
|
|
||||||
lines.push(`\n<available_items>\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n</available_items>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return TOOL_DESCRIPTION_PREFIX + lines.join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
|
||||||
if (skill.lazyContent) {
|
|
||||||
const fullTemplate = await skill.lazyContent.load()
|
|
||||||
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
|
||||||
return templateMatch ? templateMatch[1].trim() : fullTemplate
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skill.scope === "config" && skill.definition.template) {
|
|
||||||
const templateMatch = skill.definition.template.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
|
||||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skill.path) {
|
|
||||||
return extractSkillTemplate(skill)
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
|
||||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formatMcpCapabilities(
|
|
||||||
skill: LoadedSkill,
|
|
||||||
manager: SkillMcpManager,
|
|
||||||
sessionID: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections: string[] = ["", "## Available MCP Servers", ""]
|
|
||||||
|
|
||||||
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
|
|
||||||
const info: SkillMcpClientInfo = {
|
|
||||||
serverName,
|
|
||||||
skillName: skill.name,
|
|
||||||
sessionID,
|
|
||||||
}
|
|
||||||
const context: SkillMcpServerContext = {
|
|
||||||
config,
|
|
||||||
skillName: skill.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push(`### ${serverName}`)
|
|
||||||
sections.push("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [tools, resources, prompts] = await Promise.all([
|
|
||||||
manager.listTools(info, context).catch(() => []),
|
|
||||||
manager.listResources(info, context).catch(() => []),
|
|
||||||
manager.listPrompts(info, context).catch(() => []),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (tools.length > 0) {
|
|
||||||
sections.push("**Tools:**")
|
|
||||||
sections.push("")
|
|
||||||
for (const t of tools as Tool[]) {
|
|
||||||
sections.push(`#### \`${t.name}\``)
|
|
||||||
if (t.description) {
|
|
||||||
sections.push(t.description)
|
|
||||||
}
|
|
||||||
sections.push("")
|
|
||||||
sections.push("**inputSchema:**")
|
|
||||||
sections.push("```json")
|
|
||||||
sections.push(JSON.stringify(sanitizeJsonSchema(t.inputSchema), null, 2))
|
|
||||||
sections.push("```")
|
|
||||||
sections.push("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (resources.length > 0) {
|
|
||||||
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
|
|
||||||
}
|
|
||||||
if (prompts.length > 0) {
|
|
||||||
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
|
|
||||||
sections.push("*No capabilities discovered*")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
||||||
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push("")
|
|
||||||
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
|
|
||||||
sections.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sections.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||||
let cachedDescription: string | null = null
|
let cachedDescription: string | null = null
|
||||||
|
|
||||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||||
clearSkillCache()
|
clearSkillCache()
|
||||||
const discovered = await getAllSkills({disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider})
|
const discovered = await getAllSkills({
|
||||||
|
disabledSkills: options?.disabledSkills,
|
||||||
|
browserProvider: options?.browserProvider,
|
||||||
|
})
|
||||||
const allSkills = !options.skills
|
const allSkills = !options.skills
|
||||||
? discovered
|
? discovered
|
||||||
: [...discovered, ...options.skills.filter(s => !new Set(discovered.map(d => d.name)).has(s.name))]
|
: [
|
||||||
|
...discovered,
|
||||||
|
...options.skills.filter(
|
||||||
|
(skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
if (options.nativeSkills) {
|
if (options.nativeSkills) {
|
||||||
try {
|
try {
|
||||||
const nativeAll = await options.nativeSkills.all()
|
const nativeAll = await options.nativeSkills.all()
|
||||||
mergeNativeSkills(allSkills, nativeAll)
|
mergeNativeSkills(allSkills, nativeAll)
|
||||||
} catch {
|
} catch {
|
||||||
// Native skill discovery may not be available
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +83,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
mergeNativeSkillInfos(skillInfos, nativeAll)
|
mergeNativeSkillInfos(skillInfos, nativeAll)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Native skill discovery may not be available
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,21 +116,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
|
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
|
||||||
|
|
||||||
const requestedName = args.name.replace(/^\//, "")
|
const requestedName = args.name.replace(/^\//, "")
|
||||||
|
const matchedSkill = matchSkillByName(skills, requestedName)
|
||||||
// Check skills first (exact match, case-insensitive)
|
|
||||||
let matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
|
|
||||||
|
|
||||||
// Fallback: try matching by short name (basename) for namespaced skills
|
|
||||||
// e.g. "systematic-debugging" matches "superpowers/systematic-debugging"
|
|
||||||
if (!matchedSkill) {
|
|
||||||
const shortNameMatches = skills.filter(s => {
|
|
||||||
const parts = s.name.split("/")
|
|
||||||
return parts.length > 1 && parts[parts.length - 1].toLowerCase() === requestedName.toLowerCase()
|
|
||||||
})
|
|
||||||
if (shortNameMatches.length === 1) {
|
|
||||||
matchedSkill = shortNameMatches[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedSkill) {
|
if (matchedSkill) {
|
||||||
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
|
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
|
||||||
@@ -380,27 +159,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
return output.join("\n")
|
return output.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check commands (exact match, case-insensitive) - sort by priority first
|
const matchedCommand = matchCommandByName(commands, requestedName)
|
||||||
const sortedCommands = [...commands].sort((a, b) => {
|
|
||||||
const priorityA = scopePriority[a.scope] || 0
|
|
||||||
const priorityB = scopePriority[b.scope] || 0
|
|
||||||
return priorityB - priorityA // Higher priority first
|
|
||||||
})
|
|
||||||
const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
|
|
||||||
|
|
||||||
if (matchedCommand) {
|
if (matchedCommand) {
|
||||||
return await formatLoadedCommand(matchedCommand, args.user_message)
|
return await formatLoadedCommand(matchedCommand, args.user_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No match found — provide helpful error with partial matches
|
const partialMatches = findPartialMatches(skills, commands, requestedName)
|
||||||
const allNames = [
|
|
||||||
...skills.map(s => s.name),
|
|
||||||
...commands.map(c => `/${c.name}`),
|
|
||||||
]
|
|
||||||
|
|
||||||
const partialMatches = allNames.filter(n =>
|
|
||||||
n.toLowerCase().includes(requestedName.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
if (partialMatches.length > 0) {
|
if (partialMatches.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -408,7 +173,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = allNames.join(", ")
|
const available = [
|
||||||
|
...skills.map((skill) => skill.name),
|
||||||
|
...commands.map((command) => `/${command.name}`),
|
||||||
|
].join(", ")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
|
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user