From d06636c3e42861527e56fe065f7de326d6de5a30 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Tue, 27 Jan 2026 02:17:23 -0700 Subject: [PATCH] feat(plugin): consolidate to single systematic_skill tool Replace systematic_find_* tools with systematic_skill for skill loading and discovery via description. Add logging for plugin initialization with version tracking. --- README.md | 6 +- .../2026-01-20-systematic-implementation.md | 142 +-------- .../2026-01-20-systematic-plugin-design.md | 12 +- skills/using-systematic/SKILL.md | 10 +- src/index.ts | 146 ++++----- src/lib/skill-tool.ts | 216 +++++++++++++ tests/integration/opencode.test.ts | 30 +- tests/unit/skill-tool.test.ts | 296 ++++++++++++++++++ 8 files changed, 602 insertions(+), 256 deletions(-) create mode 100644 src/lib/skill-tool.ts create mode 100644 tests/unit/skill-tool.test.ts diff --git a/README.md b/README.md index 175837ac..39d6941b 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,9 @@ The plugin provides these tools to OpenCode: | Tool | Description | |------|-------------| -| `systematic_find_skills` | List available skills | -| `systematic_find_agents` | List available agents | -| `systematic_find_commands` | List available commands | +| `systematic_skill` | Load Systematic bundled skills | + +The bootstrap skill instructs OpenCode to use the native `skill` tool to load non-Systematic skills. ## Configuration diff --git a/docs/plans/2026-01-20-systematic-implementation.md b/docs/plans/2026-01-20-systematic-implementation.md index 9ddcca51..481282bb 100644 --- a/docs/plans/2026-01-20-systematic-implementation.md +++ b/docs/plans/2026-01-20-systematic-implementation.md @@ -719,14 +719,15 @@ const getBootstrapContent = (config: SystematicConfig, compact = false): string const configDir = path.join(homeDir, '.config/opencode') const toolMapping = compact - ? `**Tool Mapping:** TodoWrite->update_plan, Task->@mention, Skill->systematic_use_skill + ? `**Tool Mapping:** TodoWrite->update_plan, Task->@mention, Skill->systematic_skill (Systematic), skill (native) **Skills naming (priority order):** project: > user > sys:` : `**Tool Mapping for OpenCode:** When skills reference tools you don't have, substitute OpenCode equivalents: - \`TodoWrite\` → \`update_plan\` - \`Task\` tool with subagents → Use OpenCode's subagent system (@mention) -- \`Skill\` tool → \`systematic_use_skill\` custom tool +- \`Skill\` tool → \`systematic_skill\` (Systematic plugin skills) +- Native \`skill\` tool → non-Systematic skills - \`Read\`, \`Write\`, \`Edit\`, \`Bash\` → Your native tools **Skills naming (priority order):** @@ -738,7 +739,7 @@ When skills reference tools you don't have, substitute OpenCode equivalents: return ` You have access to structured engineering workflows via the systematic plugin. -**IMPORTANT: The using-systematic skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use systematic_use_skill to load "using-systematic" - that would be redundant. Use systematic_use_skill only for OTHER skills.** +**IMPORTANT: The using-systematic skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use systematic_skill to load "using-systematic" - that would be redundant. Use systematic_skill only for Systematic bundled skills and the native skill tool for everything else.** ${content} @@ -760,7 +761,7 @@ export const SystematicPlugin = async ({ client, directory }: PluginContext) => return { tool: { - systematic_use_skill: tool({ + systematic_skill: tool({ description: 'Load and read a specific skill to guide your work. Skills contain proven workflows, mandatory processes, and expert techniques.', args: { @@ -790,9 +791,9 @@ export const SystematicPlugin = async ({ client, directory }: PluginContext) => projectSkillsDir ) - if (!resolved) { - return `Error: Skill "${skill_name}" not found.\n\nRun systematic_find_skills to see available skills.` - } + if (!resolved) { + return `Error: Skill "${skill_name}" not found.\n\nUse the systematic_skill tool description to see available Systematic skills.` + } const fullContent = fs.readFileSync(resolved.skillFile, 'utf8') const { name, description } = skillsCore.extractFrontmatter(resolved.skillFile) @@ -826,120 +827,7 @@ export const SystematicPlugin = async ({ client, directory }: PluginContext) => }, }), - systematic_find_skills: tool({ - description: - 'List all available skills in the project, user, and bundled skill libraries.', - args: {}, - execute: async (): Promise => { - const projectSkills = skillsCore.findSkillsInDir(projectSkillsDir, 'project', 3) - const userSkills = skillsCore.findSkillsInDir(userSkillsDir, 'user', 3) - const bundledSkills = skillsCore.findSkillsInDir(bundledSkillsDir, 'bundled', 3) - - // Filter disabled skills - const filterDisabled = (skills: skillsCore.SkillInfo[]) => - skills.filter((s) => !config.disabled_skills.includes(s.name)) - - const allSkills = [ - ...filterDisabled(projectSkills), - ...filterDisabled(userSkills), - ...filterDisabled(bundledSkills), - ] - - if (allSkills.length === 0) { - return `No skills found. Add skills to ${bundledSkillsDir}/ or ${userSkillsDir}/` - } - - let output = 'Available skills:\n\n' - - for (const skill of allSkills) { - let namespace: string - switch (skill.sourceType) { - case 'project': - namespace = 'project:' - break - case 'user': - namespace = '' - break - default: - namespace = 'sys:' - } - - output += `${namespace}${skill.name}\n` - if (skill.description) { - output += ` ${skill.description}\n` - } - output += ` Directory: ${skill.path}\n\n` - } - - return output - }, - }), - - systematic_find_agents: tool({ - description: 'List all available review agents.', - args: {}, - execute: async (): Promise => { - const projectAgents = skillsCore.findAgentsInDir(projectAgentsDir, 'project') - const userAgents = skillsCore.findAgentsInDir(userAgentsDir, 'user') - const bundledAgents = skillsCore.findAgentsInDir(bundledAgentsDir, 'bundled') - - const seen = new Set() - const agents: Array<{ name: string; sourceType: string }> = [] - - for (const list of [projectAgents, userAgents, bundledAgents]) { - for (const agent of list) { - if (seen.has(agent.name)) continue - if (config.disabled_agents.includes(agent.name)) continue - seen.add(agent.name) - agents.push({ name: agent.name, sourceType: agent.sourceType }) - } - } - - if (agents.length === 0) { - return 'No agents available.' - } - - let output = 'Available agents:\n\n' - for (const agent of agents.sort((a, b) => a.name.localeCompare(b.name))) { - output += `- ${agent.name} (${agent.sourceType})\n` - } - - return output - }, - }), - - systematic_find_commands: tool({ - description: 'List all available commands.', - args: {}, - execute: async (): Promise => { - const projectCommands = skillsCore.findCommandsInDir(projectCommandsDir, 'project') - const userCommands = skillsCore.findCommandsInDir(userCommandsDir, 'user') - const bundledCommands = skillsCore.findCommandsInDir(bundledCommandsDir, 'bundled') - - const seen = new Set() - const commands: Array<{ name: string; sourceType: string }> = [] - - for (const list of [projectCommands, userCommands, bundledCommands]) { - for (const cmd of list) { - if (seen.has(cmd.name)) continue - if (config.disabled_commands.includes(cmd.name)) continue - seen.add(cmd.name) - commands.push({ name: cmd.name, sourceType: cmd.sourceType }) - } - } - - if (commands.length === 0) { - return 'No commands available.' - } - - let output = 'Available commands:\n\n' - for (const cmd of commands.sort((a, b) => a.name.localeCompare(b.name))) { - output += `- ${cmd.name} (${cmd.sourceType})\n` - } - - return output - }, - }), + // system lists provided by config hook; no find_* tools }, event: async () => { @@ -1837,10 +1725,8 @@ You have access to structured engineering workflows via the systematic plugin. T ## Available Tools -- `systematic_use_skill` - Load a skill to guide your work -- `systematic_find_skills` - List all available skills -- `systematic_find_agents` - List available review agents -- `systematic_find_commands` - List available commands +- `systematic_skill` - Load Systematic bundled skills +- Native `skill` tool - Load non-Systematic skills ## Core Workflow @@ -1883,7 +1769,7 @@ Each unit of work should make subsequent work easier: - Build on prior knowledge - Compound your engineering -When a skill might apply to your current task, use `systematic_use_skill` to load it. Skills provide proven workflows for common engineering tasks. +When a skill might apply to your current task, use `systematic_skill` for Systematic bundled skills or the native `skill` tool for everything else. ``` **Step 2: Commit** @@ -1910,7 +1796,7 @@ Port these skills from Superpowers with full content: **For each skill:** 1. Fetch the full SKILL.md content from Superpowers repo -2. Adapt tool references (Skill → systematic_use_skill, TodoWrite → update_plan) +2. Adapt tool references (Skill → systematic_skill for Systematic skills, Skill → native skill for non-Systematic, TodoWrite → update_plan) 3. Update namespace references (superpowers: → sys:) 4. Save to `skills//SKILL.md` 5. Commit individually @@ -2022,7 +1908,7 @@ Full documentation with installation, usage, customization, and credits. **Key Differences from Original Plan:** - Uses `tool()` from `@opencode-ai/plugin/tool` (matches Superpowers exactly) - Uses `experimental.chat.system.transform` for bootstrap injection (workaround for model reset issue per PR #228) -- `session.prompt()` still used for `systematic_use_skill` tool to inject skills into conversation +- `session.prompt()` still used for `systematic_skill` tool to inject skills into conversation - Comprehensive bash test suite modeled after Superpowers - Phase 5 explicitly requires FULL content porting, not placeholders - Test setup creates isolated environment with fixtures diff --git a/docs/plans/2026-01-20-systematic-plugin-design.md b/docs/plans/2026-01-20-systematic-plugin-design.md index 9039a4eb..54f97094 100644 --- a/docs/plans/2026-01-20-systematic-plugin-design.md +++ b/docs/plans/2026-01-20-systematic-plugin-design.md @@ -229,10 +229,7 @@ export const SystematicPlugin = async ({ client, directory }) => { return { tool: { - systematic_use_skill: tool({...}), - systematic_find_skills: tool({...}), - systematic_find_agents: tool({...}), - systematic_find_commands: tool({...}), + systematic_skill: tool({...}), }, // Workaround for session.prompt() model reset issue @@ -260,10 +257,9 @@ export default SystematicPlugin | Tool | Purpose | | -------------------------- | --------------------------------- | -| `systematic_use_skill` | Load a skill into context | -| `systematic_find_skills` | List available skills (all tiers) | -| `systematic_find_agents` | List available agents | -| `systematic_find_commands` | List available commands | +| `systematic_skill` | Load Systematic bundled skills | + +Use the native `skill` tool for non-Systematic skills. ### Bootstrap Injection diff --git a/skills/using-systematic/SKILL.md b/skills/using-systematic/SKILL.md index fc5093b3..b84294f4 100644 --- a/skills/using-systematic/SKILL.md +++ b/skills/using-systematic/SKILL.md @@ -13,7 +13,7 @@ This is not negotiable. This is not optional. You cannot rationalize your way ou ## How to Access Skills -Use the `skill` tool. When you invoke a skill, its content is loaded and presented to you—follow it directly. +Use the `systematic_skill` tool for Systematic bundled skills. Use the native `skill` tool for non-Systematic skills. When you invoke a skill, its content is loaded and presented to you—follow it directly. # Using Skills @@ -25,7 +25,7 @@ Use the `skill` tool. When you invoke a skill, its content is loaded and present digraph skill_flow { "User message received" [shape=doublecircle]; "Might any skill apply?" [shape=diamond]; - "Invoke `skill` tool" [shape=box]; + "Invoke `systematic_skill` tool" [shape=box]; "Announce: 'Using [skill] to [purpose]'" [shape=box]; "Has checklist?" [shape=diamond]; "Create todo per item" [shape=box]; @@ -33,9 +33,9 @@ digraph skill_flow { "Respond (including clarifications)" [shape=doublecircle]; "User message received" -> "Might any skill apply?"; - "Might any skill apply?" -> "Invoke `skill` tool" [label="yes, even 1%"]; + "Might any skill apply?" -> "Invoke `systematic_skill` tool" [label="yes, even 1%"]; "Might any skill apply?" -> "Respond (including clarifications)" [label="definitely not"]; - "Invoke `skill` tool" -> "Announce: 'Using [skill] to [purpose]'"; + "Invoke `systematic_skill` tool" -> "Announce: 'Using [skill] to [purpose]'"; "Announce: 'Using [skill] to [purpose]'" -> "Has checklist?"; "Has checklist?" -> "Create todo per item" [label="yes"]; "Has checklist?" -> "Follow skill exactly" [label="no"]; @@ -91,4 +91,4 @@ Skills are resolved in priority order: 2. **User skills**: `~/.config/opencode/skills/` 3. **Bundled skills**: Provided by systematic plugin -Use `systematic_find_skills` to see all available skills and their sources. +Systematic bundled skills are listed in the `systematic_skill` tool description. Use the native `skill` tool for skills outside the Systematic plugin. diff --git a/src/index.ts b/src/index.ts index 9a8690d6..f7276c63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,9 @@ import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { Plugin } from '@opencode-ai/plugin' -import { tool } from '@opencode-ai/plugin/tool' import { loadConfig, type SystematicConfig } from './lib/config.js' import { createConfigHandler } from './lib/config-handler.js' +import { createSkillTool } from './lib/skill-tool.js' import * as skillsCore from './lib/skills-core.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -14,21 +14,18 @@ const packageRoot = path.resolve(__dirname, '..') const bundledSkillsDir = path.join(packageRoot, 'skills') const bundledAgentsDir = path.join(packageRoot, 'agents') const bundledCommandsDir = path.join(packageRoot, 'commands') - -type NamedItem = { name: string; sourceType: string } - -function formatItemList( - items: NamedItem[], - emptyMessage: string, - header: string, -): string { - if (items.length === 0) return emptyMessage - - let output = header - for (const item of items) { - output += `- ${item.name} (${item.sourceType})\n` +const packageJsonPath = path.join(packageRoot, 'package.json') +let hasLoggedInit = false + +const getPackageVersion = (): string => { + try { + if (!fs.existsSync(packageJsonPath)) return 'unknown' + const content = fs.readFileSync(packageJsonPath, 'utf8') + const parsed = JSON.parse(content) as { version?: string } + return parsed.version ?? 'unknown' + } catch { + return 'unknown' } - return output } const getBootstrapContent = (config: SystematicConfig): string | null => { @@ -57,20 +54,24 @@ When skills reference tools you don't have, substitute OpenCode equivalents: - \`TodoWrite\` → \`update_plan\` - \`Task\` tool with subagents → Use OpenCode's subagent system (@mention) - \`Skill\` tool → OpenCode's native \`skill\` tool +- \`SystematicSkill\` tool → \`systematic_skill\` (Systematic plugin skills) - \`Read\`, \`Write\`, \`Edit\`, \`Bash\` → Your native tools **Skills naming:** - Bundled skills use the \`systematic:\` prefix (e.g., \`systematic:brainstorming\`) - Skills can also be invoked without prefix if unambiguous +**Skills usage:** +- Use \`systematic_skill\` to load Systematic bundled skills +- Use the native \`skill\` tool for non-Systematic skills + **Skills location:** -Bundled skills are in \`${bundledSkillsDir}/\` -Use \`systematic_find_skills\` to list all available skills.` +Bundled skills are in \`${bundledSkillsDir}/\`` return ` You have access to structured engineering workflows via the systematic plugin. -**IMPORTANT: The using-systematic skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-systematic" again - that would be redundant.** +**IMPORTANT: The using-systematic skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the systematic_skill tool to load "using-systematic" again - that would be redundant.** ${content} @@ -92,85 +93,44 @@ export const SystematicPlugin: Plugin = async ({ client, directory }) => { config: configHandler, tool: { - systematic_find_skills: tool({ - description: 'List all available skills in the bundled skill library.', - args: {}, - execute: async (): Promise => { - const bundledSkills = skillsCore.findSkillsInDir( - bundledSkillsDir, - 'bundled', - 3, - ) - - const skills = bundledSkills - .filter((s) => !config.disabled_skills.includes(s.name)) - .sort((a, b) => a.name.localeCompare(b.name)) - - if (skills.length === 0) { - return 'No skills available. Skills are bundled with the systematic plugin.' - } - - let output = 'Available skills:\n\n' - - for (const skill of skills) { - output += `systematic:${skill.name}\n` - if (skill.description) { - output += ` ${skill.description}\n` - } - output += ` Directory: ${skill.path}\n\n` - } - - return output.trim() - }, - }), - - systematic_find_agents: tool({ - description: 'List all available review agents.', - args: {}, - execute: async (): Promise => { - const bundledAgents = skillsCore.findAgentsInDir( - bundledAgentsDir, - 'bundled', - ) - - const agents = bundledAgents - .filter((a) => !config.disabled_agents.includes(a.name)) - .sort((a, b) => a.name.localeCompare(b.name)) - - return formatItemList( - agents, - 'No agents available.', - 'Available agents:\n\n', - ) - }, - }), - - systematic_find_commands: tool({ - description: 'List all available commands.', - args: {}, - execute: async (): Promise => { - const bundledCommands = skillsCore.findCommandsInDir( - bundledCommandsDir, - 'bundled', - ) - - const commands = bundledCommands - .filter( - (c) => - !config.disabled_commands.includes(c.name.replace(/^\//, '')), - ) - .sort((a, b) => a.name.localeCompare(b.name)) - - return formatItemList( - commands, - 'No commands available.', - 'Available commands:\n\n', - ) - }, + systematic_skill: createSkillTool({ + bundledSkillsDir, + disabledSkills: config.disabled_skills, }), }, 'experimental.chat.system.transform': async (_input, output) => { + if (!hasLoggedInit) { + hasLoggedInit = true + const packageVersion = getPackageVersion() + try { + await client.app.log({ + body: { + service: 'systematic', + level: 'info', + message: 'Systematic plugin initialized', + extra: { + version: packageVersion, + bootstrapEnabled: config.bootstrap.enabled, + disabledSkillsCount: config.disabled_skills.length, + disabledAgentsCount: config.disabled_agents.length, + disabledCommandsCount: config.disabled_commands.length, + }, + }, + }) + } catch { + // ignore logging failures to avoid blocking the hook + } + } + + // Skip for title generation requests + const existingSystem = output.system.join('\n').toLowerCase() + if ( + existingSystem.includes('title generator') || + existingSystem.includes('generate a title') + ) { + return + } const content = getBootstrapContent(config) if (content) { if (!output.system) { diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts new file mode 100644 index 00000000..11efdb8a --- /dev/null +++ b/src/lib/skill-tool.ts @@ -0,0 +1,216 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { ToolDefinition } from '@opencode-ai/plugin' +import { tool } from '@opencode-ai/plugin/tool' +import type { SkillInfo } from './skills-core.js' +import { findSkillsInDir, stripFrontmatter } from './skills-core.js' + +const HOOK_KEY = 'systematic_skill_tool_hooked' +const SYSTEMATIC_MARKER = '__systematic_skill_tool__' + +interface HookState { + hookedTool: ToolDefinition | null + hookedDescription: string | null + initialized: boolean +} + +const globalStore = globalThis as unknown as Record + +function getHookState(): HookState { + let state = globalStore[HOOK_KEY] + if (state == null) { + state = { + hookedTool: null, + hookedDescription: null, + initialized: false, + } + globalStore[HOOK_KEY] = state + } + return state +} + +export function isAlreadyHooked(): boolean { + const state = globalStore[HOOK_KEY] + return state != null && state.initialized +} + +export function setHookedTool(hookedTool: ToolDefinition | null): void { + const state = getHookState() + state.hookedTool = hookedTool + state.initialized = true + if (hookedTool != null) { + state.hookedDescription = + typeof hookedTool.description === 'string' ? hookedTool.description : null + } else { + state.hookedDescription = null + } +} + +export function getHookedTool(): ToolDefinition | null { + return getHookState().hookedTool +} + +export function resetHookState(): void { + delete globalStore[HOOK_KEY] +} + +function formatSkillsXml(skills: SkillInfo[]): string { + if (skills.length === 0) return '' + + const skillsXml = skills + .map((skill) => { + const lines = [ + ' ', + ` systematic:${skill.name}`, + ` ${skill.description}`, + ] + lines.push(' ') + return lines.join('\n') + }) + .join('\n') + + return `\n${skillsXml}\n` +} + +function mergeDescriptions( + baseDescription: string, + hookedDescription: string | null, + systematicSkillsXml: string +): string { + if (hookedDescription == null || hookedDescription.trim() === '') { + return `${baseDescription}\n\n${systematicSkillsXml}` + } + + const availableSkillsMatch = hookedDescription.match( + /([\s\S]*?)<\/available_skills>/ + ) + + if (availableSkillsMatch) { + const existingSkillsContent = availableSkillsMatch[1] + const systematicSkillsContent = systematicSkillsXml + .replace('', '') + .replace('', '') + .trim() + + const mergedContent = `\n${systematicSkillsContent}\n${existingSkillsContent}` + return hookedDescription.replace( + /[\s\S]*?<\/available_skills>/, + mergedContent + ) + } + + return `${hookedDescription}\n\n${systematicSkillsXml}` +} + +function wrapSkillContent(skillPath: string, content: string): string { + const skillDir = path.dirname(skillPath) + const body = stripFrontmatter(content) + + return ` +Base directory for this skill: ${skillDir}/ +File references (@path) in this skill are relative to this directory. + +${body.trim()} +` +} + +export interface SkillToolOptions { + bundledSkillsDir: string + disabledSkills: string[] +} + +export function createSkillTool(options: SkillToolOptions): ToolDefinition { + const { bundledSkillsDir, disabledSkills } = options + + const getSystematicSkills = (): SkillInfo[] => { + return findSkillsInDir(bundledSkillsDir, 'bundled', 3) + .filter((s) => !disabledSkills.includes(s.name)) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + const buildDescription = (): string => { + const skills = getSystematicSkills() + const systematicXml = formatSkillsXml(skills) + + const baseDescription = `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.` + + const hookState = getHookState() + return mergeDescriptions( + baseDescription, + hookState.hookedDescription, + systematicXml + ) + } + + let cachedDescription: string | null = null + + const toolDef = tool({ + get description() { + if (cachedDescription == null) { + cachedDescription = buildDescription() + } + return cachedDescription + }, + args: { + name: tool.schema + .string() + .describe( + "The skill identifier from available_skills (e.g., 'systematic:brainstorming')" + ), + }, + async execute(args: { name: string }): Promise { + const requestedName = args.name + + const normalizedName = requestedName.startsWith('systematic:') + ? requestedName.slice('systematic:'.length) + : requestedName + + const skills = getSystematicSkills() + const matchedSkill = skills.find((s) => s.name === normalizedName) + + if (matchedSkill) { + try { + const content = fs.readFileSync(matchedSkill.skillFile, 'utf8') + const wrapped = wrapSkillContent(matchedSkill.skillFile, content) + + return `## Skill: systematic:${matchedSkill.name} + +**Base directory**: ${matchedSkill.path} + +${wrapped}` + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to load skill "${requestedName}": ${errorMessage}` + ) + } + } + + const hookedTool = getHookedTool() + if (hookedTool != null && typeof hookedTool.execute === 'function') { + try { + return await (hookedTool.execute as (args: { name: string }) => Promise)(args) + } catch { + // Fallback failed, continue to error below + } + } + + const availableSystematic = skills.map((s) => `systematic:${s.name}`) + throw new Error( + `Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}` + ) + }, + }) + + Object.defineProperty(toolDef, SYSTEMATIC_MARKER, { + value: true, + enumerable: false, + writable: false, + }) + + return toolDef +} diff --git a/tests/integration/opencode.test.ts b/tests/integration/opencode.test.ts index 5e845a63..4b31fd30 100644 --- a/tests/integration/opencode.test.ts +++ b/tests/integration/opencode.test.ts @@ -98,36 +98,28 @@ describe.skipIf(!OPENCODE_AVAILABLE)('opencode integration', () => { } test( - 'systematic_find_skills tool discovers bundled skills', + 'systematic_skill tool loads systematic:brainstorming skill', async () => { - const result = await runOpencode('Call systematic_find_skills') - - expect(result.stdout.toLowerCase()).toMatch( - /brainstorming|systematic:.*|available skills/i, + const result = await runOpencode( + 'Use the systematic_skill tool to load systematic:brainstorming', ) - }, - TIMEOUT_MS * MAX_RETRIES, - ) - test( - 'systematic_find_agents tool discovers bundled agents', - async () => { - const result = await runOpencode('Call systematic_find_agents') - - expect(result.stdout.toLowerCase()).toMatch( - /architecture-strategist|security-sentinel|available.*agents|bundled/i, + expect(result.stdout).toMatch( + /|brainstorming|systematic/i, ) }, TIMEOUT_MS * MAX_RETRIES, ) test( - 'systematic_find_commands tool discovers bundled commands', + 'systematic_skill tool lists systematic skills in description', async () => { - const result = await runOpencode('Call systematic_find_commands') + const result = await runOpencode( + 'What skills are available? List the systematic skills you can load.', + ) - expect(result.stdout.toLowerCase()).toMatch( - /\/lfg|\/workflows|available.*commands|bundled/i, + expect(result.stdout).toMatch( + /systematic:brainstorming|systematic:.*|available.*skills/i, ) }, TIMEOUT_MS * MAX_RETRIES, diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts new file mode 100644 index 00000000..bc58c666 --- /dev/null +++ b/tests/unit/skill-tool.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + createSkillTool, + getHookedTool, + isAlreadyHooked, + resetHookState, + setHookedTool, +} from '../../src/lib/skill-tool.ts' + +const mockContext = {} as never + +describe('skill-tool', () => { + let testDir: string + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'systematic-skill-test-')) + resetHookState() + }) + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }) + resetHookState() + }) + + describe('hook state management', () => { + test('isAlreadyHooked returns false before initialization', () => { + expect(isAlreadyHooked()).toBe(false) + }) + + test('isAlreadyHooked returns true after setHookedTool', () => { + setHookedTool(null) + expect(isAlreadyHooked()).toBe(true) + }) + + test('getHookedTool returns null after initialization with null', () => { + setHookedTool(null) + expect(getHookedTool()).toBeNull() + }) + + test('getHookedTool returns set tool', () => { + const mockTool = { + description: 'Mock tool', + args: {}, + execute: async () => 'result', + } + setHookedTool(mockTool as never) + expect(getHookedTool()).toBe(mockTool) + }) + }) + + describe('createSkillTool', () => { + test('creates tool with description property', () => { + const skillDir = path.join(testDir, 'test-skill') + fs.mkdirSync(skillDir) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: test-skill +description: A test skill for unit testing +--- +# Test Skill Content`, + ) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + expect(tool.description).toContain('systematic:test-skill') + expect(tool.description).toContain('A test skill for unit testing') + }) + + test('filters out disabled skills from description', () => { + const skill1Dir = path.join(testDir, 'enabled-skill') + const skill2Dir = path.join(testDir, 'disabled-skill') + fs.mkdirSync(skill1Dir) + fs.mkdirSync(skill2Dir) + + fs.writeFileSync( + path.join(skill1Dir, 'SKILL.md'), + `--- +name: enabled-skill +description: Enabled +--- +# Content`, + ) + + fs.writeFileSync( + path.join(skill2Dir, 'SKILL.md'), + `--- +name: disabled-skill +description: Disabled +--- +# Content`, + ) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: ['disabled-skill'], + }) + + expect(tool.description).toContain('systematic:enabled-skill') + expect(tool.description).not.toContain('systematic:disabled-skill') + }) + + test('merges hooked tool description', () => { + const skillDir = path.join(testDir, 'my-skill') + fs.mkdirSync(skillDir) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: my-skill +description: My skill +--- +# Content`, + ) + + const mockHookedTool = { + description: `Load a skill. + + + + other-skill + Other skill from hooked tool + +`, + args: {}, + execute: async () => 'hooked result', + } + + setHookedTool(mockHookedTool as never) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + expect(tool.description).toContain('systematic:my-skill') + expect(tool.description).toContain('other-skill') + }) + }) + + describe('execute', () => { + test('loads systematic skill with prefix', async () => { + const skillDir = path.join(testDir, 'load-test') + fs.mkdirSync(skillDir) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: load-test +description: Skill for loading test +--- +# Load Test Skill + +This is the skill content.`, + ) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + const result = await tool.execute( + { name: 'systematic:load-test' }, + mockContext, + ) + + expect(result).toContain('systematic:load-test') + expect(result).toContain('') + expect(result).toContain('# Load Test Skill') + expect(result).toContain('This is the skill content.') + }) + + test('loads systematic skill without prefix', async () => { + const skillDir = path.join(testDir, 'no-prefix') + fs.mkdirSync(skillDir) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: no-prefix +description: Test +--- +# No Prefix Content`, + ) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + const result = await tool.execute({ name: 'no-prefix' }, mockContext) + + expect(result).toContain('systematic:no-prefix') + expect(result).toContain('# No Prefix Content') + }) + + test('falls back to hooked tool for unknown skill', async () => { + fs.mkdirSync(path.join(testDir, 'known-skill')) + fs.writeFileSync( + path.join(testDir, 'known-skill', 'SKILL.md'), + `--- +name: known-skill +description: Known +--- +# Known`, + ) + + const mockHookedTool = { + description: 'Mock', + args: {}, + execute: async (args: { name: string }) => + `Hooked skill loaded: ${args.name}`, + } + + setHookedTool(mockHookedTool as never) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + const result = await tool.execute( + { name: 'unknown-external-skill' }, + mockContext, + ) + + expect(result).toBe('Hooked skill loaded: unknown-external-skill') + }) + + test('throws error when skill not found and no hooked tool', async () => { + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + await expect( + tool.execute({ name: 'nonexistent' }, mockContext), + ).rejects.toThrow('Skill "nonexistent" not found') + }) + + test('strips frontmatter from loaded skill content', async () => { + const skillDir = path.join(testDir, 'frontmatter-strip') + fs.mkdirSync(skillDir) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: frontmatter-strip +description: Test frontmatter stripping +--- +# Actual Content + +No frontmatter visible here.`, + ) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + const result = await tool.execute( + { name: 'frontmatter-strip' }, + mockContext, + ) + + expect(result).not.toContain('description: Test frontmatter stripping') + expect(result).toContain('# Actual Content') + }) + + test('wraps content with skill_instruction tags', async () => { + const skillDir = path.join(testDir, 'wrap-test') + fs.mkdirSync(skillDir) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: wrap-test +description: Test wrapper +--- +# Wrapped Content`, + ) + + const tool = createSkillTool({ + bundledSkillsDir: testDir, + disabledSkills: [], + }) + + const result = await tool.execute({ name: 'wrap-test' }, mockContext) + + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('Base directory for this skill:') + }) + }) +})