Merge pull request #1807 from code-yeongyu/fix/skills-sources-schema
fix schema generation and implement skills.sources runtime loading
This commit is contained in:
File diff suppressed because it is too large
Load Diff
17
script/build-schema-document.ts
Normal file
17
script/build-schema-document.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as z from "zod"
|
||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||
|
||||
export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
|
||||
const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, {
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
})
|
||||
|
||||
return {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
title: "Oh My OpenCode Configuration",
|
||||
description: "Configuration schema for oh-my-opencode plugin",
|
||||
...jsonSchema,
|
||||
}
|
||||
}
|
||||
18
script/build-schema.test.ts
Normal file
18
script/build-schema.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createOhMyOpenCodeJsonSchema } from "./build-schema-document"
|
||||
|
||||
describe("build-schema-document", () => {
|
||||
test("generates schema with skills property", () => {
|
||||
// given
|
||||
const expectedDraft = "http://json-schema.org/draft-07/schema#"
|
||||
|
||||
// when
|
||||
const schema = createOhMyOpenCodeJsonSchema()
|
||||
|
||||
// then
|
||||
expect(schema.$schema).toBe(expectedDraft)
|
||||
expect(schema.title).toBe("Oh My OpenCode Configuration")
|
||||
expect(schema.properties).toBeDefined()
|
||||
expect(schema.properties.skills).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,12 @@
|
||||
#!/usr/bin/env bun
|
||||
import * as z from "zod"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||
import { createOhMyOpenCodeJsonSchema } from "./build-schema-document"
|
||||
|
||||
const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
|
||||
|
||||
async function main() {
|
||||
console.log("Generating JSON Schema...")
|
||||
|
||||
const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema, {
|
||||
target: "draft7",
|
||||
})
|
||||
|
||||
const finalSchema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
title: "Oh My OpenCode Configuration",
|
||||
description: "Configuration schema for oh-my-opencode plugin",
|
||||
...jsonSchema,
|
||||
}
|
||||
const finalSchema = createOhMyOpenCodeJsonSchema()
|
||||
|
||||
await Bun.write(SCHEMA_OUTPUT_PATH, JSON.stringify(finalSchema, null, 2))
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof SkillsConfigSchema>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { SkillsConfigSchema } from "../../config/schema/skills"
|
||||
import { discoverConfigSourceSkills, normalizePathForGlob } 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")
|
||||
const config = SkillsConfigSchema.parse({
|
||||
sources: [{ path: "./custom-skills", recursive: true }],
|
||||
})
|
||||
|
||||
// when
|
||||
const skills = await discoverConfigSourceSkills({
|
||||
config,
|
||||
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")
|
||||
const config = SkillsConfigSchema.parse({
|
||||
sources: [{ path: "./custom-skills", recursive: true, glob: "keep/**" }],
|
||||
})
|
||||
|
||||
// when
|
||||
const skills = await discoverConfigSourceSkills({
|
||||
config,
|
||||
configDir,
|
||||
})
|
||||
|
||||
// then
|
||||
const names = skills.map((skill) => skill.name)
|
||||
expect(names).toContain("keep/kept-skill")
|
||||
expect(names).not.toContain("skip/skipped-skill")
|
||||
})
|
||||
|
||||
it("normalizes windows separators before glob matching", () => {
|
||||
// given
|
||||
const windowsPath = "keep\\nested\\SKILL.md"
|
||||
|
||||
// when
|
||||
const normalized = normalizePathForGlob(windowsPath)
|
||||
|
||||
// then
|
||||
expect(normalized).toBe("keep/nested/SKILL.md")
|
||||
})
|
||||
})
|
||||
105
src/features/opencode-skill-loader/config-source-discovery.ts
Normal file
105
src/features/opencode-skill-loader/config-source-discovery.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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"
|
||||
}
|
||||
|
||||
export function normalizePathForGlob(path: string): string {
|
||||
return path.split("\\").join("/")
|
||||
}
|
||||
|
||||
function filterByGlob(skills: LoadedSkill[], sourceBaseDir: string, globPattern?: string): LoadedSkill[] {
|
||||
if (!globPattern) return skills
|
||||
|
||||
return skills.filter((skill) => {
|
||||
if (!skill.path) return false
|
||||
const rel = normalizePathForGlob(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())
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
55
src/features/opencode-skill-loader/merger.test.ts
Normal file
55
src/features/opencode-skill-loader/merger.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user