From 4f4e53b43667d131389d4cc094380ee8e76233c7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 12 Mar 2026 20:03:58 +0900 Subject: [PATCH] feat(skill): re-read skills and commands from disk on every invocation Removes in-memory caching so newly created skills mid-session are immediately available via skill(). Clears the module-level skill cache before each getAllSkills() call. Pre-provided skills from options are merged as fallbacks for test compatibility. --- src/tools/skill/tools.test.ts | 39 +++++++++++++++++++++++++++++++++++ src/tools/skill/tools.ts | 18 +++++++--------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/tools/skill/tools.test.ts b/src/tools/skill/tools.test.ts index e19b72c54..0c30d69d2 100644 --- a/src/tools/skill/tools.test.ts +++ b/src/tools/skill/tools.test.ts @@ -486,3 +486,42 @@ describe("skill tool - ordering and priority", () => { expect(tool.description).toContain("/test-cmd") }) }) + +describe("skill tool - dynamic discovery", () => { + it("discovers skills from disk on every invocation instead of caching", async () => { + // given: tool created with initial skills + const initialSkills = [createMockSkill("initial-skill")] + const tool = createSkillTool({ skills: initialSkills }) + + // when: executing with the initial skill name + const result = await tool.execute({ name: "initial-skill" }, mockContext) + + // then: initial skill found (merged from options.skills since not on disk) + expect(result).toContain("Skill: initial-skill") + }) + + it("merges pre-provided skills with dynamically discovered ones", async () => { + // given: tool with a synthetic skill not on disk + const syntheticSkill = createMockSkill("synthetic-only") + const tool = createSkillTool({ skills: [syntheticSkill] }) + + // when: looking up the synthetic skill + const result = await tool.execute({ name: "synthetic-only" }, mockContext) + + // then: synthetic skill is still accessible via merge + expect(result).toContain("Skill: synthetic-only") + }) + + it("prefers disk-discovered skills over pre-provided ones", async () => { + // given: tool with a pre-provided skill that also exists on disk (builtin) + const overrideSkill = createMockSkill("playwright") + overrideSkill.definition.description = "SHOULD_BE_OVERRIDDEN" + const tool = createSkillTool({ skills: [overrideSkill] }) + + // when: executing with the builtin skill name + const result = await tool.execute({ name: "playwright" }, mockContext) + + // then: disk version wins (not the pre-provided override) + expect(result).not.toContain("SHOULD_BE_OVERRIDDEN") + }) +}) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 6836b9e59..a5bc4b282 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -3,7 +3,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants" import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types" import type { LoadedSkill } from "../../features/opencode-skill-loader" -import { getAllSkills, extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content" +import { getAllSkills, extractSkillTemplate, clearSkillCache } 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" @@ -184,24 +184,22 @@ async function formatMcpCapabilities( } export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition { - let cachedSkills: LoadedSkill[] | null = null - let cachedCommands: CommandInfo[] | null = options.commands ?? null let cachedDescription: string | null = null const getSkills = async (): Promise => { - if (options.skills) return options.skills - if (cachedSkills) return cachedSkills - cachedSkills = await getAllSkills({disabledSkills: options?.disabledSkills}) - return cachedSkills + 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 getCommands = (): CommandInfo[] => { - if (cachedCommands) return cachedCommands - cachedCommands = discoverCommandsSync(undefined, { + return discoverCommandsSync(undefined, { pluginsEnabled: options.pluginsEnabled, enabledPluginsOverride: options.enabledPluginsOverride, }) - return cachedCommands } const buildDescription = async (): Promise => {