diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts new file mode 100644 index 0000000..241d730 --- /dev/null +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -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) + } + }) + }) +}) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 5f48898..206e6d6 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -1,21 +1,30 @@ import { existsSync, readdirSync, readFileSync } from "fs" import { join, basename, dirname } from "path" import { homedir } from "os" +import yaml from "js-yaml" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import type { CommandDefinition } from "../claude-code-command-loader/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 + 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 { if (!allowedTools) return undefined return allowedTools.split(/\s+/).filter(Boolean) @@ -30,6 +39,7 @@ function loadSkillFromPath( try { const content = readFileSync(skillPath, "utf-8") const { data, body } = parseFrontmatter(content) + const mcpConfig = parseSkillMcpConfig(content) const skillName = data.name || defaultName const originalDescription = data.description || "" @@ -67,6 +77,7 @@ $ARGUMENTS compatibility: data.compatibility, metadata: data.metadata, allowedTools: parseAllowedTools(data["allowed-tools"]), + mcpConfig, } } catch { return null diff --git a/src/features/opencode-skill-loader/types.ts b/src/features/opencode-skill-loader/types.ts index f956922..fe74dfa 100644 --- a/src/features/opencode-skill-loader/types.ts +++ b/src/features/opencode-skill-loader/types.ts @@ -1,4 +1,5 @@ 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" @@ -13,6 +14,7 @@ export interface SkillMetadata { compatibility?: string metadata?: Record "allowed-tools"?: string + mcp?: SkillMcpConfig } export interface LoadedSkill { @@ -25,4 +27,5 @@ export interface LoadedSkill { compatibility?: string metadata?: Record allowedTools?: string[] + mcpConfig?: SkillMcpConfig }