Compare commits
7 Commits
fix/fallba
...
fix/merge-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d3184e7a | ||
|
|
e70539fe5f | ||
|
|
c13886e13a | ||
|
|
05d7c3f462 | ||
|
|
e00275c07d | ||
|
|
70c033060b | ||
|
|
8fee45401d |
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CategoryConfig } from "../../config/schema"
|
import type { CategoryConfig } from "../../config/schema"
|
||||||
import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder"
|
import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||||
import { CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
|
import { CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
|
||||||
import { mergeCategories } from "../../shared/merge-categories"
|
import { mergeCategories } from "../../shared/merge-categories"
|
||||||
import { truncateDescription } from "../../shared/truncate-description"
|
import { truncateDescription } from "../../shared/truncate-description"
|
||||||
@@ -58,43 +58,16 @@ export function buildSkillsSection(skills: AvailableSkill[]): string {
|
|||||||
const builtinSkills = skills.filter((s) => s.location === "plugin")
|
const builtinSkills = skills.filter((s) => s.location === "plugin")
|
||||||
const customSkills = skills.filter((s) => s.location !== "plugin")
|
const customSkills = skills.filter((s) => s.location !== "plugin")
|
||||||
|
|
||||||
const builtinRows = builtinSkills.map((s) => {
|
|
||||||
const shortDesc = truncateDescription(s.description)
|
|
||||||
return `- **\`${s.name}\`** — ${shortDesc}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const customRows = customSkills.map((s) => {
|
|
||||||
const shortDesc = truncateDescription(s.description)
|
|
||||||
const source = s.location === "project" ? "project" : "user"
|
|
||||||
return `- **\`${s.name}\`** (${source}): ${shortDesc}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills, "**")
|
|
||||||
|
|
||||||
let skillsTable: string
|
|
||||||
|
|
||||||
if (customSkills.length > 0 && builtinSkills.length > 0) {
|
|
||||||
skillsTable = `**Built-in Skills:**
|
|
||||||
|
|
||||||
${builtinRows.join("\n")}
|
|
||||||
|
|
||||||
${customSkillBlock}`
|
|
||||||
} else if (customSkills.length > 0) {
|
|
||||||
skillsTable = customSkillBlock
|
|
||||||
} else {
|
|
||||||
skillsTable = `${builtinRows.join("\n")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
#### 3.2.2: Skill Selection (PREPEND TO PROMPT)
|
#### 3.2.2: Skill Selection (PREPEND TO PROMPT)
|
||||||
|
|
||||||
**Skills are specialized instructions that guide subagent behavior. Consider them alongside category selection.**
|
**Use the \`Category + Skills Delegation System\` section below as the single source of truth for skill details.**
|
||||||
|
- Built-in skills available: ${builtinSkills.length}
|
||||||
${skillsTable}
|
- User-installed skills available: ${customSkills.length}
|
||||||
|
|
||||||
**MANDATORY: Evaluate ALL skills (built-in AND user-installed) for relevance to your task.**
|
**MANDATORY: Evaluate ALL skills (built-in AND user-installed) for relevance to your task.**
|
||||||
|
|
||||||
Read each skill's description and ask: "Does this skill's domain overlap with my task?"
|
Read each skill's description in the section below and ask: "Does this skill's domain overlap with my task?"
|
||||||
- If YES: INCLUDE in load_skills=[...]
|
- If YES: INCLUDE in load_skills=[...]
|
||||||
- If NO: You MUST justify why in your pre-delegation declaration
|
- If NO: You MUST justify why in your pre-delegation declaration
|
||||||
|
|
||||||
|
|||||||
@@ -43,16 +43,16 @@ describe("buildCategorySkillsDelegationGuide", () => {
|
|||||||
expect(result).toContain("HIGH PRIORITY")
|
expect(result).toContain("HIGH PRIORITY")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should include custom skill names in CRITICAL warning", () => {
|
it("should list custom skills and keep CRITICAL warning", () => {
|
||||||
//#given: custom skills installed
|
//#given: custom skills installed
|
||||||
const allSkills = [...builtinSkills, ...customUserSkills]
|
const allSkills = [...builtinSkills, ...customUserSkills]
|
||||||
|
|
||||||
//#when: building the delegation guide
|
//#when: building the delegation guide
|
||||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||||
|
|
||||||
//#then: should mention custom skills by name in the warning
|
//#then: should mention custom skills by name and include warning
|
||||||
expect(result).toContain('"react-19"')
|
expect(result).toContain("`react-19`")
|
||||||
expect(result).toContain('"tailwind-4"')
|
expect(result).toContain("`tailwind-4`")
|
||||||
expect(result).toContain("CRITICAL")
|
expect(result).toContain("CRITICAL")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,8 +180,8 @@ describe("formatCustomSkillsBlock", () => {
|
|||||||
//#then: contains all expected elements
|
//#then: contains all expected elements
|
||||||
expect(result).toContain("User-Installed Skills (HIGH PRIORITY)")
|
expect(result).toContain("User-Installed Skills (HIGH PRIORITY)")
|
||||||
expect(result).toContain("CRITICAL")
|
expect(result).toContain("CRITICAL")
|
||||||
expect(result).toContain('"react-19"')
|
expect(result).toContain("`react-19`")
|
||||||
expect(result).toContain('"tailwind-4"')
|
expect(result).toContain("`tailwind-4`")
|
||||||
expect(result).toContain("| user |")
|
expect(result).toContain("| user |")
|
||||||
expect(result).toContain("| project |")
|
expect(result).toContain("| project |")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function categorizeTools(toolNames: string[]): AvailableTool[] {
|
|||||||
category = "search"
|
category = "search"
|
||||||
} else if (name.startsWith("session_")) {
|
} else if (name.startsWith("session_")) {
|
||||||
category = "session"
|
category = "session"
|
||||||
} else if (name === "slashcommand") {
|
} else if (name === "skill") {
|
||||||
category = "command"
|
category = "command"
|
||||||
}
|
}
|
||||||
return { name, category }
|
return { name, category }
|
||||||
@@ -167,7 +167,6 @@ export function formatCustomSkillsBlock(
|
|||||||
customSkills: AvailableSkill[],
|
customSkills: AvailableSkill[],
|
||||||
headerLevel: "####" | "**" = "####"
|
headerLevel: "####" | "**" = "####"
|
||||||
): string {
|
): string {
|
||||||
const customSkillNames = customSkills.map((s) => `"${s.name}"`).join(", ")
|
|
||||||
const header = headerLevel === "####"
|
const header = headerLevel === "####"
|
||||||
? `#### User-Installed Skills (HIGH PRIORITY)`
|
? `#### User-Installed Skills (HIGH PRIORITY)`
|
||||||
: `**User-Installed Skills (HIGH PRIORITY):**`
|
: `**User-Installed Skills (HIGH PRIORITY):**`
|
||||||
@@ -180,7 +179,7 @@ Subagents are STATELESS — they lose all custom knowledge unless you pass these
|
|||||||
${customRows.join("\n")}
|
${customRows.join("\n")}
|
||||||
|
|
||||||
> **CRITICAL**: Ignoring user-installed skills when they match the task domain is a failure.
|
> **CRITICAL**: Ignoring user-installed skills when they match the task domain is a failure.
|
||||||
> The user installed ${customSkillNames} for a reason — USE THEM when the task overlaps with their domain.`
|
> The user installed custom skills for a reason — USE THEM when the task overlaps with their domain.`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCategorySkillsDelegationGuide(categories: AvailableCategory[], skills: AvailableSkill[]): string {
|
export function buildCategorySkillsDelegationGuide(categories: AvailableCategory[], skills: AvailableSkill[]): string {
|
||||||
|
|||||||
@@ -202,7 +202,9 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
|
|||||||
if (!command) {
|
if (!command) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: parsed.command.includes(":") ? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.` : `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
error: parsed.command.includes(":")
|
||||||
|
? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.`
|
||||||
|
: `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hooks.ralphLoop && input.tool === "slashcommand") {
|
if (hooks.ralphLoop && input.tool === "skill") {
|
||||||
const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined
|
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
|
||||||
const command = rawCommand?.replace(/^\//, "").toLowerCase()
|
const command = rawName?.replace(/^\//, "").toLowerCase()
|
||||||
const sessionID = input.sessionID || getMainSessionID()
|
const sessionID = input.sessionID || getMainSessionID()
|
||||||
|
|
||||||
if (command === "ralph-loop" && sessionID) {
|
if (command === "ralph-loop" && sessionID) {
|
||||||
const rawArgs = rawCommand?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
|
const rawArgs = rawName?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
|
||||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
||||||
const prompt =
|
const prompt =
|
||||||
taskMatch?.[1] ||
|
taskMatch?.[1] ||
|
||||||
@@ -66,7 +66,7 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
} else if (command === "cancel-ralph" && sessionID) {
|
} else if (command === "cancel-ralph" && sessionID) {
|
||||||
hooks.ralphLoop.cancelLoop(sessionID)
|
hooks.ralphLoop.cancelLoop(sessionID)
|
||||||
} else if (command === "ulw-loop" && sessionID) {
|
} else if (command === "ulw-loop" && sessionID) {
|
||||||
const rawArgs = rawCommand?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
|
const rawArgs = rawName?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
|
||||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
||||||
const prompt =
|
const prompt =
|
||||||
taskMatch?.[1] ||
|
taskMatch?.[1] ||
|
||||||
@@ -84,9 +84,9 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.tool === "slashcommand") {
|
if (input.tool === "skill") {
|
||||||
const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined
|
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
|
||||||
const command = rawCommand?.replace(/^\//, "").toLowerCase()
|
const command = rawName?.replace(/^\//, "").toLowerCase()
|
||||||
const sessionID = input.sessionID || getMainSessionID()
|
const sessionID = input.sessionID || getMainSessionID()
|
||||||
|
|
||||||
if (command === "stop-continuation" && sessionID) {
|
if (command === "stop-continuation" && sessionID) {
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
createBackgroundTools,
|
createBackgroundTools,
|
||||||
createCallOmoAgent,
|
createCallOmoAgent,
|
||||||
createLookAt,
|
createLookAt,
|
||||||
createSkillTool,
|
|
||||||
createSkillMcpTool,
|
createSkillMcpTool,
|
||||||
createSlashcommandTool,
|
createSkillTool,
|
||||||
createGrepTools,
|
createGrepTools,
|
||||||
createGlobTools,
|
createGlobTools,
|
||||||
createAstGrepTools,
|
createAstGrepTools,
|
||||||
@@ -89,14 +88,6 @@ export function createToolRegistry(args: {
|
|||||||
|
|
||||||
const getSessionIDForMcp = (): string => getMainSessionID() || ""
|
const getSessionIDForMcp = (): string => getMainSessionID() || ""
|
||||||
|
|
||||||
const skillTool = createSkillTool({
|
|
||||||
skills: skillContext.mergedSkills,
|
|
||||||
mcpManager: managers.skillMcpManager,
|
|
||||||
getSessionID: getSessionIDForMcp,
|
|
||||||
gitMasterConfig: pluginConfig.git_master,
|
|
||||||
disabledSkills: skillContext.disabledSkills,
|
|
||||||
})
|
|
||||||
|
|
||||||
const skillMcpTool = createSkillMcpTool({
|
const skillMcpTool = createSkillMcpTool({
|
||||||
manager: managers.skillMcpManager,
|
manager: managers.skillMcpManager,
|
||||||
getLoadedSkills: () => skillContext.mergedSkills,
|
getLoadedSkills: () => skillContext.mergedSkills,
|
||||||
@@ -104,9 +95,12 @@ export function createToolRegistry(args: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const commands = discoverCommandsSync(ctx.directory)
|
const commands = discoverCommandsSync(ctx.directory)
|
||||||
const slashcommandTool = createSlashcommandTool({
|
const skillTool = createSkillTool({
|
||||||
commands,
|
commands,
|
||||||
skills: skillContext.mergedSkills,
|
skills: skillContext.mergedSkills,
|
||||||
|
mcpManager: managers.skillMcpManager,
|
||||||
|
getSessionID: getSessionIDForMcp,
|
||||||
|
gitMasterConfig: pluginConfig.git_master,
|
||||||
})
|
})
|
||||||
|
|
||||||
const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false
|
const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false
|
||||||
@@ -134,9 +128,8 @@ export function createToolRegistry(args: {
|
|||||||
call_omo_agent: callOmoAgent,
|
call_omo_agent: callOmoAgent,
|
||||||
...(lookAt ? { look_at: lookAt } : {}),
|
...(lookAt ? { look_at: lookAt } : {}),
|
||||||
task: delegateTask,
|
task: delegateTask,
|
||||||
skill: skillTool,
|
|
||||||
skill_mcp: skillMcpTool,
|
skill_mcp: skillMcpTool,
|
||||||
slashcommand: slashcommandTool,
|
skill: skillTool,
|
||||||
interactive_bash,
|
interactive_bash,
|
||||||
...taskToolsRecord,
|
...taskToolsRecord,
|
||||||
...hashlineToolsRecord,
|
...hashlineToolsRecord,
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ export { lspManager }
|
|||||||
export { createAstGrepTools } from "./ast-grep"
|
export { createAstGrepTools } from "./ast-grep"
|
||||||
export { createGrepTools } from "./grep"
|
export { createGrepTools } from "./grep"
|
||||||
export { createGlobTools } from "./glob"
|
export { createGlobTools } from "./glob"
|
||||||
export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand"
|
export { createSkillTool } from "./skill"
|
||||||
|
export { discoverCommandsSync } from "./slashcommand"
|
||||||
export { createSessionManagerTools } from "./session-manager"
|
export { createSessionManagerTools } from "./session-manager"
|
||||||
|
|
||||||
export { sessionExists } from "./session-manager/storage"
|
export { sessionExists } from "./session-manager/storage"
|
||||||
|
|
||||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||||
export { createSkillTool } from "./skill"
|
|
||||||
export { createSkillMcpTool } from "./skill-mcp"
|
export { createSkillMcpTool } from "./skill-mcp"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
export const TOOL_NAME = "skill" as const
|
export const TOOL_NAME = "skill" as const
|
||||||
|
|
||||||
export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill to get detailed instructions for a specific task. No skills are currently available."
|
export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill or execute a slash command to get detailed instructions for a specific task. No skills are currently available."
|
||||||
|
|
||||||
export const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.
|
export const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a command to get detailed instructions for a specific task.
|
||||||
|
|
||||||
Skills provide specialized knowledge and step-by-step guidance.
|
Skills and commands provide specialized knowledge and step-by-step guidance.
|
||||||
Use this when a task matches an available skill's description.`
|
Use this when a task matches an available skill's or command's description.
|
||||||
|
`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as fs from "node:fs"
|
|||||||
import { createSkillTool } from "./tools"
|
import { createSkillTool } from "./tools"
|
||||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||||
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
||||||
|
|
||||||
const originalReadFileSync = fs.readFileSync.bind(fs)
|
const originalReadFileSync = fs.readFileSync.bind(fs)
|
||||||
@@ -105,6 +106,61 @@ describe("skill tool - synchronous description", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("skill tool - command listing format", () => {
|
||||||
|
it("uses XML format for commands in description", () => {
|
||||||
|
// given
|
||||||
|
const commands: CommandInfo[] = [
|
||||||
|
{
|
||||||
|
name: "publish",
|
||||||
|
path: "/test/commands/publish",
|
||||||
|
metadata: { name: "publish", description: "Publish to npm", argumentHint: "<patch|minor|major>" },
|
||||||
|
scope: "opencode-project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "commit",
|
||||||
|
path: "/test/commands/commit",
|
||||||
|
metadata: { name: "commit", description: "Commits changes" },
|
||||||
|
scope: "builtin",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// when
|
||||||
|
const tool = createSkillTool({ skills: [], commands })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(tool.description).toContain("<available_commands>")
|
||||||
|
expect(tool.description).toContain("</available_commands>")
|
||||||
|
expect(tool.description).toContain("<command>")
|
||||||
|
expect(tool.description).toContain("<name>/publish <patch|minor|major></name>")
|
||||||
|
expect(tool.description).toContain("<description>Publish to npm</description>")
|
||||||
|
expect(tool.description).toContain("<scope>opencode-project</scope>")
|
||||||
|
expect(tool.description).toContain("<name>/commit</name>")
|
||||||
|
expect(tool.description).toContain("<scope>builtin</scope>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses XML format for both skills and commands together", () => {
|
||||||
|
// given
|
||||||
|
const loadedSkills = [createMockSkill("test-skill")]
|
||||||
|
const commands: CommandInfo[] = [
|
||||||
|
{
|
||||||
|
name: "deploy",
|
||||||
|
path: "/test/commands/deploy",
|
||||||
|
metadata: { name: "deploy", description: "Deploy app" },
|
||||||
|
scope: "user",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// when
|
||||||
|
const tool = createSkillTool({ skills: loadedSkills, commands })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(tool.description).toContain("<available_skills>")
|
||||||
|
expect(tool.description).toContain("<available_commands>")
|
||||||
|
expect(tool.description).toContain("<command>")
|
||||||
|
expect(tool.description).toContain("<name>/deploy</name>")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("skill tool - agent restriction", () => {
|
describe("skill tool - agent restriction", () => {
|
||||||
it("allows skill without agent restriction to any agent", async () => {
|
it("allows skill without agent restriction to any agent", async () => {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { getAllSkills, extractSkillTemplate } from "../../features/opencode-skil
|
|||||||
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 { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||||
|
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
||||||
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
|
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
||||||
|
|
||||||
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||||
return {
|
return {
|
||||||
@@ -21,23 +24,45 @@ function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSkillsXml(skills: SkillInfo[]): string {
|
function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
|
||||||
if (skills.length === 0) return ""
|
const lines: string[] = []
|
||||||
|
|
||||||
const skillsXml = skills.map(skill => {
|
if (skills.length === 0 && commands.length === 0) {
|
||||||
const lines = [
|
return TOOL_DESCRIPTION_NO_SKILLS
|
||||||
" <skill>",
|
}
|
||||||
` <name>${skill.name}</name>`,
|
|
||||||
` <description>${skill.description}</description>`,
|
|
||||||
]
|
|
||||||
if (skill.compatibility) {
|
|
||||||
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
|
||||||
}
|
|
||||||
lines.push(" </skill>")
|
|
||||||
return lines.join("\n")
|
|
||||||
}).join("\n")
|
|
||||||
|
|
||||||
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`
|
if (skills.length > 0) {
|
||||||
|
const skillsXml = skills.map(skill => {
|
||||||
|
const parts = [
|
||||||
|
" <skill>",
|
||||||
|
` <name>${skill.name}</name>`,
|
||||||
|
` <description>${skill.description}</description>`,
|
||||||
|
]
|
||||||
|
if (skill.compatibility) {
|
||||||
|
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||||
|
}
|
||||||
|
parts.push(" </skill>")
|
||||||
|
return parts.join("\n")
|
||||||
|
}).join("\n")
|
||||||
|
lines.push(`\n<available_skills>\n${skillsXml}\n</available_skills>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commands.length > 0) {
|
||||||
|
const commandsXml = commands.map(cmd => {
|
||||||
|
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
||||||
|
const parts = [
|
||||||
|
" <command>",
|
||||||
|
` <name>/${cmd.name}${hint}</name>`,
|
||||||
|
` <description>${cmd.metadata.description || "(no description)"}</description>`,
|
||||||
|
` <scope>${cmd.scope}</scope>`,
|
||||||
|
" </command>",
|
||||||
|
]
|
||||||
|
return parts.join("\n")
|
||||||
|
}).join("\n")
|
||||||
|
lines.push(`\n<available_commands>\n${commandsXml}\n</available_commands>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TOOL_DESCRIPTION_PREFIX + lines.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||||
@@ -128,6 +153,7 @@ async function formatMcpCapabilities(
|
|||||||
|
|
||||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||||
let cachedSkills: LoadedSkill[] | null = null
|
let cachedSkills: LoadedSkill[] | null = null
|
||||||
|
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
||||||
let cachedDescription: string | null = null
|
let cachedDescription: string | null = null
|
||||||
|
|
||||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||||
@@ -137,23 +163,30 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
return cachedSkills
|
return cachedSkills
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDescription = async (): Promise<string> => {
|
const getCommands = (): CommandInfo[] => {
|
||||||
|
if (cachedCommands) return cachedCommands
|
||||||
|
cachedCommands = discoverCommandsSync()
|
||||||
|
return cachedCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDescription = async (): Promise<string> => {
|
||||||
if (cachedDescription) return cachedDescription
|
if (cachedDescription) return cachedDescription
|
||||||
const skills = await getSkills()
|
const skills = await getSkills()
|
||||||
|
const commands = getCommands()
|
||||||
const skillInfos = skills.map(loadedSkillToInfo)
|
const skillInfos = skills.map(loadedSkillToInfo)
|
||||||
cachedDescription = skillInfos.length === 0
|
cachedDescription = formatCombinedDescription(skillInfos, commands)
|
||||||
? TOOL_DESCRIPTION_NO_SKILLS
|
|
||||||
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
|
|
||||||
return cachedDescription
|
return cachedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.skills) {
|
// Eagerly build description when callers pre-provide skills/commands.
|
||||||
|
if (options.skills !== undefined) {
|
||||||
const skillInfos = options.skills.map(loadedSkillToInfo)
|
const skillInfos = options.skills.map(loadedSkillToInfo)
|
||||||
cachedDescription = skillInfos.length === 0
|
const commandsForDescription = options.commands ?? []
|
||||||
? TOOL_DESCRIPTION_NO_SKILLS
|
cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription)
|
||||||
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
|
} else if (options.commands !== undefined) {
|
||||||
|
cachedDescription = formatCombinedDescription([], options.commands)
|
||||||
} else {
|
} else {
|
||||||
getDescription()
|
void buildDescription()
|
||||||
}
|
}
|
||||||
|
|
||||||
return tool({
|
return tool({
|
||||||
@@ -161,49 +194,79 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
|
name: tool.schema.string().describe("The skill or command name (e.g., 'code-review' or 'publish'). Use without leading slash for commands."),
|
||||||
},
|
},
|
||||||
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
||||||
const skills = await getSkills()
|
const skills = await getSkills()
|
||||||
const skill = skills.find(s => s.name === args.name)
|
const commands = getCommands()
|
||||||
|
|
||||||
if (!skill) {
|
const requestedName = args.name.replace(/^\//, "")
|
||||||
const available = skills.map(s => s.name).join(", ")
|
|
||||||
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
|
// Check skills first (exact match, case-insensitive)
|
||||||
|
const matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
|
||||||
|
|
||||||
|
if (matchedSkill) {
|
||||||
|
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 && options.getSessionID && matchedSkill.mcpConfig) {
|
||||||
|
const mcpInfo = await formatMcpCapabilities(
|
||||||
|
matchedSkill,
|
||||||
|
options.mcpManager,
|
||||||
|
options.getSessionID()
|
||||||
|
)
|
||||||
|
if (mcpInfo) {
|
||||||
|
output.push(mcpInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skill.definition.agent && (!ctx?.agent || skill.definition.agent !== ctx.agent)) {
|
// Check commands (exact match, case-insensitive)
|
||||||
throw new Error(`Skill "${args.name}" is restricted to agent "${skill.definition.agent}"`)
|
const matchedCommand = commands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
|
||||||
|
|
||||||
|
if (matchedCommand) {
|
||||||
|
return await formatLoadedCommand(matchedCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = await extractSkillBody(skill)
|
// No match found — provide helpful error with partial matches
|
||||||
|
const allNames = [
|
||||||
if (args.name === "git-master") {
|
...skills.map(s => s.name),
|
||||||
body = injectGitMasterConfig(body, options.gitMasterConfig)
|
...commands.map(c => `/${c.name}`),
|
||||||
}
|
|
||||||
|
|
||||||
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
|
||||||
|
|
||||||
const output = [
|
|
||||||
`## Skill: ${skill.name}`,
|
|
||||||
"",
|
|
||||||
`**Base directory**: ${dir}`,
|
|
||||||
"",
|
|
||||||
body,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if (options.mcpManager && options.getSessionID && skill.mcpConfig) {
|
const partialMatches = allNames.filter(n =>
|
||||||
const mcpInfo = await formatMcpCapabilities(
|
n.toLowerCase().includes(requestedName.toLowerCase())
|
||||||
skill,
|
)
|
||||||
options.mcpManager,
|
|
||||||
options.getSessionID()
|
if (partialMatches.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Skill or command "${args.name}" not found. Did you mean: ${partialMatches.join(", ")}?`
|
||||||
)
|
)
|
||||||
if (mcpInfo) {
|
|
||||||
output.push(mcpInfo)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return output.join("\n")
|
const available = allNames.join(", ")
|
||||||
|
throw new Error(
|
||||||
|
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types"
|
import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||||
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||||
import type { GitMasterConfig } from "../../config/schema"
|
import type { GitMasterConfig } from "../../config/schema"
|
||||||
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
|
|
||||||
export interface SkillArgs {
|
export interface SkillArgs {
|
||||||
name: string
|
name: string
|
||||||
@@ -22,6 +23,8 @@ export interface SkillLoadOptions {
|
|||||||
opencodeOnly?: boolean
|
opencodeOnly?: boolean
|
||||||
/** Pre-merged skills to use instead of discovering */
|
/** Pre-merged skills to use instead of discovering */
|
||||||
skills?: LoadedSkill[]
|
skills?: LoadedSkill[]
|
||||||
|
/** Pre-discovered commands to use instead of discovering */
|
||||||
|
commands?: CommandInfo[]
|
||||||
/** MCP manager for querying skill-embedded MCP servers */
|
/** MCP manager for querying skill-embedded MCP servers */
|
||||||
mcpManager?: SkillMcpManager
|
mcpManager?: SkillMcpManager
|
||||||
/** Session ID getter for MCP client identification */
|
/** Session ID getter for MCP client identification */
|
||||||
|
|||||||
131
src/tools/slashcommand/skill-formatter.ts
Normal file
131
src/tools/slashcommand/skill-formatter.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { dirname } from "node:path"
|
||||||
|
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||||
|
import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
|
||||||
|
import { injectGitMasterConfig as injectGitMasterConfigOriginal } 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 type { GitMasterConfig } from "../../config/schema/git-master"
|
||||||
|
|
||||||
|
export 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.path) {
|
||||||
|
return extractSkillTemplate(skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||||
|
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
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(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 { injectGitMasterConfigOriginal as injectGitMasterConfig }
|
||||||
|
|
||||||
|
export async function formatSkillOutput(
|
||||||
|
skill: LoadedSkill,
|
||||||
|
mcpManager?: SkillMcpManager,
|
||||||
|
getSessionID?: () => string,
|
||||||
|
gitMasterConfig?: GitMasterConfig
|
||||||
|
): Promise<string> {
|
||||||
|
let body = await extractSkillBody(skill)
|
||||||
|
|
||||||
|
if (skill.name === "git-master") {
|
||||||
|
body = injectGitMasterConfigOriginal(body, gitMasterConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
||||||
|
|
||||||
|
const output = [
|
||||||
|
`## Skill: ${skill.name}`,
|
||||||
|
"",
|
||||||
|
`**Base directory**: ${dir}`,
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (mcpManager && getSessionID && skill.mcpConfig) {
|
||||||
|
const mcpInfo = await formatMcpCapabilities(
|
||||||
|
skill,
|
||||||
|
mcpManager,
|
||||||
|
getSessionID()
|
||||||
|
)
|
||||||
|
if (mcpInfo) {
|
||||||
|
output.push(mcpInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join("\n")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { discoverCommandsSync } from "./command-discovery"
|
|||||||
import { buildDescriptionFromItems, TOOL_DESCRIPTION_PREFIX } from "./slashcommand-description"
|
import { buildDescriptionFromItems, TOOL_DESCRIPTION_PREFIX } from "./slashcommand-description"
|
||||||
import { formatCommandList, formatLoadedCommand } from "./command-output-formatter"
|
import { formatCommandList, formatLoadedCommand } from "./command-output-formatter"
|
||||||
import { skillToCommandInfo } from "./skill-command-converter"
|
import { skillToCommandInfo } from "./skill-command-converter"
|
||||||
|
import { formatSkillOutput } from "./skill-formatter"
|
||||||
|
|
||||||
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
|
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
|
||||||
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
||||||
@@ -75,6 +76,18 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (exactMatch) {
|
if (exactMatch) {
|
||||||
|
const skills = await getSkills()
|
||||||
|
const matchedSkill = skills.find(s => s.name === exactMatch.name)
|
||||||
|
|
||||||
|
if (matchedSkill) {
|
||||||
|
return await formatSkillOutput(
|
||||||
|
matchedSkill,
|
||||||
|
options.mcpManager,
|
||||||
|
options.getSessionID,
|
||||||
|
options.gitMasterConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return await formatLoadedCommand(exactMatch, args.user_message)
|
return await formatLoadedCommand(exactMatch, args.user_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
|
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||||
|
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||||
|
import type { GitMasterConfig } from "../../config/schema/git-master"
|
||||||
|
|
||||||
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||||
|
|
||||||
@@ -25,4 +27,10 @@ export interface SlashcommandToolOptions {
|
|||||||
commands?: CommandInfo[]
|
commands?: CommandInfo[]
|
||||||
/** Pre-loaded skills (skip discovery if provided) */
|
/** Pre-loaded skills (skip discovery if provided) */
|
||||||
skills?: LoadedSkill[]
|
skills?: LoadedSkill[]
|
||||||
|
/** MCP manager for skill MCP capabilities */
|
||||||
|
mcpManager?: SkillMcpManager
|
||||||
|
/** Function to get current session ID */
|
||||||
|
getSessionID?: () => string
|
||||||
|
/** Git master configuration */
|
||||||
|
gitMasterConfig?: GitMasterConfig
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user