From acc28a89c17f6eb37053bd51c887b758f755c465 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 19 Feb 2026 17:40:59 +0900 Subject: [PATCH] feat(skill): merge skills and commands into unified available_items with priority sorting - Merge and into single - 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 --- src/tools/skill/tools.test.ts | 136 +++++++++++++++++++++++++++++++++- src/tools/skill/tools.ts | 52 +++++++++++-- 2 files changed, 177 insertions(+), 11 deletions(-) diff --git a/src/tools/skill/tools.test.ts b/src/tools/skill/tools.test.ts index 52d69bb5..d4f2d01f 100644 --- a/src/tools/skill/tools.test.ts +++ b/src/tools/skill/tools.test.ts @@ -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("") + expect(tool.description).toContain("") 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("") 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("") + const commandIndex = desc.indexOf("") + 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 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("") + expect(tool.description).toContain("") + expect(tool.description).toContain("") + expect(tool.description).toContain("") + }) +}) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 151190cc..2821a807 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -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 = { + 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}`, @@ -42,17 +60,35 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]) parts.push(` ${skill.compatibility}`) } parts.push(" ") - return parts.join("\n") - }).join("\n") - lines.push(`\n\n${skillsXml}\n`) + 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\n${commandLines}\n`) + const parts = [ + " ", + ` /${cmd.name}`, + ` ${cmd.metadata.description || "(no description)"}`, + ` ${cmd.scope}`, + ] + if (hint) { + parts.push(` ${hint.trim()}`) + } + parts.push(" ") + allItems.push(parts.join("\n")) + }) + } + + if (allItems.length > 0) { + lines.push(`\n\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`) } return TOOL_DESCRIPTION_PREFIX + lines.join("")