From 0001bc87c2f05746cba46890ca93e9fd18d837ca Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 11:08:22 +0900 Subject: [PATCH] feat(skills): load config sources in runtime discovery --- src/config/schema.test.ts | 17 +++ src/config/schema/skills.ts | 16 +-- .../config-source-discovery.test.ts | 68 ++++++++++++ .../config-source-discovery.ts | 101 ++++++++++++++++++ src/features/opencode-skill-loader/index.ts | 1 + .../opencode-skill-loader/merger.test.ts | 55 ++++++++++ src/features/opencode-skill-loader/merger.ts | 2 + src/plugin-handlers/agent-config-handler.ts | 7 ++ src/plugin-handlers/command-config-handler.ts | 9 ++ src/plugin-handlers/config-handler.ts | 2 +- src/plugin/skill-context.ts | 8 +- 11 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 src/features/opencode-skill-loader/config-source-discovery.test.ts create mode 100644 src/features/opencode-skill-loader/config-source-discovery.ts create mode 100644 src/features/opencode-skill-loader/merger.test.ts diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 704a7ea81..2d151ec53 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -733,3 +733,20 @@ describe("GitMasterConfigSchema", () => { expect(result.success).toBe(false) }) }) + +describe("skills schema", () => { + test("accepts skills.sources configuration", () => { + //#given + const config = { + skills: { + sources: [{ path: "skill/", recursive: true }], + }, + } + + //#when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) +}) diff --git a/src/config/schema/skills.ts b/src/config/schema/skills.ts index 0e7fbaa8a..07afd1fd2 100644 --- a/src/config/schema/skills.ts +++ b/src/config/schema/skills.ts @@ -28,17 +28,11 @@ export const SkillEntrySchema = z.union([z.boolean(), SkillDefinitionSchema]) export const SkillsConfigSchema = z.union([ z.array(z.string()), - z - .record(z.string(), SkillEntrySchema) - .and( - z - .object({ - sources: z.array(SkillSourceSchema).optional(), - enable: z.array(z.string()).optional(), - disable: z.array(z.string()).optional(), - }) - .partial() - ), + z.object({ + sources: z.array(SkillSourceSchema).optional(), + enable: z.array(z.string()).optional(), + disable: z.array(z.string()).optional(), + }).catchall(SkillEntrySchema), ]) export type SkillsConfig = z.infer diff --git a/src/features/opencode-skill-loader/config-source-discovery.test.ts b/src/features/opencode-skill-loader/config-source-discovery.test.ts new file mode 100644 index 000000000..98a1eb806 --- /dev/null +++ b/src/features/opencode-skill-loader/config-source-discovery.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, rmSync, writeFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" +import { discoverConfigSourceSkills } from "./config-source-discovery" + +const TEST_DIR = join(tmpdir(), `config-source-discovery-test-${Date.now()}`) + +function writeSkill(path: string, name: string, description: string): void { + mkdirSync(path, { recursive: true }) + writeFileSync( + join(path, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\nBody\n`, + ) +} + +describe("config source discovery", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) + }) + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) + }) + + it("loads skills from local sources path", async () => { + // given + const configDir = join(TEST_DIR, "config") + const sourceDir = join(configDir, "custom-skills") + writeSkill(join(sourceDir, "local-skill"), "local-skill", "Loaded from local source") + + // when + const skills = await discoverConfigSourceSkills({ + config: { + sources: [{ path: "./custom-skills", recursive: true }], + }, + configDir, + }) + + // then + const localSkill = skills.find((skill) => skill.name === "local-skill") + expect(localSkill).toBeDefined() + expect(localSkill?.scope).toBe("config") + expect(localSkill?.definition.description).toContain("Loaded from local source") + }) + + it("filters discovered skills using source glob", async () => { + // given + const configDir = join(TEST_DIR, "config") + const sourceDir = join(configDir, "custom-skills") + + writeSkill(join(sourceDir, "keep", "kept"), "kept-skill", "Should be kept") + writeSkill(join(sourceDir, "skip", "skipped"), "skipped-skill", "Should be skipped") + + // when + const skills = await discoverConfigSourceSkills({ + config: { + sources: [{ path: "./custom-skills", recursive: true, glob: "keep/**" }], + }, + configDir, + }) + + // then + const names = skills.map((skill) => skill.name) + expect(names).toContain("keep/kept-skill") + expect(names).not.toContain("skip/skipped-skill") + }) +}) diff --git a/src/features/opencode-skill-loader/config-source-discovery.ts b/src/features/opencode-skill-loader/config-source-discovery.ts new file mode 100644 index 000000000..198bce852 --- /dev/null +++ b/src/features/opencode-skill-loader/config-source-discovery.ts @@ -0,0 +1,101 @@ +import { promises as fs } from "fs" +import { dirname, extname, isAbsolute, join, relative } from "path" +import picomatch from "picomatch" +import type { SkillsConfig } from "../../config/schema" +import { normalizeSkillsConfig } from "./merger/skills-config-normalizer" +import { deduplicateSkillsByName } from "./skill-deduplication" +import { loadSkillsFromDir } from "./skill-directory-loader" +import { inferSkillNameFromFileName, loadSkillFromPath } from "./loaded-skill-from-path" +import type { LoadedSkill } from "./types" + +const MAX_RECURSIVE_DEPTH = 10 + +function isHttpUrl(path: string): boolean { + return path.startsWith("http://") || path.startsWith("https://") +} + +function toAbsolutePath(path: string, configDir: string): string { + if (isAbsolute(path)) { + return path + } + return join(configDir, path) +} + +function isMarkdownPath(path: string): boolean { + return extname(path).toLowerCase() === ".md" +} + +function filterByGlob(skills: LoadedSkill[], sourceBaseDir: string, globPattern?: string): LoadedSkill[] { + if (!globPattern) return skills + + return skills.filter((skill) => { + if (!skill.path) return false + const rel = relative(sourceBaseDir, skill.path) + return picomatch.isMatch(rel, globPattern, { dot: true, bash: true }) + }) +} + +async function loadSourcePath(options: { + sourcePath: string + recursive: boolean + globPattern?: string + configDir: string +}): Promise { + if (isHttpUrl(options.sourcePath)) { + return [] + } + + const absolutePath = toAbsolutePath(options.sourcePath, options.configDir) + const stat = await fs.stat(absolutePath).catch(() => null) + if (!stat) return [] + + if (stat.isFile()) { + if (!isMarkdownPath(absolutePath)) return [] + const loaded = await loadSkillFromPath({ + skillPath: absolutePath, + resolvedPath: dirname(absolutePath), + defaultName: inferSkillNameFromFileName(absolutePath), + scope: "config", + }) + if (!loaded) return [] + return filterByGlob([loaded], dirname(absolutePath), options.globPattern) + } + + if (!stat.isDirectory()) return [] + + const directorySkills = await loadSkillsFromDir({ + skillsDir: absolutePath, + scope: "config", + maxDepth: options.recursive ? MAX_RECURSIVE_DEPTH : 0, + }) + return filterByGlob(directorySkills, absolutePath, options.globPattern) +} + +export async function discoverConfigSourceSkills(options: { + config: SkillsConfig | undefined + configDir: string +}): Promise { + const normalized = normalizeSkillsConfig(options.config) + if (normalized.sources.length === 0) return [] + + const loadedBySource = await Promise.all( + normalized.sources.map((source) => { + if (typeof source === "string") { + return loadSourcePath({ + sourcePath: source, + recursive: false, + configDir: options.configDir, + }) + } + + return loadSourcePath({ + sourcePath: source.path, + recursive: source.recursive ?? false, + globPattern: source.glob, + configDir: options.configDir, + }) + }), + ) + + return deduplicateSkillsByName(loadedBySource.flat()) +} diff --git a/src/features/opencode-skill-loader/index.ts b/src/features/opencode-skill-loader/index.ts index 68c556245..94ce077f3 100644 --- a/src/features/opencode-skill-loader/index.ts +++ b/src/features/opencode-skill-loader/index.ts @@ -14,3 +14,4 @@ export * from "./skill-discovery" export * from "./skill-resolution-options" export * from "./loaded-skill-template-extractor" export * from "./skill-template-resolver" +export * from "./config-source-discovery" diff --git a/src/features/opencode-skill-loader/merger.test.ts b/src/features/opencode-skill-loader/merger.test.ts new file mode 100644 index 000000000..0b5a3f649 --- /dev/null +++ b/src/features/opencode-skill-loader/merger.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test" +import type { BuiltinSkill } from "../builtin-skills/types" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import { mergeSkills } from "./merger" +import type { LoadedSkill, SkillScope } from "./types" + +function createLoadedSkill(scope: SkillScope, name: string, description: string): LoadedSkill { + const definition: CommandDefinition = { + name, + description, + template: "template", + } + + return { + name, + definition, + scope, + } +} + +describe("mergeSkills", () => { + it("gives higher scopes priority over config source skills", () => { + // given + const builtinSkills: BuiltinSkill[] = [ + { + name: "priority-skill", + description: "builtin", + template: "builtin-template", + }, + ] + + const configSourceSkills: LoadedSkill[] = [ + createLoadedSkill("config", "priority-skill", "config source"), + ] + const userSkills: LoadedSkill[] = [ + createLoadedSkill("user", "priority-skill", "user skill"), + ] + + // when + const merged = mergeSkills( + builtinSkills, + undefined, + configSourceSkills, + userSkills, + [], + [], + [], + ) + + // then + expect(merged).toHaveLength(1) + expect(merged[0]?.scope).toBe("user") + expect(merged[0]?.definition.description).toBe("user skill") + }) +}) diff --git a/src/features/opencode-skill-loader/merger.ts b/src/features/opencode-skill-loader/merger.ts index c5598ca5d..04ff351c5 100644 --- a/src/features/opencode-skill-loader/merger.ts +++ b/src/features/opencode-skill-loader/merger.ts @@ -14,6 +14,7 @@ export interface MergeSkillsOptions { export function mergeSkills( builtinSkills: BuiltinSkill[], config: SkillsConfig | undefined, + configSourceSkills: LoadedSkill[], userClaudeSkills: LoadedSkill[], userOpencodeSkills: LoadedSkill[], projectClaudeSkills: LoadedSkill[], @@ -47,6 +48,7 @@ export function mergeSkills( } const fileSystemSkills = [ + ...configSourceSkills, ...userClaudeSkills, ...userOpencodeSkills, ...projectClaudeSkills, diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 240cdab52..d08094551 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { log, migrateAgentConfig } from "../shared"; import { AGENT_NAME_MAP } from "../shared/migration"; import { + discoverConfigSourceSkills, discoverOpencodeGlobalSkills, discoverOpencodeProjectSkills, discoverProjectClaudeSkills, @@ -34,11 +35,16 @@ export async function applyAgentConfig(params: { const includeClaudeSkillsForAwareness = params.pluginConfig.claude_code?.skills ?? true; const [ + discoveredConfigSourceSkills, discoveredUserSkills, discoveredProjectSkills, discoveredOpencodeGlobalSkills, discoveredOpencodeProjectSkills, ] = await Promise.all([ + discoverConfigSourceSkills({ + config: params.pluginConfig.skills, + configDir: params.ctx.directory, + }), includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]), includeClaudeSkillsForAwareness ? discoverProjectClaudeSkills() @@ -48,6 +54,7 @@ export async function applyAgentConfig(params: { ]); const allDiscoveredSkills = [ + ...discoveredConfigSourceSkills, ...discoveredOpencodeProjectSkills, ...discoveredProjectSkills, ...discoveredOpencodeGlobalSkills, diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 1e2ca9402..983c25a04 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -7,16 +7,19 @@ import { } from "../features/claude-code-command-loader"; import { loadBuiltinCommands } from "../features/builtin-commands"; import { + discoverConfigSourceSkills, loadUserSkills, loadProjectSkills, loadOpencodeGlobalSkills, loadOpencodeProjectSkills, + skillsToCommandDefinitionRecord, } from "../features/opencode-skill-loader"; import type { PluginComponents } from "./plugin-components-loader"; export async function applyCommandConfig(params: { config: Record; pluginConfig: OhMyOpenCodeConfig; + ctx: { directory: string }; pluginComponents: PluginComponents; }): Promise { const builtinCommands = loadBuiltinCommands(params.pluginConfig.disabled_commands); @@ -26,6 +29,7 @@ export async function applyCommandConfig(params: { const includeClaudeSkills = params.pluginConfig.claude_code?.skills ?? true; const [ + configSourceSkills, userCommands, projectCommands, opencodeGlobalCommands, @@ -35,6 +39,10 @@ export async function applyCommandConfig(params: { opencodeGlobalSkills, opencodeProjectSkills, ] = await Promise.all([ + discoverConfigSourceSkills({ + config: params.pluginConfig.skills, + configDir: params.ctx.directory, + }), includeClaudeCommands ? loadUserCommands() : Promise.resolve({}), includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}), loadOpencodeGlobalCommands(), @@ -47,6 +55,7 @@ export async function applyCommandConfig(params: { params.config.command = { ...builtinCommands, + ...skillsToCommandDefinitionRecord(configSourceSkills), ...userCommands, ...userSkills, ...opencodeGlobalCommands, diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 5eb7f242b..e9b814a4f 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -33,7 +33,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { applyToolConfig({ config, pluginConfig, agentResult }); await applyMcpConfig({ config, pluginConfig, pluginComponents }); - await applyCommandConfig({ config, pluginConfig, pluginComponents }); + await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents }); log("[config-handler] config handler applied", { agentCount: Object.keys(agentResult).length, diff --git a/src/plugin/skill-context.ts b/src/plugin/skill-context.ts index 634cbc594..630cc2085 100644 --- a/src/plugin/skill-context.ts +++ b/src/plugin/skill-context.ts @@ -7,6 +7,7 @@ import type { } from "../features/opencode-skill-loader/types" import { + discoverConfigSourceSkills, discoverUserClaudeSkills, discoverProjectClaudeSkills, discoverOpencodeGlobalSkills, @@ -54,8 +55,12 @@ export async function createSkillContext(args: { }) const includeClaudeSkills = pluginConfig.claude_code?.skills !== false - const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = + const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([ + discoverConfigSourceSkills({ + config: pluginConfig.skills, + configDir: directory, + }), includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), discoverOpencodeGlobalSkills(), includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), @@ -65,6 +70,7 @@ export async function createSkillContext(args: { const mergedSkills = mergeSkills( builtinSkills, pluginConfig.skills, + configSourceSkills, userSkills, globalSkills, projectSkills,