From 99462003e6ad471808026918df6ac23958b2dd2b Mon Sep 17 00:00:00 2001 From: JosXa Date: Sat, 31 Jan 2026 03:30:01 +0100 Subject: [PATCH] feat: add skill rendering with name syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for rendering OpenCode skills inline within snippets. - Introduces experimental.skillRendering config flag (disabled by default) - Supports two syntaxes: name and - Loads skills from standard OpenCode paths: - ~/.config/opencode/skill[s]//SKILL.md (global) - .opencode/skill[s]//SKILL.md (project) - .claude/skills//SKILL.md (Claude Code compatibility) - Skills are rendered verbatim (body only, excludes frontmatter) - Processing order: skills → snippets → shell commands - Includes comprehensive tests for skill rendering Closes #22 --- index.ts | 40 ++++++- schema/config.schema.json | 12 +++ src/config.test.ts | 2 + src/config.ts | 33 ++++++ src/constants.ts | 8 ++ src/skill-loader.ts | 160 +++++++++++++++++++++++++++ src/skill-renderer.test.ts | 215 +++++++++++++++++++++++++++++++++++++ src/skill-renderer.ts | 50 +++++++++ 8 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 src/skill-loader.ts create mode 100644 src/skill-renderer.test.ts create mode 100644 src/skill-renderer.ts diff --git a/index.ts b/index.ts index 3ca392e..2346c7b 100644 --- a/index.ts +++ b/index.ts @@ -16,6 +16,8 @@ import { InjectionManager } from "./src/injection-manager.js"; import { loadSnippets } from "./src/loader.js"; import { logger } from "./src/logger.js"; import { executeShellCommands, type ShellContext } from "./src/shell.js"; +import { loadSkills, type SkillRegistry } from "./src/skill-loader.js"; +import { expandSkillTags } from "./src/skill-renderer.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -78,11 +80,20 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Load all snippets at startup (global + project directory) const startupStart = performance.now(); const snippets = await loadSnippets(ctx.directory); + + // Load skills if skill rendering is enabled + let skills: SkillRegistry = new Map(); + if (config.experimental.skillRendering) { + skills = await loadSkills(ctx.directory); + } + const startupTime = performance.now() - startupStart; logger.debug("Plugin startup complete", { startupTimeMs: startupTime.toFixed(2), snippetCount: snippets.size, + skillCount: skills.size, + skillRenderingEnabled: config.experimental.skillRendering, installSkill: config.installSkill, debugLogging: config.logging.debug, }); @@ -93,7 +104,7 @@ export const SnippetsPlugin: Plugin = async (ctx) => { const injectionManager = new InjectionManager(); /** - * Processes text parts for snippet expansion and shell command execution. + * Processes text parts for snippet expansion, skill rendering, and shell command execution. * Returns collected inject blocks from expanded snippets. */ const processTextParts = async ( @@ -101,18 +112,28 @@ export const SnippetsPlugin: Plugin = async (ctx) => { ): Promise => { const messageStart = performance.now(); let expandTimeTotal = 0; + let skillTimeTotal = 0; let shellTimeTotal = 0; let processedParts = 0; const allInjected: string[] = []; for (const part of parts) { if (part.type === "text" && part.text) { + // 1. Expand skill tags if skill rendering is enabled + if (config.experimental.skillRendering && skills.size > 0) { + const skillStart = performance.now(); + part.text = expandSkillTags(part.text, skills); + skillTimeTotal += performance.now() - skillStart; + } + + // 2. Expand hashtags recursively with loop detection const expandStart = performance.now(); const expansionResult = expandHashtags(part.text, snippets); part.text = assembleMessage(expansionResult); allInjected.push(...expansionResult.inject); expandTimeTotal += performance.now() - expandStart; + // 3. Execute shell commands: !`command` const shellStart = performance.now(); part.text = await executeShellCommands(part.text, ctx as unknown as ShellContext, { hideCommandInOutput: config.hideCommandInOutput, @@ -123,8 +144,10 @@ export const SnippetsPlugin: Plugin = async (ctx) => { } if (processedParts > 0) { + const totalTime = performance.now() - messageStart; logger.debug("Text parts processing complete", { - totalTimeMs: (performance.now() - messageStart).toFixed(2), + totalTimeMs: totalTime.toFixed(2), + skillTimeMs: skillTimeTotal.toFixed(2), snippetExpandTimeMs: expandTimeTotal.toFixed(2), shellTimeMs: shellTimeTotal.toFixed(2), processedParts, @@ -219,11 +242,22 @@ export const SnippetsPlugin: Plugin = async (ctx) => { injectionManager.clearSession(event.sessionID); }, + // Process skill tool output to expand snippets and skill tags in skill content "tool.execute.after": async (input, output) => { if (input.tool !== "skill") return; + // The skill tool returns markdown content in its output + // Expand skill tags and hashtags in the skill content if (typeof output.output === "string" && output.output.trim()) { - const expansionResult = expandHashtags(output.output, snippets); + let processed = output.output; + + // First expand skill tags if enabled + if (config.experimental.skillRendering && skills.size > 0) { + processed = expandSkillTags(processed, skills); + } + + // Then expand hashtag snippets + const expansionResult = expandHashtags(processed, snippets); output.output = assembleMessage(expansionResult); logger.debug("Skill content expanded", { diff --git a/schema/config.schema.json b/schema/config.schema.json index 3c05373..15cf7a7 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -26,6 +26,18 @@ }, "additionalProperties": false }, + "experimental": { + "type": "object", + "description": "Experimental features (may change or be removed in future versions)", + "properties": { + "skillRendering": { + "$ref": "#/definitions/booleanSetting", + "default": false, + "description": "Enable skill rendering with name or syntax. When enabled, skill tags are replaced with the skill's content body. Skills are loaded from OpenCode's standard skill directories." + } + }, + "additionalProperties": false + }, "installSkill": { "$ref": "#/definitions/booleanSetting", "default": true, diff --git a/src/config.test.ts b/src/config.test.ts index c622726..0035b58 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -63,6 +63,7 @@ describe("config", () => { expect(config).toEqual({ logging: { debug: false }, + experimental: { skillRendering: false }, installSkill: true, hideCommandInOutput: false, }); @@ -200,6 +201,7 @@ describe("config", () => { // Should return defaults when config is invalid expect(config).toEqual({ logging: { debug: false }, + experimental: { skillRendering: false }, installSkill: true, hideCommandInOutput: false, }); diff --git a/src/config.ts b/src/config.ts index a447ef3..72edeef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,14 @@ export interface LoggingConfig { debug: boolean; } +/** + * Experimental features configuration + */ +export interface ExperimentalConfig { + /** Enable skill rendering with name or syntax */ + skillRendering: boolean; +} + /** * Configuration schema for the snippets plugin */ @@ -23,6 +31,9 @@ export interface SnippetsConfig { /** Logging settings */ logging: LoggingConfig; + /** Experimental features */ + experimental: ExperimentalConfig; + /** Automatically install SKILL.md to global skill directory */ installSkill: boolean; @@ -37,6 +48,9 @@ interface RawConfig { logging?: { debug?: BooleanSetting; }; + experimental?: { + skillRendering?: BooleanSetting; + }; installSkill?: BooleanSetting; hideCommandInOutput?: BooleanSetting; } @@ -48,6 +62,9 @@ const DEFAULT_CONFIG: SnippetsConfig = { logging: { debug: false, }, + experimental: { + skillRendering: false, + }, installSkill: true, hideCommandInOutput: false, }; @@ -68,6 +85,16 @@ const DEFAULT_CONFIG_CONTENT = `{ "debug": false }, + // Experimental features (may change or be removed) + "experimental": { + // Enable skill rendering with name or syntax + // When enabled, skill tags are replaced with the skill's content body + // Skills are loaded from OpenCode's standard skill directories + // Values: true, false, "enabled", "disabled" + // Default: false + "skillRendering": false + }, + // Automatically install SKILL.md to global skill directory // When enabled, the snippets skill is copied to ~/.config/opencode/skill/snippets/ // This enables the LLM to understand how to use snippets @@ -172,6 +199,7 @@ export function loadConfig(projectDir?: string): SnippetsConfig { logger.debug("Final config", { loggingDebug: config.logging.debug, + experimentalSkillRendering: config.experimental.skillRendering, installSkill: config.installSkill, hideCommandInOutput: config.hideCommandInOutput, }); @@ -184,6 +212,7 @@ export function loadConfig(projectDir?: string): SnippetsConfig { */ function mergeConfig(base: SnippetsConfig, raw: RawConfig): SnippetsConfig { const debugValue = normalizeBooleanSetting(raw.logging?.debug); + const skillRenderingValue = normalizeBooleanSetting(raw.experimental?.skillRendering); const installSkillValue = normalizeBooleanSetting(raw.installSkill); const hideCommandValue = normalizeBooleanSetting(raw.hideCommandInOutput); @@ -191,6 +220,10 @@ function mergeConfig(base: SnippetsConfig, raw: RawConfig): SnippetsConfig { logging: { debug: debugValue !== undefined ? debugValue : base.logging.debug, }, + experimental: { + skillRendering: + skillRenderingValue !== undefined ? skillRenderingValue : base.experimental.skillRendering, + }, installSkill: installSkillValue !== undefined ? installSkillValue : base.installSkill, hideCommandInOutput: hideCommandValue !== undefined ? hideCommandValue : base.hideCommandInOutput, diff --git a/src/constants.ts b/src/constants.ts index 44988ec..20a5551 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,14 @@ export const PATTERNS = { /** Matches shell commands like !`command` */ SHELL_COMMAND: /!`([^`]+)`/g, + + /** + * Matches skill tags in two formats: + * 1. Self-closing: or + * 2. Block format: skill-name + */ + SKILL_TAG_SELF_CLOSING: //gi, + SKILL_TAG_BLOCK: /([^<]+)<\/skill>/gi, } as const; /** diff --git a/src/skill-loader.ts b/src/skill-loader.ts new file mode 100644 index 0000000..93b869f --- /dev/null +++ b/src/skill-loader.ts @@ -0,0 +1,160 @@ +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import matter from "gray-matter"; +import { logger } from "./logger.js"; + +/** + * Loaded skill info + */ +export interface SkillInfo { + /** The skill name */ + name: string; + /** The skill content body (markdown, excluding frontmatter) */ + content: string; + /** Optional description from frontmatter */ + description?: string; + /** Where the skill was loaded from */ + source: "global" | "project"; + /** Full path to the skill file */ + filePath: string; +} + +/** + * Skill registry that maps skill names to their info + */ +export type SkillRegistry = Map; + +/** + * OpenCode skill directory patterns (in order of priority) + * + * Global paths: + * - ~/.config/opencode/skill//SKILL.md + * - ~/.config/opencode/skills//SKILL.md + * + * Project paths (higher priority): + * - .opencode/skill//SKILL.md + * - .opencode/skills//SKILL.md + * - .claude/skills//SKILL.md (Claude Code compatibility) + */ +const GLOBAL_SKILL_DIRS = [ + join(homedir(), ".config", "opencode", "skill"), + join(homedir(), ".config", "opencode", "skills"), +]; + +function getProjectSkillDirs(projectDir: string): string[] { + return [ + join(projectDir, ".opencode", "skill"), + join(projectDir, ".opencode", "skills"), + join(projectDir, ".claude", "skills"), + ]; +} + +/** + * Loads all skills from global and project directories + * + * @param projectDir - Optional project directory path + * @returns A map of skill names (lowercase) to their SkillInfo + */ +export async function loadSkills(projectDir?: string): Promise { + const skills: SkillRegistry = new Map(); + + // Load from global directories first + for (const dir of GLOBAL_SKILL_DIRS) { + await loadFromDirectory(dir, skills, "global"); + } + + // Load from project directories (overrides global) + if (projectDir) { + for (const dir of getProjectSkillDirs(projectDir)) { + await loadFromDirectory(dir, skills, "project"); + } + } + + logger.debug("Skills loaded", { count: skills.size }); + return skills; +} + +/** + * Loads skills from a specific directory + */ +async function loadFromDirectory( + dir: string, + registry: SkillRegistry, + source: "global" | "project", +): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skill = await loadSkill(dir, entry.name, source); + if (skill) { + registry.set(skill.name.toLowerCase(), skill); + } + } + + logger.debug(`Loaded skills from ${source} directory`, { path: dir }); + } catch { + // Directory doesn't exist or can't be read - that's fine + logger.debug(`${source} skill directory not found`, { path: dir }); + } +} + +/** + * Loads a single skill from its directory + * + * @param baseDir - Base skill directory + * @param skillName - Name of the skill (directory name) + * @param source - Whether this is a global or project skill + * @returns The parsed skill info, or null if not found/invalid + */ +async function loadSkill( + baseDir: string, + skillName: string, + source: "global" | "project", +): Promise { + const filePath = join(baseDir, skillName, "SKILL.md"); + + try { + const file = Bun.file(filePath); + if (!(await file.exists())) { + return null; + } + + const fileContent = await file.text(); + const parsed = matter(fileContent); + + const content = parsed.content.trim(); + const frontmatter = parsed.data as { name?: string; description?: string }; + + // Use frontmatter name if available, otherwise use directory name + const name = frontmatter.name || skillName; + + return { + name, + content, + description: frontmatter.description, + source, + filePath, + }; + } catch (error) { + logger.warn("Failed to load skill", { + skillName, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Gets a skill by name from the registry + * + * @param registry - The skill registry + * @param name - The skill name (case-insensitive) + * @returns The skill info, or undefined if not found + */ +export function getSkill(registry: SkillRegistry, name: string): SkillInfo | undefined { + return registry.get(name.toLowerCase()); +} diff --git a/src/skill-renderer.test.ts b/src/skill-renderer.test.ts new file mode 100644 index 0000000..08cd4ef --- /dev/null +++ b/src/skill-renderer.test.ts @@ -0,0 +1,215 @@ +import type { SkillInfo, SkillRegistry } from "./skill-loader.js"; +import { expandSkillTags } from "./skill-renderer.js"; + +/** Helper to create a SkillInfo from just content */ +function skill(content: string, name = "test"): SkillInfo { + return { name, content, source: "global", filePath: "" }; +} + +/** Helper to create a registry from [key, content] pairs */ +function createRegistry(entries: [string, string][]): SkillRegistry { + return new Map(entries.map(([key, content]) => [key, skill(content, key)])); +} + +describe("expandSkillTags", () => { + describe("Block format name", () => { + it("should expand a single skill tag", () => { + const registry = createRegistry([["jira", "Jira skill content"]]); + + const result = expandSkillTags("Use jira for tickets", registry); + + expect(result).toBe("Use Jira skill content for tickets"); + }); + + it("should expand multiple skill tags", () => { + const registry = createRegistry([ + ["jira", "Jira skill"], + ["github", "GitHub skill"], + ]); + + const result = expandSkillTags("jira and github", registry); + + expect(result).toBe("Jira skill and GitHub skill"); + }); + + it("should leave unknown skills unchanged", () => { + const registry = createRegistry([["known", "Known content"]]); + + const result = expandSkillTags("known and unknown", registry); + + expect(result).toBe("Known content and unknown"); + }); + + it("should be case-insensitive for skill names", () => { + const registry = createRegistry([["jira", "Jira content"]]); + + const result = expandSkillTags("JIRA Jira", registry); + + expect(result).toBe("Jira content Jira content"); + }); + + it("should trim whitespace in skill names", () => { + const registry = createRegistry([["jira", "Jira content"]]); + + const result = expandSkillTags(" jira ", registry); + + expect(result).toBe("Jira content"); + }); + }); + + describe('Self-closing format ', () => { + it("should expand skill with double quotes", () => { + const registry = createRegistry([["jira", "Jira skill content"]]); + + const result = expandSkillTags('Use for tickets', registry); + + expect(result).toBe("Use Jira skill content for tickets"); + }); + + it("should expand skill with single quotes", () => { + const registry = createRegistry([["jira", "Jira skill content"]]); + + const result = expandSkillTags("Use for tickets", registry); + + expect(result).toBe("Use Jira skill content for tickets"); + }); + + it("should expand skill without space before slash", () => { + const registry = createRegistry([["jira", "Jira skill content"]]); + + const result = expandSkillTags('Use for tickets', registry); + + expect(result).toBe("Use Jira skill content for tickets"); + }); + + it("should expand multiple self-closing tags", () => { + const registry = createRegistry([ + ["jira", "Jira skill"], + ["github", "GitHub skill"], + ]); + + const result = expandSkillTags(' and ', registry); + + expect(result).toBe("Jira skill and GitHub skill"); + }); + + it("should leave unknown skills unchanged", () => { + const registry = createRegistry([["known", "Known content"]]); + + const result = expandSkillTags( + ' and ', + registry, + ); + + expect(result).toBe('Known content and '); + }); + + it("should be case-insensitive for skill names", () => { + const registry = createRegistry([["jira", "Jira content"]]); + + const result = expandSkillTags(' ', registry); + + expect(result).toBe("Jira content Jira content"); + }); + }); + + describe("Mixed formats", () => { + it("should handle both formats in the same text", () => { + const registry = createRegistry([ + ["jira", "Jira skill"], + ["github", "GitHub skill"], + ]); + + const result = expandSkillTags(' and github', registry); + + expect(result).toBe("Jira skill and GitHub skill"); + }); + }); + + describe("Edge cases", () => { + it("should handle empty registry", () => { + const registry: SkillRegistry = new Map(); + + const result = expandSkillTags("anything", registry); + + expect(result).toBe("anything"); + }); + + it("should handle text without skill tags", () => { + const registry = createRegistry([["jira", "Jira content"]]); + + const result = expandSkillTags("No skill tags here", registry); + + expect(result).toBe("No skill tags here"); + }); + + it("should handle empty text", () => { + const registry = createRegistry([["jira", "Jira content"]]); + + const result = expandSkillTags("", registry); + + expect(result).toBe(""); + }); + + it("should preserve multiline skill content", () => { + const registry = createRegistry([["jira", "Line 1\nLine 2\nLine 3"]]); + + const result = expandSkillTags("Start\njira\nEnd", registry); + + expect(result).toBe("Start\nLine 1\nLine 2\nLine 3\nEnd"); + }); + + it("should handle skill names with hyphens", () => { + const registry = createRegistry([["my-skill", "My skill content"]]); + + const result = expandSkillTags("my-skill", registry); + + expect(result).toBe("My skill content"); + }); + + it("should handle skill names with underscores", () => { + const registry = createRegistry([["my_skill", "My skill content"]]); + + const result = expandSkillTags('', registry); + + expect(result).toBe("My skill content"); + }); + }); + + describe("Real-world scenarios", () => { + it("should expand a Jira skill with custom field mappings", () => { + const registry = createRegistry([ + [ + "jira", + `## Jira Custom Field Mappings + +When creating issues, use these field mappings: +- customfield_16570 => Acceptance Criteria +- customfield_11401 => Team`, + ], + ]); + + const result = expandSkillTags("Create a bug ticket in Jira. jira", registry); + + expect(result).toContain("Create a bug ticket in Jira."); + expect(result).toContain("Jira Custom Field Mappings"); + expect(result).toContain("customfield_16570"); + }); + + it("should work with snippet-style instructions", () => { + const registry = createRegistry([ + ["careful", "Think step by step and double-check your work."], + ["testing", "Always write tests for new functionality."], + ]); + + const result = expandSkillTags( + "Implement this feature. careful testing", + registry, + ); + + expect(result).toBe( + "Implement this feature. Think step by step and double-check your work. Always write tests for new functionality.", + ); + }); + }); +}); diff --git a/src/skill-renderer.ts b/src/skill-renderer.ts new file mode 100644 index 0000000..fac9eca --- /dev/null +++ b/src/skill-renderer.ts @@ -0,0 +1,50 @@ +import { PATTERNS } from "./constants.js"; +import { logger } from "./logger.js"; +import type { SkillRegistry } from "./skill-loader.js"; + +/** + * Expands skill tags in text, replacing them with the skill's content body + * + * Supports two formats: + * 1. Self-closing: + * 2. Block format: skill-name + * + * @param text - The text containing skill tags to expand + * @param registry - The skill registry to look up skills + * @returns The text with skill tags replaced by their content + */ +export function expandSkillTags(text: string, registry: SkillRegistry): string { + let expanded = text; + + // Expand self-closing tags: + PATTERNS.SKILL_TAG_SELF_CLOSING.lastIndex = 0; + expanded = expanded.replace(PATTERNS.SKILL_TAG_SELF_CLOSING, (match, name) => { + const key = name.trim().toLowerCase(); + const skill = registry.get(key); + + if (!skill) { + logger.warn(`Skill not found: '${name}', leaving tag unchanged`); + return match; + } + + logger.debug(`Expanded skill tag: ${name}`, { source: skill.source }); + return skill.content; + }); + + // Expand block tags: skill-name + PATTERNS.SKILL_TAG_BLOCK.lastIndex = 0; + expanded = expanded.replace(PATTERNS.SKILL_TAG_BLOCK, (match, name) => { + const key = name.trim().toLowerCase(); + const skill = registry.get(key); + + if (!skill) { + logger.warn(`Skill not found: '${name}', leaving tag unchanged`); + return match; + } + + logger.debug(`Expanded skill tag: ${name}`, { source: skill.source }); + return skill.content; + }); + + return expanded; +}