feat(skill): merge skills and commands into unified available_items with priority sorting

- Merge <available_skills> and <available_commands> into single <available_items>
- Sort by priority: project > user > opencode > builtin
- List skills before commands
- Add priority documentation to description
- Add 5 tests for ordering and priority

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim 2026-02-19 17:40:59 +09:00
parent 3adade46e3
commit acc28a89c1
2 changed files with 177 additions and 11 deletions

View File

@ -4,6 +4,7 @@ import * as fs from "node:fs"
import { createSkillTool } from "./tools"
import { SkillMcpManager } from "../../features/skill-mcp-manager"
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"
const originalReadFileSync = fs.readFileSync.bind(fs)
@ -67,7 +68,7 @@ const mockContext: ToolContext = {
}
describe("skill tool - synchronous description", () => {
it("includes available_skills immediately when skills are pre-provided", () => {
it("includes available_items immediately when skills are pre-provided", () => {
// given
const loadedSkills = [createMockSkill("test-skill")]
@ -75,11 +76,11 @@ describe("skill tool - synchronous description", () => {
const tool = createSkillTool({ skills: loadedSkills })
// then
expect(tool.description).toContain("<available_skills>")
expect(tool.description).toContain("<available_items>")
expect(tool.description).toContain("test-skill")
})
it("includes all pre-provided skills in available_skills immediately", () => {
it("includes all pre-provided skills in available_items immediately", () => {
// given
const loadedSkills = [
createMockSkill("playwright"),
@ -91,6 +92,7 @@ describe("skill tool - synchronous description", () => {
const tool = createSkillTool({ skills: loadedSkills })
// then
expect(tool.description).toContain("<available_items>")
expect(tool.description).toContain("playwright")
expect(tool.description).toContain("frontend-ui-ux")
expect(tool.description).toContain("git-master")
@ -353,3 +355,131 @@ describe("skill tool - MCP schema display", () => {
})
})
})
describe("skill tool - ordering and priority", () => {
function createMockSkillWithScope(name: string, scope: string): LoadedSkill {
return {
name,
path: `/test/skills/${name}/SKILL.md`,
resolvedPath: `/test/skills/${name}`,
definition: {
name,
description: `Test skill ${name}`,
template: "Test template",
},
scope: scope as LoadedSkill["scope"],
}
}
function createMockCommand(name: string, scope: string) {
return {
name,
path: `/test/commands/${name}.md`,
metadata: {
name,
description: `Test command ${name}`,
},
scope: scope as CommandInfo["scope"],
}
}
it("lists skills before commands in available_items", () => {
//#given: mix of skills and commands
const skills = [
createMockSkillWithScope("builtin-skill", "builtin"),
createMockSkillWithScope("project-skill", "project"),
]
const commands = [
createMockCommand("project-cmd", "project"),
createMockCommand("builtin-cmd", "builtin"),
]
//#when: creating tool with both
const tool = createSkillTool({ skills, commands })
//#then: skills should appear before commands
const desc = tool.description
const skillIndex = desc.indexOf("<skill>")
const commandIndex = desc.indexOf("<command>")
expect(skillIndex).toBeGreaterThan(0)
expect(commandIndex).toBeGreaterThan(0)
expect(skillIndex).toBeLessThan(commandIndex)
})
it("sorts skills by priority: project > user > opencode > builtin", () => {
//#given: skills in random order
const skills = [
createMockSkillWithScope("builtin-skill", "builtin"),
createMockSkillWithScope("opencode-skill", "opencode"),
createMockSkillWithScope("project-skill", "project"),
createMockSkillWithScope("user-skill", "user"),
]
//#when: creating tool
const tool = createSkillTool({ skills })
//#then: should be sorted by priority
const desc = tool.description
const projectIndex = desc.indexOf("project-skill")
const userIndex = desc.indexOf("user-skill")
const opencodeIndex = desc.indexOf("opencode-skill")
const builtinIndex = desc.indexOf("builtin-skill")
expect(projectIndex).toBeLessThan(userIndex)
expect(userIndex).toBeLessThan(opencodeIndex)
expect(opencodeIndex).toBeLessThan(builtinIndex)
})
it("sorts commands by priority: project > user > opencode > builtin", () => {
//#given: commands in random order
const commands = [
createMockCommand("builtin-cmd", "builtin"),
createMockCommand("opencode-cmd", "opencode"),
createMockCommand("project-cmd", "project"),
createMockCommand("user-cmd", "user"),
]
//#when: creating tool
const tool = createSkillTool({ commands })
//#then: should be sorted by priority
const desc = tool.description
const projectIndex = desc.indexOf("project-cmd")
const userIndex = desc.indexOf("user-cmd")
const opencodeIndex = desc.indexOf("opencode-cmd")
const builtinIndex = desc.indexOf("builtin-cmd")
expect(projectIndex).toBeLessThan(userIndex)
expect(userIndex).toBeLessThan(opencodeIndex)
expect(opencodeIndex).toBeLessThan(builtinIndex)
})
it("includes priority documentation in description", () => {
//#given: some skills and commands
const skills = [createMockSkillWithScope("test-skill", "project")]
const commands = [createMockCommand("test-cmd", "project")]
//#when: creating tool
const tool = createSkillTool({ skills, commands })
//#then: should include priority info
expect(tool.description).toContain("Priority: project > user > opencode > builtin")
expect(tool.description).toContain("Skills listed before commands")
})
it("uses <available_items> wrapper with unified format", () => {
//#given: mix of skills and commands
const skills = [createMockSkillWithScope("test-skill", "project")]
const commands = [createMockCommand("test-cmd", "project")]
//#when: creating tool
const tool = createSkillTool({ skills, commands })
//#then: should use unified wrapper
expect(tool.description).toContain("<available_items>")
expect(tool.description).toContain("</available_items>")
expect(tool.description).toContain("<skill>")
expect(tool.description).toContain("<command>")
})
})

View File

@ -31,8 +31,26 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[])
return TOOL_DESCRIPTION_NO_SKILLS
}
// Priority: project > user > opencode/opencode-project > builtin/config
const scopePriority: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
config: 1,
builtin: 1,
}
const allItems: string[] = []
// Sort and add skills first (skills before commands)
if (skills.length > 0) {
const skillsXml = skills.map(skill => {
const sortedSkills = [...skills].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA // Higher priority first
})
sortedSkills.forEach(skill => {
const parts = [
" <skill>",
` <name>${skill.name}</name>`,
@ -42,17 +60,35 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[])
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>`)
allItems.push(parts.join("\n"))
})
}
// Sort and add commands second (commands after skills)
if (commands.length > 0) {
const commandLines = commands.map(cmd => {
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}` : ""
return ` - /${cmd.name}${hint}: ${cmd.metadata.description || "(no description)"} (${cmd.scope})`
}).join("\n")
lines.push(`\n<available_commands>\n${commandLines}\n</available_commands>`)
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 | 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("")