fix(#2754): include native PluginInput skills in skill() discovery

The skill tool previously only merged disk-discovered skills and preloaded
options.skills, so skills registered natively via ctx.skills (for example
from config.skills.paths or other plugins) were prompt-visible but not
discoverable by skill().

Fix:
- pass ctx.skills from tool-registry into createSkillTool
- extend SkillLoadOptions with nativeSkills accessor
- merge nativeSkills.all() results into getSkills()
- add test covering native PluginInput skill discovery
This commit is contained in:
YeonGyu-Kim
2026-03-27 17:42:43 +09:00
parent d3dbb4976e
commit 76bf269b39
4 changed files with 64 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import type { ToolDefinition } from "@opencode-ai/plugin"
import type { SkillLoadOptions } from "../tools/skill/types"
import type {
AvailableCategory,
@@ -112,6 +113,7 @@ export function createToolRegistry(args: {
mcpManager: managers.skillMcpManager,
getSessionID: getSessionIDForMcp,
gitMasterConfig: pluginConfig.git_master,
nativeSkills: "skills" in ctx ? (ctx as { skills: SkillLoadOptions["nativeSkills"] }).skills : undefined,
})
// task_system defaults to true since v3.14 — delegation (oracle, subagents) requires it

View File

@@ -581,3 +581,32 @@ describe("skill tool - dynamic description cache invalidation", () => {
})
})
describe("skill tool - nativeSkills integration", () => {
it("merges native skills exposed by PluginInput.skills.all()", async () => {
//#given
const tool = createSkillTool({
skills: [],
nativeSkills: {
async all() {
return [{
name: "external-plugin-skill",
description: "Skill from config.skills.paths",
location: "/external/skills/external-plugin-skill/SKILL.md",
content: "External plugin skill body",
}]
},
async get() { return undefined },
async dirs() { return [] },
},
})
//#when
const result = await tool.execute({ name: "external-plugin-skill" }, mockContext)
//#then
expect(result).toContain("external-plugin-skill")
expect(result).toContain("Test skill body content")
})
})

View File

@@ -190,10 +190,33 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
const getSkills = async (): Promise<LoadedSkill[]> => {
clearSkillCache()
const discovered = await getAllSkills({disabledSkills: options?.disabledSkills})
if (!options.skills) return discovered
const discoveredNames = new Set(discovered.map(s => s.name))
const extras = options.skills.filter(s => !discoveredNames.has(s.name))
return [...discovered, ...extras]
const allSkills = !options.skills
? discovered
: [...discovered, ...options.skills.filter(s => !new Set(discovered.map(d => d.name)).has(s.name))]
if (options.nativeSkills) {
const knownNames = new Set(allSkills.map(s => s.name))
try {
const nativeAll = await options.nativeSkills.all()
for (const native of nativeAll) {
if (knownNames.has(native.name)) continue
allSkills.push({
name: native.name,
path: native.location,
definition: {
name: native.name,
description: native.description,
template: native.content,
},
scope: "config",
})
}
} catch {
// Native skill discovery may not be available
}
}
return allSkills
}
const getCommands = (): CommandInfo[] => {

View File

@@ -37,4 +37,10 @@ export interface SkillLoadOptions {
pluginsEnabled?: boolean
/** Override plugin enablement from Claude settings by plugin key */
enabledPluginsOverride?: Record<string, boolean>
/** Native skill accessor from PluginInput for discovering skills registered via config.skills.paths */
nativeSkills?: {
all(): Promise<{ name: string; description: string; location: string; content: string }[]>
get(name: string): Promise<{ name: string; description: string; location: string; content: string } | undefined>
dirs(): Promise<string[]>
}
}