From 45ee5751a53beabda9ca34ddd030f0d37b8d9a93 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 11:35:38 -0700 Subject: [PATCH 1/9] feat(skill-tool): update description format to match OpenCode v1.1.50 --- src/lib/skill-tool.ts | 47 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index a98132b8..7b493d60 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,38 @@ 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. + * Lists top-level files only (no recursion), filters out SKILL.md and hidden files. + * + * @param dir - Directory path to search for skill files + * @param limit - Maximum number of files to return (default: 10) + * @returns String with file names formatted as XML tags, one per line + */ +export function discoverSkillFiles(dir: string, limit?: number): string { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + const files = entries + .filter((entry) => entry.isFile()) + .filter((entry) => !entry.name.startsWith('.')) + .filter((entry) => entry.name !== 'SKILL.md') + .map((entry) => entry.name) + .slice(0, limit ?? 10) + + return files.map((name) => ` ${name}`).join('\n') + } catch { + return '' + } +} + export function createSkillTool(options: SkillToolOptions): ToolDefinition { const { bundledSkillsDir, disabledSkills } = options @@ -60,12 +88,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 +110,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 From 96a0b0bd2e4f855e1df4fed2548ba3544c45e189 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 11:38:37 -0700 Subject: [PATCH 2/9] feat(skill-tool): update execute output format with skill_content wrapper and file discovery --- src/lib/skill-tool.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index 7b493d60..102a1cb2 100644 --- a/src/lib/skill-tool.ts +++ b/src/lib/skill-tool.ts @@ -152,6 +152,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', @@ -169,11 +171,19 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition { }) return [ - `## Skill: ${matchedSkill.prefixedName}`, - '', - `**Base directory**: ${dir}`, + ``, + `# Skill: ${matchedSkill.prefixedName}`, '', body.trim(), + '', + `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.', + '', + '', + files, + '', + '', ].join('\n') }, }) From 4ccc30d897c5113af6ce060b127eb1c2ab088b2e Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 11:44:11 -0700 Subject: [PATCH 3/9] test(skill-tool): update tests for v1.1.50 output format --- tests/unit/skill-tool.test.ts | 108 +++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 9 deletions(-) diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts index f7149293..e4ce8413 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 uses new format', async () => { const skillDir = path.join(testDir, 'wrap-test') fs.mkdirSync(skillDir) fs.writeFileSync( @@ -254,11 +258,97 @@ 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('') + // File discovery section + expect(result).toContain('') + expect(result).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('') + expect(result).toContain('helper.ts') + expect(result).toContain('utils.ts') + // SKILL.md should not be in the file list + expect(result).not.toContain('SKILL.md') + // Hidden files should not be included + expect(result).not.toContain('.hidden') + }) + + 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) }) }) }) From 7f54df2318c1c878781c686fa1517402559581fd Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 11:57:05 -0700 Subject: [PATCH 4/9] fix(tests): use optional chaining instead of non-null assertion --- tests/unit/skill-tool.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts index e4ce8413..44459820 100644 --- a/tests/unit/skill-tool.test.ts +++ b/tests/unit/skill-tool.test.ts @@ -345,7 +345,7 @@ description: Test file limit // Count the number of tags const fileMatches = result.match(//g) expect(fileMatches).toBeDefined() - expect(fileMatches!.length).toBe(10) + 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) From 137f4398859a5edc175ee94a02c4da21c015947b Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 12:10:40 -0700 Subject: [PATCH 5/9] fix(skill-tool): sort discovered files for deterministic output --- src/lib/skill-tool.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index 102a1cb2..62489bed 100644 --- a/src/lib/skill-tool.ts +++ b/src/lib/skill-tool.ts @@ -52,6 +52,7 @@ export function discoverSkillFiles(dir: string, limit?: number): string { .filter((entry) => !entry.name.startsWith('.')) .filter((entry) => entry.name !== 'SKILL.md') .map((entry) => entry.name) + .sort() .slice(0, limit ?? 10) return files.map((name) => ` ${name}`).join('\n') From 0af3002abba5601cc2d146b02169605a2ee3717d Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 15:56:48 -0700 Subject: [PATCH 6/9] fix(skill-tool): remove duplicate closing brace --- src/lib/skill-tool.ts | 53 +++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index 62489bed..eb97c4c3 100644 --- a/src/lib/skill-tool.ts +++ b/src/lib/skill-tool.ts @@ -37,25 +37,54 @@ export function formatSkillsXml(skills: SkillInfo[]): string { /** * Discovers skill files in a directory and formats them as XML tags. - * Lists top-level files only (no recursion), filters out SKILL.md and hidden files. + * 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 file names formatted as XML tags, one per line + * @returns String with absolute file paths formatted as XML tags, one per line */ -export function discoverSkillFiles(dir: string, limit?: number): string { +export function discoverSkillFiles(dir: string, limit = 10): string { try { - const entries = fs.readdirSync(dir, { withFileTypes: true }) + const files: string[] = [] - const files = entries - .filter((entry) => entry.isFile()) - .filter((entry) => !entry.name.startsWith('.')) - .filter((entry) => entry.name !== 'SKILL.md') - .map((entry) => entry.name) - .sort() - .slice(0, limit ?? 10) + function walk(currentDir: string): void { + if (files.length >= limit) return + + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }) + } catch { + return // Skip unreadable directories + } + + for (const entry of entries) { + if (files.length >= limit) return + + const entryPath = path.join(currentDir, entry.name) + + // Skip .git directory (matches OpenCode's --glob=!.git/*) + if (entry.name === '.git') continue - return files.map((name) => ` ${name}`).join('\n') + // Skip SKILL.md files + if (entry.name === 'SKILL.md') continue + + if (entry.isDirectory()) { + walk(entryPath) + } else if (entry.isFile()) { + // Use absolute path (matches OpenCode's path.resolve behavior) + files.push(path.resolve(entryPath)) + } + } + } + + walk(dir) + + return files + .sort() + .slice(0, limit) + .map((file) => ` ${file}`) + .join('\n') } catch { return '' } From 0585d9091b2fd07e389151c27d532e154d0cb094 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 15:58:21 -0700 Subject: [PATCH 7/9] test(skill-tool): update assertions for absolute paths and hidden files --- tests/unit/skill-tool.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts index 44459820..df5a3bbd 100644 --- a/tests/unit/skill-tool.test.ts +++ b/tests/unit/skill-tool.test.ts @@ -305,12 +305,13 @@ description: Test file discovery expect(result).toContain('') expect(result).toContain('') - expect(result).toContain('helper.ts') - expect(result).toContain('utils.ts') + // 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 not be included - expect(result).not.toContain('.hidden') + // 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 () => { From 0e7711e01b54310177b820b1072b25b7b5cae5a3 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 16:03:26 -0700 Subject: [PATCH 8/9] refactor(skill-tool): use walkDir utility for file discovery --- src/lib/skill-tool.ts | 49 +++++++++++++------------------------------ 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index eb97c4c3..3e9c8bfd 100644 --- a/src/lib/skill-tool.ts +++ b/src/lib/skill-tool.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import type { ToolDefinition } from '@opencode-ai/plugin' @@ -9,6 +8,7 @@ import { loadSkill, } from './skill-loader.js' import { findSkillsInDir, type SkillInfo } from './skills.js' +import { walkDir } from './walk-dir.js' export interface SkillToolOptions { bundledSkillsDir: string @@ -46,41 +46,20 @@ export function formatSkillsXml(skills: SkillInfo[]): string { */ export function discoverSkillFiles(dir: string, limit = 10): string { try { - const files: string[] = [] - - function walk(currentDir: string): void { - if (files.length >= limit) return - - let entries: fs.Dirent[] - try { - entries = fs.readdirSync(currentDir, { withFileTypes: true }) - } catch { - return // Skip unreadable directories - } - - for (const entry of entries) { - if (files.length >= limit) return - - const entryPath = path.join(currentDir, entry.name) - - // Skip .git directory (matches OpenCode's --glob=!.git/*) - if (entry.name === '.git') continue - + const entries = walkDir(dir, { + maxDepth: Infinity, + filter: (entry) => { + // Skip .git directory + if (entry.name === '.git') return false // Skip SKILL.md files - if (entry.name === 'SKILL.md') continue - - if (entry.isDirectory()) { - walk(entryPath) - } else if (entry.isFile()) { - // Use absolute path (matches OpenCode's path.resolve behavior) - files.push(path.resolve(entryPath)) - } - } - } - - walk(dir) - - return files + if (entry.name === 'SKILL.md') return false + // Only files (not directories) + return !entry.isDirectory + }, + }) + + return entries + .map((entry) => path.resolve(entry.path)) .sort() .slice(0, limit) .map((file) => ` ${file}`) From 9da93468aae6d08bef1b26f6ae2a42ba71e37571 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 4 Feb 2026 18:48:11 -0700 Subject: [PATCH 9/9] fix(skill-tool): match OpenCode file discovery and omit empty skill_files --- src/lib/skill-tool.ts | 75 +++++++++++++++++++++-------------- tests/unit/skill-tool.test.ts | 8 ++-- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index 3e9c8bfd..5d65e57d 100644 --- a/src/lib/skill-tool.ts +++ b/src/lib/skill-tool.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import type { ToolDefinition } from '@opencode-ai/plugin' @@ -8,7 +9,6 @@ import { loadSkill, } from './skill-loader.js' import { findSkillsInDir, type SkillInfo } from './skills.js' -import { walkDir } from './walk-dir.js' export interface SkillToolOptions { bundledSkillsDir: string @@ -45,28 +45,43 @@ export function formatSkillsXml(skills: SkillInfo[]): string { * @returns String with absolute file paths formatted as XML tags, one per line */ export function discoverSkillFiles(dir: string, limit = 10): string { - try { - const entries = walkDir(dir, { - maxDepth: Infinity, - filter: (entry) => { - // Skip .git directory - if (entry.name === '.git') return false - // Skip SKILL.md files - if (entry.name === 'SKILL.md') return false - // Only files (not directories) - return !entry.isDirectory - }, - }) - - return entries - .map((entry) => path.resolve(entry.path)) - .sort() - .slice(0, limit) - .map((file) => ` ${file}`) - .join('\n') - } catch { - return '' + 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 { @@ -179,7 +194,7 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition { }, }) - return [ + const output = [ ``, `# Skill: ${matchedSkill.prefixedName}`, '', @@ -188,12 +203,14 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition { `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.', - '', - '', - files, - '', - '', - ].join('\n') + ] + + 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 df5a3bbd..577c7756 100644 --- a/tests/unit/skill-tool.test.ts +++ b/tests/unit/skill-tool.test.ts @@ -239,7 +239,7 @@ No frontmatter visible here.`, expect(result).toContain('# Actual Content') }) - test('wraps output with skill_content tags and uses new format', 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( @@ -266,9 +266,9 @@ description: Test wrapper // New base directory format with file:// URL expect(result).toContain('Base directory for this skill: file://') expect(result).toContain('# Wrapped Content') - // File discovery section - expect(result).toContain('') - expect(result).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 () => {