Skip to content

Commit dc09cc8

Browse files
authored
feat: update skill-tool to OpenCode v1.1.50 functionality (#50)
* feat(skill-tool): update description format to match OpenCode v1.1.50 * feat(skill-tool): update execute output format with skill_content wrapper and file discovery * test(skill-tool): update tests for v1.1.50 output format * fix(tests): use optional chaining instead of non-null assertion * fix(skill-tool): sort discovered files for deterministic output * fix(skill-tool): remove duplicate closing brace * test(skill-tool): update assertions for absolute paths and hidden files * refactor(skill-tool): use walkDir utility for file discovery * fix(skill-tool): match OpenCode file discovery and omit empty skill_files
1 parent bc5c6d6 commit dc09cc8

2 files changed

Lines changed: 182 additions & 20 deletions

File tree

src/lib/skill-tool.ts

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import fs from 'node:fs'
12
import path from 'node:path'
3+
import { pathToFileURL } from 'node:url'
24
import type { ToolDefinition } from '@opencode-ai/plugin'
35
import { tool } from '@opencode-ai/plugin/tool'
46
import {
@@ -26,12 +28,62 @@ export function formatSkillsXml(skills: SkillInfo[]): string {
2628
' <skill>',
2729
` <name>systematic:${skill.name}</name>`,
2830
` <description>${skill.description}</description>`,
31+
` <location>${pathToFileURL(skill.path).href}</location>`,
2932
' </skill>',
3033
])
3134

3235
return ['<available_skills>', ...skillLines, '</available_skills>'].join(' ')
3336
}
3437

38+
/**
39+
* Discovers skill files in a directory and formats them as XML tags.
40+
* Recursively searches subdirectories, includes hidden files, excludes .git and SKILL.md.
41+
* Matches OpenCode v1.1.50 behavior exactly.
42+
*
43+
* @param dir - Directory path to search for skill files
44+
* @param limit - Maximum number of files to return (default: 10)
45+
* @returns String with absolute file paths formatted as XML tags, one per line
46+
*/
47+
export function discoverSkillFiles(dir: string, limit = 10): string {
48+
const files: string[] = []
49+
50+
function shouldSkipDirectory(name: string): boolean {
51+
return name === '.git'
52+
}
53+
54+
function shouldIncludeFile(name: string): boolean {
55+
return name !== 'SKILL.md'
56+
}
57+
58+
function handleEntry(entry: fs.Dirent, currentDir: string): void {
59+
if (entry.isDirectory()) {
60+
if (!shouldSkipDirectory(entry.name)) {
61+
recurse(path.resolve(currentDir, entry.name))
62+
}
63+
} else if (shouldIncludeFile(entry.name)) {
64+
files.push(path.resolve(currentDir, entry.name))
65+
}
66+
}
67+
68+
function recurse(currentDir: string): void {
69+
if (files.length >= limit) return
70+
71+
try {
72+
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
73+
74+
for (const entry of entries) {
75+
if (files.length >= limit) break
76+
handleEntry(entry, currentDir)
77+
}
78+
} catch {
79+
// Silently ignore read errors
80+
}
81+
}
82+
83+
recurse(dir)
84+
return files.map((file) => ` <file>${file}</file>`).join('\n')
85+
}
86+
3587
export function createSkillTool(options: SkillToolOptions): ToolDefinition {
3688
const { bundledSkillsDir, disabledSkills } = options
3789

@@ -60,12 +112,19 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
60112
const systematicXml = formatSkillsXml(skillInfos)
61113

62114
return [
63-
'Load a skill to get detailed instructions for a specific task.',
64-
'Skills provide specialized knowledge and step-by-step guidance.',
65-
"Use this when a task matches an available skill's description.",
66-
'Only the skills listed here are available:',
115+
'Load a specialized skill that provides domain-specific instructions and workflows.',
116+
'',
117+
'When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.',
118+
'',
119+
'The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.',
120+
'',
121+
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
122+
'',
123+
'The following skills provide specialized sets of instructions for particular tasks.',
124+
'Invoke this tool to load a skill when a task matches one of the available skills listed below:',
125+
'',
67126
systematicXml,
68-
].join(' ')
127+
].join('\n')
69128
}
70129

71130
const buildParameterHint = (): string => {
@@ -75,7 +134,7 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
75134
.map((s) => `'systematic:${s.name}'`)
76135
.join(', ')
77136
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ''
78-
return `The skill identifier from available_skills${hint}`
137+
return `The name of the skill from available_skills${hint}`
79138
}
80139

81140
let cachedDescription: string | null = null
@@ -117,6 +176,8 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
117176

118177
const body = extractSkillBody(matchedSkill.wrappedTemplate)
119178
const dir = path.dirname(matchedSkill.skillFile)
179+
const base = pathToFileURL(dir).href
180+
const files = discoverSkillFiles(dir)
120181

121182
await context.ask({
122183
permission: 'skill',
@@ -133,13 +194,23 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
133194
},
134195
})
135196

136-
return [
137-
`## Skill: ${matchedSkill.prefixedName}`,
138-
'',
139-
`**Base directory**: ${dir}`,
197+
const output = [
198+
`<skill_content name="${matchedSkill.prefixedName}">`,
199+
`# Skill: ${matchedSkill.prefixedName}`,
140200
'',
141201
body.trim(),
142-
].join('\n')
202+
'',
203+
`Base directory for this skill: ${base}`,
204+
'Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.',
205+
'Note: file list is sampled.',
206+
]
207+
208+
if (files) {
209+
output.push('', '<skill_files>', files, '</skill_files>')
210+
}
211+
212+
output.push('</skill_content>')
213+
return output.join('\n')
143214
},
144215
})
145216
}

