Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/features/builtin-skills/loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { loadSkillFromFile } from "./loader"

describe("Skill Loader", () => {
let tempDir: string

beforeEach(() => {
tempDir = mkdtempSync("/tmp/skill-loader-test-")
})

afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})

it("loads skill with frontmatter correctly", () => {
const skillContent = `---
description: Test skill description
metadata:
key: value
mcpConfig:
test-server:
type: stdio
command: test-command
---

# Test Skill

This is the template content.
`

const skillDir = join(tempDir, "test-skill")
const skillPath = join(skillDir, "SKILL.md")
mkdirSync(skillDir, { recursive: true })
writeFileSync(skillPath, skillContent)

const originalDirname = (global as any).__dirname
;(global as any).__dirname = tempDir

try {
const skill = loadSkillFromFile("test-skill", tempDir)

expect(skill).not.toBeNull()
expect(skill?.name).toBe("test-skill")
expect(skill?.description).toBe("Test skill description")
expect(skill?.metadata).toEqual({ key: "value" })
expect(skill?.mcpConfig).toEqual({
"test-server": {
type: "stdio",
command: "test-command"
}
})
expect(skill?.template).toBe("# Test Skill\n\nThis is the template content.")
} finally {
;(global as any).__dirname = originalDirname
}
})

it("loads skill without frontmatter with defaults", () => {
const skillContent = `# Test Skill

This is the template content.
`

const skillDir = join(tempDir, "test-skill")
const skillPath = join(skillDir, "SKILL.md")
mkdirSync(skillDir, { recursive: true })
writeFileSync(skillPath, skillContent)

const originalDirname = (global as any).__dirname
;(global as any).__dirname = tempDir

try {
const skill = loadSkillFromFile("test-skill", tempDir)

expect(skill).not.toBeNull()
expect(skill?.name).toBe("test-skill")
expect(skill?.description).toBe("")
expect(skill?.metadata).toEqual({})
expect(skill?.mcpConfig).toBeUndefined()
expect(skill?.template).toBe("# Test Skill\n\nThis is the template content.")
} finally {
;(global as any).__dirname = originalDirname
}
})

it("returns null for missing skill file", () => {
const originalDirname = (global as any).__dirname
;(global as any).__dirname = tempDir

try {
const skill = loadSkillFromFile("nonexistent-skill", tempDir)
expect(skill).toBeNull()
} finally {
;(global as any).__dirname = originalDirname
}
})

it("falls back to flat markdown when SKILL.md is missing", () => {
const skillContent = `---
description: Flat skill
---

# Flat Skill

Flat template content.
`

const skillPath = join(tempDir, "flat-skill.md")
writeFileSync(skillPath, skillContent)

const skill = loadSkillFromFile("flat-skill", tempDir)
expect(skill).not.toBeNull()
expect(skill?.description).toBe("Flat skill")
expect(skill?.template).toBe("# Flat Skill\n\nFlat template content.")
})

it("preserves template content exactly", () => {
const templateContent = `# Skill Template

Some content with **markdown** formatting.

\`\`\`typescript
const code = "preserved";
\`\`\`

End of template.
`

const skillContent = `---
description: Test
---

${templateContent}`

const skillDir = join(tempDir, "test-skill")
const skillPath = join(skillDir, "SKILL.md")
mkdirSync(skillDir, { recursive: true })
writeFileSync(skillPath, skillContent)

const originalDirname = (global as any).__dirname
;(global as any).__dirname = tempDir

try {
const skill = loadSkillFromFile("test-skill", tempDir)
expect(skill?.template).toBe(templateContent.trim())
} finally {
;(global as any).__dirname = originalDirname
}
})
})
63 changes: 63 additions & 0 deletions src/features/builtin-skills/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { parseFrontmatter } from "../../shared/frontmatter"
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
import type { BuiltinSkill } from "./types"

const SKILL_FILE_NAME = "SKILL.md"

/**
* Resolves builtin skill file path using SKILL.md-first convention.
*/
function resolveSkillPath(name: string, baseDir: string): string | null {
const skillDirPath = join(baseDir, name)
const skillMdPath = join(skillDirPath, SKILL_FILE_NAME)
if (existsSync(skillMdPath)) {
return skillMdPath
}

const flatMdPath = join(baseDir, `${name}.md`)
if (existsSync(flatMdPath)) {
return flatMdPath
}

return null
}

/**
* Loads a skill from a markdown file.
* Handles missing files gracefully by returning null.
*/
export function loadSkillFromFile(name: string, baseDir: string = __dirname): BuiltinSkill | null {
const filePath = resolveSkillPath(name, baseDir)

if (!filePath) {
return null
}

const content = readFileSync(filePath, "utf-8")
const { data: attributes, body } = parseFrontmatter(content)

const description = typeof attributes.description === "string" ? attributes.description : ""
const metadata = (attributes.metadata as Record<string, unknown>) ?? {}
const mcpConfig = attributes.mcpConfig as SkillMcpConfig | undefined

return {
name,
description,
template: body.trim(),
metadata,
mcpConfig,
}
}

/**
* Creates builtin skills array.
*/
export function createBuiltinSkills(): BuiltinSkill[] {
const playwright = loadSkillFromFile("playwright")
const frontend = loadSkillFromFile("frontend-ui-ux")
const gitMaster = loadSkillFromFile("git-master")

return [playwright, frontend, gitMaster].filter((skill): skill is BuiltinSkill => skill !== null)
}
39 changes: 39 additions & 0 deletions src/features/builtin-skills/playwright/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
name: playwright
description: Browser automation with Playwright MCP for web scraping, testing, and screenshots
---

# Playwright Browser Automation

This skill provides browser automation capabilities via the Playwright MCP server.

## Usage

Trigger via `/playwright` skill or `skill: playwright` tool.

## MCP Configuration

The Playwright MCP server is configured in your MCP settings (typically `~/.claude/.mcp.json` or `~/.config/claude/.mcp.json`).

### Common Playwright Commands

```bash
# Navigate to a URL
npx @playwright/mcp@latest goto https://example.com

# Click an element
npx @playwright/mcp@latest click selector="button#submit"

# Get page text
npx @playwright/mcp@latest page_content

# Take screenshot
npx @playwright/mcp@latest screenshot path=~/screenshot.png
```

### Selector Examples

- CSS selector: `selector="css=.my-class"`
- Text selector: `selector="text=Submit"`
- XPath: `selector="xpath=//button[@type='submit']"`
- Test ID: `selector="testid=my-button"`
Loading