feat(skills): load config sources in runtime discovery

This commit is contained in:
YeonGyu-Kim
2026-02-13 11:08:22 +09:00
parent aab8a23243
commit 0001bc87c2
11 changed files with 273 additions and 13 deletions

View File

@@ -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)
})
})

View File

@@ -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({
z.object({
sources: z.array(SkillSourceSchema).optional(),
enable: z.array(z.string()).optional(),
disable: z.array(z.string()).optional(),
})
.partial()
),
}).catchall(SkillEntrySchema),
])
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>

View File

@@ -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")
})
})

View File

@@ -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<LoadedSkill[]> {
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<LoadedSkill[]> {
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())
}

View File

@@ -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"

View File

@@ -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")
})
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, unknown>;
pluginConfig: OhMyOpenCodeConfig;
ctx: { directory: string };
pluginComponents: PluginComponents;
}): Promise<void> {
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,

View File

@@ -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,

View File

@@ -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,