feat(skill-loader): parse MCP server config from skill frontmatter
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
160
src/features/opencode-skill-loader/loader.test.ts
Normal file
160
src/features/opencode-skill-loader/loader.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||||
|
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||||
|
import { join } from "path"
|
||||||
|
import { tmpdir } from "os"
|
||||||
|
|
||||||
|
const TEST_DIR = join(tmpdir(), "skill-loader-test-" + Date.now())
|
||||||
|
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
|
||||||
|
|
||||||
|
function createTestSkill(name: string, content: string): string {
|
||||||
|
const skillDir = join(SKILLS_DIR, name)
|
||||||
|
mkdirSync(skillDir, { recursive: true })
|
||||||
|
const skillPath = join(skillDir, "SKILL.md")
|
||||||
|
writeFileSync(skillPath, content)
|
||||||
|
return skillDir
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("skill loader MCP parsing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("parseSkillMcpConfig", () => {
|
||||||
|
it("parses skill with nested MCP config", async () => {
|
||||||
|
// #given
|
||||||
|
const skillContent = `---
|
||||||
|
name: test-skill
|
||||||
|
description: A test skill with MCP
|
||||||
|
mcp:
|
||||||
|
sqlite:
|
||||||
|
command: uvx
|
||||||
|
args:
|
||||||
|
- mcp-server-sqlite
|
||||||
|
- --db-path
|
||||||
|
- ./data.db
|
||||||
|
memory:
|
||||||
|
command: npx
|
||||||
|
args: [-y, "@anthropic-ai/mcp-server-memory"]
|
||||||
|
---
|
||||||
|
This is the skill body.
|
||||||
|
`
|
||||||
|
createTestSkill("test-mcp-skill", skillContent)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const { discoverSkills } = await import("./loader")
|
||||||
|
const originalCwd = process.cwd()
|
||||||
|
process.chdir(TEST_DIR)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||||
|
const skill = skills.find(s => s.name === "test-skill")
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(skill).toBeDefined()
|
||||||
|
expect(skill?.mcpConfig).toBeDefined()
|
||||||
|
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||||
|
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||||
|
expect(skill?.mcpConfig?.sqlite?.args).toEqual([
|
||||||
|
"mcp-server-sqlite",
|
||||||
|
"--db-path",
|
||||||
|
"./data.db"
|
||||||
|
])
|
||||||
|
expect(skill?.mcpConfig?.memory).toBeDefined()
|
||||||
|
expect(skill?.mcpConfig?.memory?.command).toBe("npx")
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined mcpConfig for skill without MCP", async () => {
|
||||||
|
// #given
|
||||||
|
const skillContent = `---
|
||||||
|
name: simple-skill
|
||||||
|
description: A simple skill without MCP
|
||||||
|
---
|
||||||
|
This is a simple skill.
|
||||||
|
`
|
||||||
|
createTestSkill("simple-skill", skillContent)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const { discoverSkills } = await import("./loader")
|
||||||
|
const originalCwd = process.cwd()
|
||||||
|
process.chdir(TEST_DIR)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||||
|
const skill = skills.find(s => s.name === "simple-skill")
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(skill).toBeDefined()
|
||||||
|
expect(skill?.mcpConfig).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves env var placeholders without expansion", async () => {
|
||||||
|
// #given
|
||||||
|
const skillContent = `---
|
||||||
|
name: env-skill
|
||||||
|
mcp:
|
||||||
|
api-server:
|
||||||
|
command: node
|
||||||
|
args: [server.js]
|
||||||
|
env:
|
||||||
|
API_KEY: "\${API_KEY}"
|
||||||
|
DB_PATH: "\${HOME}/data.db"
|
||||||
|
---
|
||||||
|
Skill with env vars.
|
||||||
|
`
|
||||||
|
createTestSkill("env-skill", skillContent)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const { discoverSkills } = await import("./loader")
|
||||||
|
const originalCwd = process.cwd()
|
||||||
|
process.chdir(TEST_DIR)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||||
|
const skill = skills.find(s => s.name === "env-skill")
|
||||||
|
|
||||||
|
// #then
|
||||||
|
expect(skill?.mcpConfig?.["api-server"]?.env?.API_KEY).toBe("${API_KEY}")
|
||||||
|
expect(skill?.mcpConfig?.["api-server"]?.env?.DB_PATH).toBe("${HOME}/data.db")
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles malformed YAML gracefully", async () => {
|
||||||
|
// #given
|
||||||
|
const skillContent = `---
|
||||||
|
name: bad-yaml
|
||||||
|
mcp: [this is not valid yaml for mcp
|
||||||
|
---
|
||||||
|
Skill body.
|
||||||
|
`
|
||||||
|
createTestSkill("bad-yaml-skill", skillContent)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const { discoverSkills } = await import("./loader")
|
||||||
|
const originalCwd = process.cwd()
|
||||||
|
process.chdir(TEST_DIR)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||||
|
const skill = skills.find(s => s.name === "bad-yaml")
|
||||||
|
|
||||||
|
// #then - should still load skill but without MCP config
|
||||||
|
expect(skill).toBeDefined()
|
||||||
|
expect(skill?.mcpConfig).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||||
import { join, basename, dirname } from "path"
|
import { join, basename, dirname } from "path"
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
|
import yaml from "js-yaml"
|
||||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||||
import { getClaudeConfigDir } from "../../shared"
|
import { getClaudeConfigDir } from "../../shared"
|
||||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||||
|
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||||
|
|
||||||
|
function parseSkillMcpConfig(content: string): SkillMcpConfig | undefined {
|
||||||
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||||
|
if (!frontmatterMatch) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
|
||||||
|
if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) {
|
||||||
|
return parsed.mcp as SkillMcpConfig
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a skill from a markdown file path.
|
|
||||||
*
|
|
||||||
* @param skillPath - Path to the skill file (SKILL.md or {name}.md)
|
|
||||||
* @param resolvedPath - Directory for file reference resolution (@path references)
|
|
||||||
* @param defaultName - Fallback name if not specified in frontmatter
|
|
||||||
* @param scope - Source scope for priority ordering
|
|
||||||
*/
|
|
||||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||||
if (!allowedTools) return undefined
|
if (!allowedTools) return undefined
|
||||||
return allowedTools.split(/\s+/).filter(Boolean)
|
return allowedTools.split(/\s+/).filter(Boolean)
|
||||||
@@ -30,6 +39,7 @@ function loadSkillFromPath(
|
|||||||
try {
|
try {
|
||||||
const content = readFileSync(skillPath, "utf-8")
|
const content = readFileSync(skillPath, "utf-8")
|
||||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||||
|
const mcpConfig = parseSkillMcpConfig(content)
|
||||||
|
|
||||||
const skillName = data.name || defaultName
|
const skillName = data.name || defaultName
|
||||||
const originalDescription = data.description || ""
|
const originalDescription = data.description || ""
|
||||||
@@ -67,6 +77,7 @@ $ARGUMENTS
|
|||||||
compatibility: data.compatibility,
|
compatibility: data.compatibility,
|
||||||
metadata: data.metadata,
|
metadata: data.metadata,
|
||||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||||
|
mcpConfig,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||||
|
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||||
|
|
||||||
export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export interface SkillMetadata {
|
|||||||
compatibility?: string
|
compatibility?: string
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
"allowed-tools"?: string
|
"allowed-tools"?: string
|
||||||
|
mcp?: SkillMcpConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadedSkill {
|
export interface LoadedSkill {
|
||||||
@@ -25,4 +27,5 @@ export interface LoadedSkill {
|
|||||||
compatibility?: string
|
compatibility?: string
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
allowedTools?: string[]
|
allowedTools?: string[]
|
||||||
|
mcpConfig?: SkillMcpConfig
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user