tests/unit/skill-tool.test.ts

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('skill-tool', () => {
2626
expect(result).toBe('')
2727
})
2828

29-
test('formats single skill with space delimiters and indented structure', () => {
29+
test('formats single skill with space delimiters, indented structure, and location field', () => {
3030
const result = formatSkillsXml([
3131
{
3232
path: '/test/path',
@@ -35,9 +35,13 @@ describe('skill-tool', () => {
3535
description: 'A test skill',
3636
},
3737
])
38-
expect(result).toBe(
39-
'<available_skills> <skill> <name>systematic:test-skill</name> <description>A test skill</description> </skill> </available_skills>',
40-
)
38+
expect(result).toContain('<available_skills>')
39+
expect(result).toContain('</available_skills>')
40+
expect(result).toContain('<name>systematic:test-skill</name>')
41+
expect(result).toContain('<description>A test skill</description>')
42+
expect(result).toContain('<location>file:///test/path</location>')
43+
// Ensure space-delimited format (no newlines)
44+
expect(result).not.toContain('\n')
4145
})
4246

4347
test('formats multiple skills with space delimiters and indented structure', () => {
@@ -235,7 +239,7 @@ No frontmatter visible here.`,
235239
expect(result).toContain('# Actual Content')
236240
})
237241

238-
test('extracts body from wrapped template (matches OMO pattern)', async () => {
242+
test('wraps output with skill_content tags and omits skill_files when no files found', async () => {
239243
const skillDir = path.join(testDir, 'wrap-test')
240244
fs.mkdirSync(skillDir)
241245
fs.writeFileSync(
@@ -254,11 +258,98 @@ description: Test wrapper
254258

255259
const result = await tool.execute({ name: 'wrap-test' }, mockContext)
256260

257-
expect(result).toContain('## Skill: systematic:wrap-test')
258-
expect(result).toContain('**Base directory**:')
261+
// New wrapper format
262+
expect(result).toContain('<skill_content name="systematic:wrap-test">')
263+
expect(result).toContain('</skill_content>')
264+
// New heading format
265+
expect(result).toContain('# Skill: systematic:wrap-test')
266+
// New base directory format with file:// URL
267+
expect(result).toContain('Base directory for this skill: file://')
259268
expect(result).toContain('# Wrapped Content')
260-
expect(result).not.toContain('<skill-instruction>')
261-
expect(result).not.toContain('</skill-instruction>')
269+
// skill_files section should be omitted when no files
270+
expect(result).not.toContain('<skill_files>')
271+
expect(result).not.toContain('</skill_files>')
272+
})
273+
274+
test('includes discovered files in skill_files section', async () => {
275+
const skillDir = path.join(testDir, 'file-discovery-test')
276+
fs.mkdirSync(skillDir)
277+
fs.writeFileSync(
278+
path.join(skillDir, 'SKILL.md'),
279+
`---
280+
name: file-discovery-test
281+
description: Test file discovery
282+
---
283+
# Test Content`,
284+
)
285+
// Add extra files to be discovered
286+
fs.writeFileSync(
287+
path.join(skillDir, 'helper.ts'),
288+
'export function helper() {}',
289+
)
290+
fs.writeFileSync(
291+
path.join(skillDir, 'utils.ts'),
292+
'export function util() {}',
293+
)
294+
fs.writeFileSync(path.join(skillDir, '.hidden'), 'hidden file')
295+
296+
const tool = createSkillTool({
297+
bundledSkillsDir: testDir,
298+
disabledSkills: [],
299+
})
300+
301+
const result = await tool.execute(
302+
{ name: 'file-discovery-test' },
303+
mockContext,
304+
)
305+
306+
expect(result).toContain('<skill_files>')
307+
expect(result).toContain('</skill_files>')
308+
// Check for absolute paths ending with the filenames
309+
expect(result).toMatch(/<file>.*\/helper\.ts<\/file>/)
310+
expect(result).toMatch(/<file>.*\/utils\.ts<\/file>/)
311+
// SKILL.md should not be in the file list
312+
expect(result).not.toContain('<file>SKILL.md</file>')
313+
// Hidden files should be included (matches OpenCode v1.1.50 behavior)
314+
expect(result).toMatch(/<file>.*\/\.hidden<\/file>/)
315+
})
316+
317+
test('enforces 10-file limit in skill_files section', async () => {
318+
const skillDir = path.join(testDir, 'file-limit-test')
319+
fs.mkdirSync(skillDir)
320+
fs.writeFileSync(
321+
path.join(skillDir, 'SKILL.md'),
322+
`---
323+
name: file-limit-test
324+
description: Test file limit
325+
---
326+
# Test Content`,
327+
)
328+
// Create 15 extra files
329+
for (let i = 1; i <= 15; i++) {
330+
fs.writeFileSync(
331+
path.join(skillDir, `file${i}.ts`),
332+
`export const file${i} = ${i}`,
333+
)
334+
}
335+
336+
const tool = createSkillTool({
337+
bundledSkillsDir: testDir,
338+
disabledSkills: [],
339+
})
340+
341+
const result = await tool.execute(
342+
{ name: 'file-limit-test' },
343+
mockContext,
344+
)
345+
346+
// Count the number of <file> tags
347+
const fileMatches = result.match(/<file>/g)
348+
expect(fileMatches).toBeDefined()
349+
expect(fileMatches?.length).toBe(10)
350+
// Verify at least one of the first 10 files is present
351+
const hasLimitedFiles = /file[0-9]\.ts/.test(result)
352+
expect(hasLimitedFiles).toBe(true)
262353
})
263354
})
264355
})

0 commit comments

Comments
 (0)