Skip to content

Commit 4bac76d

Browse files
authored
refactor(skills): extract skill loading into dedicated module (#34)
- Add skill-loader.ts module centralizing skill loading logic - Prefix skill command names with 'systematic:' for namespacing - Prefix skill descriptions with '(systematic - Skill)' for clarity - Wrap skill templates in <skill-instruction> tags with base directory - Extract body from wrapped template when displaying via tool - Fix double-prefix bug in formatSkillsXml integration - Refactor config-handler to use new loadSkill function - Refactor skill-tool to use centralized loading utilities - Add comprehensive unit tests for skill-loader module - Add integration tests for config handler skill registration - Update existing tests to verify new prefixing behavior
1 parent a892e8e commit 4bac76d

7 files changed

Lines changed: 527 additions & 64 deletions

File tree

src/lib/config-handler.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { extractCommandFrontmatter, findCommandsInDir } from './commands.js'
44
import { loadConfig } from './config.js'
55
import { convertFileWithCache } from './converter.js'
66
import { stripFrontmatter } from './frontmatter.js'
7-
import { findSkillsInDir, type SkillInfo } from './skills.js'
7+
import { type LoadedSkill, loadSkill } from './skill-loader.js'
8+
import { findSkillsInDir } from './skills.js'
89

910
export interface ConfigHandlerDeps {
1011
directory: string
@@ -58,18 +59,10 @@ function loadCommandAsConfig(commandInfo: {
5859
}
5960
}
6061

61-
function loadSkillAsCommand(skillInfo: SkillInfo): CommandConfig | null {
62-
try {
63-
const converted = convertFileWithCache(skillInfo.skillFile, 'skill', {
64-
source: 'bundled',
65-
})
66-
67-
return {
68-
template: stripFrontmatter(converted),
69-
description: skillInfo.description || `${skillInfo.name} skill`,
70-
}
71-
} catch {
72-
return null
62+
function loadSkillAsCommand(loaded: LoadedSkill): CommandConfig {
63+
return {
64+
template: loaded.wrappedTemplate,
65+
description: loaded.description,
7366
}
7467
}
7568

@@ -122,9 +115,9 @@ function collectSkillsAsCommands(
122115
for (const skillInfo of skillList) {
123116
if (disabledSkills.includes(skillInfo.name)) continue
124117

125-
const config = loadSkillAsCommand(skillInfo)
126-
if (config) {
127-
commands[skillInfo.name] = config
118+
const loaded = loadSkill(skillInfo)
119+
if (loaded) {
120+
commands[loaded.prefixedName] = loadSkillAsCommand(loaded)
128121
}
129122
}
130123

src/lib/skill-loader.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import path from 'node:path'
2+
import { convertFileWithCache } from './converter.js'
3+
import { stripFrontmatter } from './frontmatter.js'
4+
import type { SkillInfo } from './skills.js'
5+
6+
const SKILL_PREFIX = 'systematic:'
7+
const SKILL_DESCRIPTION_PREFIX = '(systematic - Skill) '
8+
9+
export interface LoadedSkill {
10+
name: string
11+
prefixedName: string
12+
description: string
13+
path: string
14+
skillFile: string
15+
wrappedTemplate: string
16+
}
17+
18+
export function formatSkillCommandName(name: string): string {
19+
if (name.startsWith(SKILL_PREFIX)) {
20+
return name
21+
}
22+
return `${SKILL_PREFIX}${name}`
23+
}
24+
25+
export function formatSkillDescription(
26+
description: string,
27+
fallbackName: string,
28+
): string {
29+
const desc = description || `${fallbackName} skill`
30+
if (desc.startsWith(SKILL_DESCRIPTION_PREFIX)) {
31+
return desc
32+
}
33+
return `${SKILL_DESCRIPTION_PREFIX}${desc}`
34+
}
35+
36+
export function wrapSkillTemplate(skillPath: string, body: string): string {
37+
const skillDir = path.dirname(skillPath)
38+
return `<skill-instruction>
39+
Base directory for this skill: ${skillDir}/
40+
File references (@path) in this skill are relative to this directory.
41+
42+
${body.trim()}
43+
</skill-instruction>`
44+
}
45+
46+
export function extractSkillBody(wrappedTemplate: string): string {
47+
const match = wrappedTemplate.match(
48+
/<skill-instruction>([\s\S]*?)<\/skill-instruction>/,
49+
)
50+
return match ? match[1].trim() : wrappedTemplate
51+
}
52+
53+
export function loadSkill(skillInfo: SkillInfo): LoadedSkill | null {
54+
try {
55+
const converted = convertFileWithCache(skillInfo.skillFile, 'skill', {
56+
source: 'bundled',
57+
})
58+
const body = stripFrontmatter(converted)
59+
const wrappedTemplate = wrapSkillTemplate(skillInfo.skillFile, body)
60+
61+
return {
62+
name: skillInfo.name,
63+
prefixedName: formatSkillCommandName(skillInfo.name),
64+
description: formatSkillDescription(
65+
skillInfo.description,
66+
skillInfo.name,
67+
),
68+
path: skillInfo.path,
69+
skillFile: skillInfo.skillFile,
70+
wrappedTemplate,
71+
}
72+
} catch {
73+
return null
74+
}
75+
}

src/lib/skill-tool.ts

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,13 @@
1-
import fs from 'node:fs'
21
import path from 'node:path'
32
import type { ToolDefinition } from '@opencode-ai/plugin'
43
import { tool } from '@opencode-ai/plugin/tool'
5-
import { convertContent } from './converter.js'
6-
import { stripFrontmatter } from './frontmatter.js'
7-
import type { SkillInfo } from './skills.js'
4+
import {
5+
extractSkillBody,
6+
type LoadedSkill,
7+
loadSkill,
8+
} from './skill-loader.js'
89
import { findSkillsInDir, formatSkillsXml } from './skills.js'
910

10-
function wrapSkillContent(skillPath: string, content: string): string {
11-
const skillDir = path.dirname(skillPath)
12-
const converted = convertContent(content, 'skill', { source: 'bundled' })
13-
const body = stripFrontmatter(converted)
14-
15-
return `<skill-instruction>
16-
Base directory for this skill: ${skillDir}/
17-
File references (@path) in this skill are relative to this directory.
18-
19-
${body.trim()}
20-
</skill-instruction>`
21-
}
22-
2311
export interface SkillToolOptions {
2412
bundledSkillsDir: string
2513
disabledSkills: string[]
@@ -28,15 +16,23 @@ export interface SkillToolOptions {
2816
export function createSkillTool(options: SkillToolOptions): ToolDefinition {
2917
const { bundledSkillsDir, disabledSkills } = options
3018

31-
const getSystematicSkills = (): SkillInfo[] => {
19+
const getSystematicSkills = (): LoadedSkill[] => {
3220
return findSkillsInDir(bundledSkillsDir)
3321
.filter((s) => !disabledSkills.includes(s.name))
22+
.map((skillInfo) => loadSkill(skillInfo))
23+
.filter((s): s is LoadedSkill => s !== null)
3424
.sort((a, b) => a.name.localeCompare(b.name))
3525
}
3626

3727
const buildDescription = (): string => {
3828
const skills = getSystematicSkills()
39-
const systematicXml = formatSkillsXml(skills)
29+
const skillInfos = skills.map((s) => ({
30+
name: s.name,
31+
description: s.description,
32+
path: s.path,
33+
skillFile: s.skillFile,
34+
}))
35+
const systematicXml = formatSkillsXml(skillInfos)
4036

4137
const baseDescription = `Load a skill to get detailed instructions for a specific task.
4238
@@ -73,25 +69,17 @@ Use this when a task matches an available skill's description.`
7369
const matchedSkill = skills.find((s) => s.name === normalizedName)
7470

7571
if (matchedSkill) {
76-
try {
77-
const content = fs.readFileSync(matchedSkill.skillFile, 'utf8')
78-
const wrapped = wrapSkillContent(matchedSkill.skillFile, content)
79-
80-
return `## Skill: systematic:${matchedSkill.name}
81-
82-
**Base directory**: ${matchedSkill.path}
83-
84-
${wrapped}`
85-
} catch (error) {
86-
const errorMessage =
87-
error instanceof Error ? error.message : String(error)
88-
throw new Error(
89-
`Failed to load skill "${requestedName}": ${errorMessage}`,
90-
)
91-
}
72+
const body = extractSkillBody(matchedSkill.wrappedTemplate)
73+
const dir = path.dirname(matchedSkill.skillFile)
74+
75+
return `## Skill: ${matchedSkill.prefixedName}
76+
77+
**Base directory**: ${dir}
78+
79+
${body}`
9280
}
9381

94-
const availableSystematic = skills.map((s) => `systematic:${s.name}`)
82+
const availableSystematic = skills.map((s) => s.prefixedName)
9583
throw new Error(
9684
`Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}`,
9785
)

tests/integration/opencode.test.ts

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
22
import fs from 'node:fs'
33
import os from 'node:os'
44
import path from 'node:path'
5+
import type { Config } from '@opencode-ai/sdk'
6+
import { createConfigHandler } from '../../src/lib/config-handler.ts'
57

68
const OPENCODE_AVAILABLE = (() => {
79
const result = Bun.spawnSync(['which', 'opencode'])
@@ -118,14 +120,126 @@ describe.skipIf(!OPENCODE_AVAILABLE)('opencode integration', () => {
118120
'What skills are available? List the systematic skills you can load.',
119121
)
120122

121-
expect(result.stdout).toMatch(
122-
/systematic:brainstorming|systematic:.*|available.*skills/i,
123-
)
123+
expect(result.stdout).toMatch(/brainstorming|systematic.*skills/i)
124124
},
125125
TIMEOUT_MS * MAX_RETRIES,
126126
)
127127
})
128128

129+
describe('config handler integration', () => {
130+
let testEnv: {
131+
tempDir: string
132+
bundledDir: string
133+
projectDir: string
134+
}
135+
136+
beforeEach(() => {
137+
const tempBase = fs.mkdtempSync(
138+
path.join(os.tmpdir(), 'systematic-config-integration-'),
139+
)
140+
141+
testEnv = {
142+
tempDir: tempBase,
143+
bundledDir: path.join(tempBase, 'bundled'),
144+
projectDir: path.join(tempBase, 'project'),
145+
}
146+
147+
fs.mkdirSync(path.join(testEnv.bundledDir, 'skills', 'test-skill'), {
148+
recursive: true,
149+
})
150+
fs.mkdirSync(path.join(testEnv.bundledDir, 'agents'), { recursive: true })
151+
fs.mkdirSync(path.join(testEnv.bundledDir, 'commands'), { recursive: true })
152+
fs.mkdirSync(testEnv.projectDir, { recursive: true })
153+
154+
fs.writeFileSync(
155+
path.join(testEnv.bundledDir, 'skills', 'test-skill', 'SKILL.md'),
156+
`---
157+
name: test-skill
158+
description: A skill for integration testing
159+
---
160+
# Test Skill
161+
162+
Integration test content.`,
163+
)
164+
})
165+
166+
afterEach(() => {
167+
if (testEnv.tempDir) {
168+
fs.rmSync(testEnv.tempDir, { recursive: true, force: true })
169+
}
170+
})
171+
172+
test('registers skills with systematic: prefix in command name', async () => {
173+
const handler = createConfigHandler({
174+
directory: testEnv.projectDir,
175+
bundledSkillsDir: path.join(testEnv.bundledDir, 'skills'),
176+
bundledAgentsDir: path.join(testEnv.bundledDir, 'agents'),
177+
bundledCommandsDir: path.join(testEnv.bundledDir, 'commands'),
178+
})
179+
180+
const config: Config = {}
181+
await handler(config)
182+
183+
const commandNames = Object.keys(config.command || {})
184+
expect(commandNames).toContain('systematic:test-skill')
185+
expect(commandNames).not.toContain('test-skill')
186+
})
187+
188+
test('adds (systematic - Skill) prefix to skill descriptions', async () => {
189+
const handler = createConfigHandler({
190+
directory: testEnv.projectDir,
191+
bundledSkillsDir: path.join(testEnv.bundledDir, 'skills'),
192+
bundledAgentsDir: path.join(testEnv.bundledDir, 'agents'),
193+
bundledCommandsDir: path.join(testEnv.bundledDir, 'commands'),
194+
})
195+
196+
const config: Config = {}
197+
await handler(config)
198+
199+
const skillCommand = config.command?.['systematic:test-skill']
200+
expect(skillCommand?.description).toMatch(/^\(systematic - Skill\) /)
201+
expect(skillCommand?.description).toBe(
202+
'(systematic - Skill) A skill for integration testing',
203+
)
204+
})
205+
206+
test('wraps skill template in <skill-instruction> tags', async () => {
207+
const handler = createConfigHandler({
208+
directory: testEnv.projectDir,
209+
bundledSkillsDir: path.join(testEnv.bundledDir, 'skills'),
210+
bundledAgentsDir: path.join(testEnv.bundledDir, 'agents'),
211+
bundledCommandsDir: path.join(testEnv.bundledDir, 'commands'),
212+
})
213+
214+
const config: Config = {}
215+
await handler(config)
216+
217+
const skillCommand = config.command?.['systematic:test-skill']
218+
expect(skillCommand?.template).toContain('<skill-instruction>')
219+
expect(skillCommand?.template).toContain('</skill-instruction>')
220+
expect(skillCommand?.template).toContain('Base directory for this skill:')
221+
expect(skillCommand?.template).toContain('Integration test content.')
222+
})
223+
224+
test('skill template does not contain frontmatter', async () => {
225+
const handler = createConfigHandler({
226+
directory: testEnv.projectDir,
227+
bundledSkillsDir: path.join(testEnv.bundledDir, 'skills'),
228+
bundledAgentsDir: path.join(testEnv.bundledDir, 'agents'),
229+
bundledCommandsDir: path.join(testEnv.bundledDir, 'commands'),
230+
})
231+
232+
const config: Config = {}
233+
await handler(config)
234+
235+
const skillCommand = config.command?.['systematic:test-skill']
236+
expect(skillCommand?.template).not.toContain('name: test-skill')
237+
expect(skillCommand?.template).not.toContain(
238+
'description: A skill for integration testing',
239+
)
240+
})
241+
})
242+
129243
describe('opencode availability check', () => {
130244
test('reports opencode installation status', () => {
131245
console.log(`OpenCode available: ${OPENCODE_AVAILABLE}`)

0 commit comments

Comments
 (0)