diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts
index a98132b8..5d65e57d 100644
--- a/src/lib/skill-tool.ts
+++ b/src/lib/skill-tool.ts
@@ -1,4 +1,6 @@
+import fs from 'node:fs'
import path from 'node:path'
+import { pathToFileURL } from 'node:url'
import type { ToolDefinition } from '@opencode-ai/plugin'
import { tool } from '@opencode-ai/plugin/tool'
import {
@@ -26,12 +28,62 @@ export function formatSkillsXml(skills: SkillInfo[]): string {
' ',
` systematic:${skill.name}`,
` ${skill.description}`,
+ ` ${pathToFileURL(skill.path).href}`,
' ',
])
return ['', ...skillLines, ''].join(' ')
}
+/**
+ * Discovers skill files in a directory and formats them as XML tags.
+ * Recursively searches subdirectories, includes hidden files, excludes .git and SKILL.md.
+ * Matches OpenCode v1.1.50 behavior exactly.
+ *
+ * @param dir - Directory path to search for skill files
+ * @param limit - Maximum number of files to return (default: 10)
+ * @returns String with absolute file paths formatted as XML tags, one per line
+ */
+export function discoverSkillFiles(dir: string, limit = 10): string {
+ const files: string[] = []
+
+ function shouldSkipDirectory(name: string): boolean {
+ return name === '.git'
+ }
+
+ function shouldIncludeFile(name: string): boolean {
+ return name !== 'SKILL.md'
+ }
+
+ function handleEntry(entry: fs.Dirent, currentDir: string): void {
+ if (entry.isDirectory()) {
+ if (!shouldSkipDirectory(entry.name)) {
+ recurse(path.resolve(currentDir, entry.name))
+ }
+ } else if (shouldIncludeFile(entry.name)) {
+ files.push(path.resolve(currentDir, entry.name))
+ }
+ }
+
+ function recurse(currentDir: string): void {
+ if (files.length >= limit) return
+
+ try {
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true })
+
+ for (const entry of entries) {
+ if (files.length >= limit) break
+ handleEntry(entry, currentDir)
+ }
+ } catch {
+ // Silently ignore read errors
+ }
+ }
+
+ recurse(dir)
+ return files.map((file) => ` ${file}`).join('\n')
+}
+
export function createSkillTool(options: SkillToolOptions): ToolDefinition {
const { bundledSkillsDir, disabledSkills } = options
@@ -60,12 +112,19 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
const systematicXml = formatSkillsXml(skillInfos)
return [
- 'Load a skill to get detailed instructions for a specific task.',
- 'Skills provide specialized knowledge and step-by-step guidance.',
- "Use this when a task matches an available skill's description.",
- 'Only the skills listed here are available:',
+ 'Load a specialized skill that provides domain-specific instructions and workflows.',
+ '',
+ 'When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.',
+ '',
+ 'The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.',
+ '',
+ 'Tool output includes a `` block with the loaded content.',
+ '',
+ 'The following skills provide specialized sets of instructions for particular tasks.',
+ 'Invoke this tool to load a skill when a task matches one of the available skills listed below:',
+ '',
systematicXml,
- ].join(' ')
+ ].join('\n')
}
const buildParameterHint = (): string => {
@@ -75,7 +134,7 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
.map((s) => `'systematic:${s.name}'`)
.join(', ')
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ''
- return `The skill identifier from available_skills${hint}`
+ return `The name of the skill from available_skills${hint}`
}
let cachedDescription: string | null = null
@@ -117,6 +176,8 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
const body = extractSkillBody(matchedSkill.wrappedTemplate)
const dir = path.dirname(matchedSkill.skillFile)
+ const base = pathToFileURL(dir).href
+ const files = discoverSkillFiles(dir)
await context.ask({
permission: 'skill',
@@ -133,13 +194,23 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
},
})
- return [
- `## Skill: ${matchedSkill.prefixedName}`,
- '',
- `**Base directory**: ${dir}`,
+ const output = [
+ ``,
+ `# Skill: ${matchedSkill.prefixedName}`,
'',
body.trim(),
- ].join('\n')
+ '',
+ `Base directory for this skill: ${base}`,
+ 'Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.',
+ 'Note: file list is sampled.',
+ ]
+
+ if (files) {
+ output.push('', '', files, '')
+ }
+
+ output.push('')
+ return output.join('\n')
},
})
}
diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts
index f7149293..577c7756 100644
--- a/tests/unit/skill-tool.test.ts
+++ b/tests/unit/skill-tool.test.ts
@@ -26,7 +26,7 @@ describe('skill-tool', () => {
expect(result).toBe('')
})
- test('formats single skill with space delimiters and indented structure', () => {
+ test('formats single skill with space delimiters, indented structure, and location field', () => {
const result = formatSkillsXml([
{
path: '/test/path',
@@ -35,9 +35,13 @@ describe('skill-tool', () => {
description: 'A test skill',
},
])
- expect(result).toBe(
- ' systematic:test-skill A test skill ',
- )
+ expect(result).toContain('')
+ expect(result).toContain('')
+ expect(result).toContain('systematic:test-skill')
+ expect(result).toContain('A test skill')
+ expect(result).toContain('file:///test/path')
+ // Ensure space-delimited format (no newlines)
+ expect(result).not.toContain('\n')
})
test('formats multiple skills with space delimiters and indented structure', () => {
@@ -235,7 +239,7 @@ No frontmatter visible here.`,
expect(result).toContain('# Actual Content')
})
- test('extracts body from wrapped template (matches OMO pattern)', async () => {
+ test('wraps output with skill_content tags and omits skill_files when no files found', async () => {
const skillDir = path.join(testDir, 'wrap-test')
fs.mkdirSync(skillDir)
fs.writeFileSync(
@@ -254,11 +258,98 @@ description: Test wrapper
const result = await tool.execute({ name: 'wrap-test' }, mockContext)
- expect(result).toContain('## Skill: systematic:wrap-test')
- expect(result).toContain('**Base directory**:')
+ // New wrapper format
+ expect(result).toContain('')
+ expect(result).toContain('')
+ // New heading format
+ expect(result).toContain('# Skill: systematic:wrap-test')
+ // New base directory format with file:// URL
+ expect(result).toContain('Base directory for this skill: file://')
expect(result).toContain('# Wrapped Content')
- expect(result).not.toContain('')
- expect(result).not.toContain('')
+ // skill_files section should be omitted when no files
+ expect(result).not.toContain('')
+ expect(result).not.toContain('')
+ })
+
+ test('includes discovered files in skill_files section', async () => {
+ const skillDir = path.join(testDir, 'file-discovery-test')
+ fs.mkdirSync(skillDir)
+ fs.writeFileSync(
+ path.join(skillDir, 'SKILL.md'),
+ `---
+name: file-discovery-test
+description: Test file discovery
+---
+# Test Content`,
+ )
+ // Add extra files to be discovered
+ fs.writeFileSync(
+ path.join(skillDir, 'helper.ts'),
+ 'export function helper() {}',
+ )
+ fs.writeFileSync(
+ path.join(skillDir, 'utils.ts'),
+ 'export function util() {}',
+ )
+ fs.writeFileSync(path.join(skillDir, '.hidden'), 'hidden file')
+
+ const tool = createSkillTool({
+ bundledSkillsDir: testDir,
+ disabledSkills: [],
+ })
+
+ const result = await tool.execute(
+ { name: 'file-discovery-test' },
+ mockContext,
+ )
+
+ expect(result).toContain('')
+ expect(result).toContain('')
+ // Check for absolute paths ending with the filenames
+ expect(result).toMatch(/.*\/helper\.ts<\/file>/)
+ expect(result).toMatch(/.*\/utils\.ts<\/file>/)
+ // SKILL.md should not be in the file list
+ expect(result).not.toContain('SKILL.md')
+ // Hidden files should be included (matches OpenCode v1.1.50 behavior)
+ expect(result).toMatch(/.*\/\.hidden<\/file>/)
+ })
+
+ test('enforces 10-file limit in skill_files section', async () => {
+ const skillDir = path.join(testDir, 'file-limit-test')
+ fs.mkdirSync(skillDir)
+ fs.writeFileSync(
+ path.join(skillDir, 'SKILL.md'),
+ `---
+name: file-limit-test
+description: Test file limit
+---
+# Test Content`,
+ )
+ // Create 15 extra files
+ for (let i = 1; i <= 15; i++) {
+ fs.writeFileSync(
+ path.join(skillDir, `file${i}.ts`),
+ `export const file${i} = ${i}`,
+ )
+ }
+
+ const tool = createSkillTool({
+ bundledSkillsDir: testDir,
+ disabledSkills: [],
+ })
+
+ const result = await tool.execute(
+ { name: 'file-limit-test' },
+ mockContext,
+ )
+
+ // Count the number of tags
+ const fileMatches = result.match(//g)
+ expect(fileMatches).toBeDefined()
+ expect(fileMatches?.length).toBe(10)
+ // Verify at least one of the first 10 files is present
+ const hasLimitedFiles = /file[0-9]\.ts/.test(result)
+ expect(hasLimitedFiles).toBe(true)
})
})
})