Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
Expand All @@ -93,26 +104,36 @@ 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 (
parts: Array<{ type: string; text?: string }>,
): Promise<string[]> => {
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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", {
Expand Down
12 changes: 12 additions & 0 deletions schema/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <skill>name</skill> or <skill name=\"name\" /> 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,
Expand Down
2 changes: 2 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe("config", () => {

expect(config).toEqual({
logging: { debug: false },
experimental: { skillRendering: false },
installSkill: true,
hideCommandInOutput: false,
});
Expand Down Expand Up @@ -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,
});
Expand Down
33 changes: 33 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,24 @@ export interface LoggingConfig {
debug: boolean;
}

/**
* Experimental features configuration
*/
export interface ExperimentalConfig {
/** Enable skill rendering with <skill>name</skill> or <skill name="name" /> syntax */
skillRendering: boolean;
}

/**
* Configuration schema for the snippets plugin
*/
export interface SnippetsConfig {
/** Logging settings */
logging: LoggingConfig;

/** Experimental features */
experimental: ExperimentalConfig;

/** Automatically install SKILL.md to global skill directory */
installSkill: boolean;

Expand All @@ -37,6 +48,9 @@ interface RawConfig {
logging?: {
debug?: BooleanSetting;
};
experimental?: {
skillRendering?: BooleanSetting;
};
installSkill?: BooleanSetting;
hideCommandInOutput?: BooleanSetting;
}
Expand All @@ -48,6 +62,9 @@ const DEFAULT_CONFIG: SnippetsConfig = {
logging: {
debug: false,
},
experimental: {
skillRendering: false,
},
installSkill: true,
hideCommandInOutput: false,
};
Expand All @@ -68,6 +85,16 @@ const DEFAULT_CONFIG_CONTENT = `{
"debug": false
},

// Experimental features (may change or be removed)
"experimental": {
// Enable skill rendering with <skill>name</skill> or <skill name="name" /> 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
Expand Down Expand Up @@ -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,
});
Expand All @@ -184,13 +212,18 @@ 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);

return {
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,
Expand Down
8 changes: 8 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export const PATTERNS = {

/** Matches shell commands like !`command` */
SHELL_COMMAND: /!`([^`]+)`/g,

/**
* Matches skill tags in two formats:
* 1. Self-closing: <skill name="skill-name" /> or <skill name='skill-name'/>
* 2. Block format: <skill>skill-name</skill>
*/
SKILL_TAG_SELF_CLOSING: /<skill\s+name=["']([^"']+)["']\s*\/>/gi,
SKILL_TAG_BLOCK: /<skill>([^<]+)<\/skill>/gi,
} as const;

/**
Expand Down
Loading