diff --git a/package.json b/package.json
index cd2253f0..12f70ee7 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
],
"scripts": {
"clean": "rimraf dist",
- "build": "bun run clean && bun build src/index.ts src/cli.ts --outdir dist --target bun --splitting --packages external",
+ "build": "bun run clean && bun build src/index.ts src/cli.ts --outdir dist --target bun --splitting --packages external && tsc --emitDeclarationOnly",
"dev": "bun --watch src/index.ts",
"test": "bun test tests/unit",
"test:integration": "bun test tests/integration",
@@ -29,7 +29,7 @@
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"fix": "bun run lint --fix",
- "prepublishOnly": "bun run build && bun run test"
+ "prepublishOnly": "bun run build"
},
"keywords": [
"opencode",
diff --git a/src/cli.ts b/src/cli.ts
index 71dc5013..8cbad8de 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,12 +1,15 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
+import * as agents from './lib/agents.js'
+import * as commands from './lib/commands.js'
+import { getConfigPaths } from './lib/config.js'
import {
type AgentMode,
type ContentType,
convertContent,
} from './lib/converter.js'
-import * as skillsCore from './lib/skills-core.js'
+import * as skills from './lib/skills.js'
const VERSION = '0.1.0'
@@ -37,38 +40,24 @@ Examples:
systematic config show
`
-function getUserConfigDir(): string {
- return path.join(
- process.env.HOME || process.env.USERPROFILE || '.',
- '.config/opencode',
- )
-}
-
-function getProjectConfigDir(): string {
- return path.join(process.cwd(), '.opencode')
-}
-
function listItems(type: string): void {
const packageRoot = path.resolve(import.meta.dirname, '..')
const bundledDir = packageRoot
- let finder: (
- dir: string,
- sourceType: 'bundled',
- ) => Array<{ name: string; sourceType: string }>
+ let finder: (dir: string) => Array<{ name: string }>
let subdir: string
switch (type) {
case 'skills':
- finder = skillsCore.findSkillsInDir
+ finder = skills.findSkillsInDir
subdir = 'skills'
break
case 'agents':
- finder = skillsCore.findAgentsInDir
+ finder = agents.findAgentsInDir
subdir = 'agents'
break
case 'commands':
- finder = skillsCore.findCommandsInDir
+ finder = commands.findCommandsInDir
subdir = 'commands'
break
default:
@@ -76,7 +65,7 @@ function listItems(type: string): void {
process.exit(1)
}
- const items = finder(path.join(bundledDir, subdir), 'bundled')
+ const items = finder(path.join(bundledDir, subdir))
if (items.length === 0) {
console.log(`No ${type} found.`)
@@ -85,7 +74,7 @@ function listItems(type: string): void {
console.log(`Available ${type}:\n`)
for (const item of items.sort((a, b) => a.name.localeCompare(b.name))) {
- console.log(` ${item.name} (${item.sourceType})`)
+ console.log(` ${item.name}`)
}
}
@@ -124,33 +113,29 @@ function runConvert(type: string, filePath: string, modeArg?: string): void {
}
function configShow(): void {
- const userDir = getUserConfigDir()
- const projectDir = getProjectConfigDir()
+ const paths = getConfigPaths(process.cwd())
console.log('Configuration locations:\n')
- console.log(` User config: ${path.join(userDir, 'systematic.json')}`)
- console.log(` Project config: ${path.join(projectDir, 'systematic.json')}`)
+ console.log(` User config: ${paths.userConfig}`)
+ console.log(` Project config: ${paths.projectConfig}`)
- const projectConfig = path.join(projectDir, 'systematic.json')
- if (fs.existsSync(projectConfig)) {
+ if (fs.existsSync(paths.projectConfig)) {
console.log('\nProject configuration:')
- console.log(fs.readFileSync(projectConfig, 'utf-8'))
+ console.log(fs.readFileSync(paths.projectConfig, 'utf-8'))
}
- const userConfig = path.join(userDir, 'systematic.json')
- if (fs.existsSync(userConfig)) {
+ if (fs.existsSync(paths.userConfig)) {
console.log('\nUser configuration:')
- console.log(fs.readFileSync(userConfig, 'utf-8'))
+ console.log(fs.readFileSync(paths.userConfig, 'utf-8'))
}
}
function configPath(): void {
- const userDir = getUserConfigDir()
- const projectDir = getProjectConfigDir()
+ const paths = getConfigPaths(process.cwd())
console.log('Config file paths:')
- console.log(` User: ${path.join(userDir, 'systematic.json')}`)
- console.log(` Project: ${path.join(projectDir, 'systematic.json')}`)
+ console.log(` User: ${paths.userConfig}`)
+ console.log(` Project: ${paths.projectConfig}`)
}
const args = process.argv.slice(2)
diff --git a/src/index.ts b/src/index.ts
index f7276c63..21649229 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,12 +1,11 @@
import fs from 'node:fs'
-import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Plugin } from '@opencode-ai/plugin'
-import { loadConfig, type SystematicConfig } from './lib/config.js'
+import { getBootstrapContent } from './lib/bootstrap.js'
+import { loadConfig } 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))
@@ -28,57 +27,6 @@ const getPackageVersion = (): string => {
}
}
-const getBootstrapContent = (config: SystematicConfig): string | null => {
- if (!config.bootstrap.enabled) return null
-
- if (config.bootstrap.file) {
- const customPath = config.bootstrap.file.startsWith('~/')
- ? path.join(os.homedir(), config.bootstrap.file.slice(2))
- : config.bootstrap.file
- if (fs.existsSync(customPath)) {
- return fs.readFileSync(customPath, 'utf8')
- }
- }
-
- const usingSystematicPath = path.join(
- bundledSkillsDir,
- 'using-systematic/SKILL.md',
- )
- if (!fs.existsSync(usingSystematicPath)) return null
-
- const fullContent = fs.readFileSync(usingSystematicPath, 'utf8')
- const content = skillsCore.stripFrontmatter(fullContent)
-
- const toolMapping = `**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 → 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}/\``
-
- 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 systematic_skill tool to load "using-systematic" again - that would be redundant.**
-
-${content}
-
-${toolMapping}
-`
-}
-
export const SystematicPlugin: Plugin = async ({ client, directory }) => {
const config = loadConfig(directory)
@@ -131,7 +79,7 @@ export const SystematicPlugin: Plugin = async ({ client, directory }) => {
) {
return
}
- const content = getBootstrapContent(config)
+ const content = getBootstrapContent(config, { bundledSkillsDir })
if (content) {
if (!output.system) {
output.system = []
diff --git a/src/lib/agents.ts b/src/lib/agents.ts
new file mode 100644
index 00000000..c438fccd
--- /dev/null
+++ b/src/lib/agents.ts
@@ -0,0 +1,54 @@
+import { walkDir } from './walk-dir.js'
+import { stripFrontmatter } from './frontmatter.js'
+
+export interface AgentFrontmatter {
+ name: string
+ description: string
+ prompt: string
+}
+
+export interface AgentInfo {
+ name: string
+ file: string
+ category?: string
+}
+
+export function findAgentsInDir(dir: string, maxDepth = 2): AgentInfo[] {
+ const entries = walkDir(dir, {
+ maxDepth,
+ filter: (e) => !e.isDirectory && e.name.endsWith('.md'),
+ })
+
+ return entries.map((entry) => ({
+ name: entry.name.replace(/\.md$/, ''),
+ file: entry.path,
+ category: entry.category,
+ }))
+}
+
+export function extractAgentFrontmatter(content: string): AgentFrontmatter {
+ const lines = content.split('\n')
+
+ let inFrontmatter = false
+ let name = ''
+ let description = ''
+
+ for (const line of lines) {
+ if (line.trim() === '---') {
+ if (inFrontmatter) break
+ inFrontmatter = true
+ continue
+ }
+
+ if (inFrontmatter) {
+ const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/)
+ if (match) {
+ const [, key, value] = match
+ if (key === 'name') name = value.trim()
+ if (key === 'description') description = value.trim()
+ }
+ }
+ }
+
+ return { name, description, prompt: stripFrontmatter(content) }
+}
diff --git a/src/lib/bootstrap.ts b/src/lib/bootstrap.ts
new file mode 100644
index 00000000..aac5a346
--- /dev/null
+++ b/src/lib/bootstrap.ts
@@ -0,0 +1,68 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import type { SystematicConfig } from './config.js'
+import { stripFrontmatter } from './frontmatter.js'
+
+export interface BootstrapDeps {
+ bundledSkillsDir: string
+}
+
+function getToolMappingTemplate(bundledSkillsDir: string): string {
+ return `**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 → 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}/\``
+}
+
+export function getBootstrapContent(
+ config: SystematicConfig,
+ deps: BootstrapDeps,
+): string | null {
+ const { bundledSkillsDir } = deps
+
+ if (!config.bootstrap.enabled) return null
+
+ if (config.bootstrap.file) {
+ const customPath = config.bootstrap.file.startsWith('~/')
+ ? path.join(os.homedir(), config.bootstrap.file.slice(2))
+ : config.bootstrap.file
+ if (fs.existsSync(customPath)) {
+ return fs.readFileSync(customPath, 'utf8')
+ }
+ }
+
+ const usingSystematicPath = path.join(
+ bundledSkillsDir,
+ 'using-systematic/SKILL.md',
+ )
+ if (!fs.existsSync(usingSystematicPath)) return null
+
+ const fullContent = fs.readFileSync(usingSystematicPath, 'utf8')
+ const content = stripFrontmatter(fullContent)
+ const toolMapping = getToolMappingTemplate(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 systematic_skill tool to load "using-systematic" again - that would be redundant.**
+
+${content}
+
+${toolMapping}
+`
+}
diff --git a/src/lib/commands.ts b/src/lib/commands.ts
new file mode 100644
index 00000000..23a26a4d
--- /dev/null
+++ b/src/lib/commands.ts
@@ -0,0 +1,59 @@
+import { walkDir } from './walk-dir.js'
+
+export interface CommandFrontmatter {
+ name: string
+ description: string
+ argumentHint: string
+}
+
+export interface CommandInfo {
+ name: string
+ file: string
+ category?: string
+}
+
+export function findCommandsInDir(dir: string, maxDepth = 2): CommandInfo[] {
+ const entries = walkDir(dir, {
+ maxDepth,
+ filter: (e) => !e.isDirectory && e.name.endsWith('.md'),
+ })
+
+ return entries.map((entry) => {
+ const baseName = entry.name.replace(/\.md$/, '')
+ const commandName = entry.category ? `/${entry.category}:${baseName}` : `/${baseName}`
+ return {
+ name: commandName,
+ file: entry.path,
+ category: entry.category,
+ }
+ })
+}
+
+export function extractCommandFrontmatter(content: string): CommandFrontmatter {
+ const lines = content.split('\n')
+
+ let inFrontmatter = false
+ let name = ''
+ let description = ''
+ let argumentHint = ''
+
+ for (const line of lines) {
+ if (line.trim() === '---') {
+ if (inFrontmatter) break
+ inFrontmatter = true
+ continue
+ }
+
+ if (inFrontmatter) {
+ const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/)
+ if (match) {
+ const [, key, value] = match
+ if (key === 'name') name = value.trim()
+ if (key === 'description') description = value.trim()
+ if (key === 'argument-hint') argumentHint = value.trim().replace(/^["']|["']$/g, '')
+ }
+ }
+ }
+
+ return { name, description, argumentHint }
+}
diff --git a/src/lib/config-handler.ts b/src/lib/config-handler.ts
index 86f17374..fcfb8186 100644
--- a/src/lib/config-handler.ts
+++ b/src/lib/config-handler.ts
@@ -1,7 +1,10 @@
import type { AgentConfig, Config } from '@opencode-ai/sdk'
import { loadConfig } from './config.js'
import { convertFileWithCache } from './converter.js'
-import * as skillsCore from './skills-core.js'
+import { stripFrontmatter } from './frontmatter.js'
+import { extractAgentFrontmatter, findAgentsInDir } from './agents.js'
+import { extractCommandFrontmatter, findCommandsInDir } from './commands.js'
+import { type SkillInfo, findSkillsInDir } from './skills.js'
export interface ConfigHandlerDeps {
directory: string
@@ -13,18 +16,18 @@ export interface ConfigHandlerDeps {
type CommandConfig = NonNullable[string]
function loadAgentAsConfig(
- agentInfo: { name: string; file: string; sourceType: string; category?: string }
+ agentInfo: { name: string; file: string; category?: string }
): AgentConfig | null {
try {
const converted = convertFileWithCache(agentInfo.file, 'agent', {
source: 'bundled',
agentMode: 'subagent',
})
- const { description, prompt } = skillsCore.extractAgentFrontmatter(converted)
+ const { description, prompt } = extractAgentFrontmatter(converted)
return {
description: description || `${agentInfo.name} agent`,
- prompt: prompt || skillsCore.stripFrontmatter(converted),
+ prompt: prompt || stripFrontmatter(converted),
}
} catch {
return null
@@ -32,16 +35,16 @@ function loadAgentAsConfig(
}
function loadCommandAsConfig(
- commandInfo: { name: string; file: string; sourceType: string; category?: string }
+ commandInfo: { name: string; file: string; category?: string }
): CommandConfig | null {
try {
const converted = convertFileWithCache(commandInfo.file, 'command', { source: 'bundled' })
- const { name, description } = skillsCore.extractCommandFrontmatter(converted)
+ const { name, description } = extractCommandFrontmatter(converted)
const cleanName = commandInfo.name.replace(/^\//, '')
return {
- template: skillsCore.stripFrontmatter(converted),
+ template: stripFrontmatter(converted),
description: description || `${name || cleanName} command`,
}
} catch {
@@ -50,13 +53,13 @@ function loadCommandAsConfig(
}
function loadSkillAsCommand(
- skillInfo: skillsCore.SkillInfo
+ skillInfo: SkillInfo
): CommandConfig | null {
try {
const converted = convertFileWithCache(skillInfo.skillFile, 'skill', { source: 'bundled' })
return {
- template: skillsCore.stripFrontmatter(converted),
+ template: stripFrontmatter(converted),
description: skillInfo.description || `${skillInfo.name} skill`,
}
} catch {
@@ -66,11 +69,10 @@ function loadSkillAsCommand(
function collectAgents(
dir: string,
- sourceType: 'bundled',
disabledAgents: string[]
): NonNullable {
const agents: NonNullable = {}
- const agentList = skillsCore.findAgentsInDir(dir, sourceType)
+ const agentList = findAgentsInDir(dir)
for (const agentInfo of agentList) {
if (disabledAgents.includes(agentInfo.name)) continue
@@ -86,11 +88,10 @@ function collectAgents(
function collectCommands(
dir: string,
- sourceType: 'bundled',
disabledCommands: string[]
): NonNullable {
const commands: NonNullable = {}
- const commandList = skillsCore.findCommandsInDir(dir, sourceType)
+ const commandList = findCommandsInDir(dir)
for (const commandInfo of commandList) {
const cleanName = commandInfo.name.replace(/^\//, '')
@@ -107,11 +108,10 @@ function collectCommands(
function collectSkillsAsCommands(
dir: string,
- sourceType: 'bundled',
disabledSkills: string[]
): NonNullable {
const commands: NonNullable = {}
- const skillList = skillsCore.findSkillsInDir(dir, sourceType, 3)
+ const skillList = findSkillsInDir(dir)
for (const skillInfo of skillList) {
if (disabledSkills.includes(skillInfo.name)) continue
@@ -142,19 +142,16 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
const bundledAgents = collectAgents(
bundledAgentsDir,
- 'bundled',
systematicConfig.disabled_agents
)
const bundledCommands = collectCommands(
bundledCommandsDir,
- 'bundled',
systematicConfig.disabled_commands
)
const bundledSkills = collectSkillsAsCommands(
bundledSkillsDir,
- 'bundled',
systematicConfig.disabled_skills
)
diff --git a/src/lib/converter.ts b/src/lib/converter.ts
index 0b1f0c4f..3d76c7ca 100644
--- a/src/lib/converter.ts
+++ b/src/lib/converter.ts
@@ -1,4 +1,5 @@
import fs from 'node:fs'
+import { parseFrontmatter, formatFrontmatter, type ParsedFrontmatter } from './frontmatter.js'
export type ContentType = 'skill' | 'agent' | 'command'
export type SourceType = 'bundled' | 'external'
@@ -16,58 +17,6 @@ interface CacheEntry {
const cache = new Map()
-interface ParsedFrontmatter {
- data: Record
- body: string
- raw: string
-}
-
-function parseFrontmatter(content: string): ParsedFrontmatter {
- const lines = content.split(/\r?\n/)
- if (lines.length === 0 || lines[0].trim() !== '---') {
- return { data: {}, body: content, raw: '' }
- }
-
- let endIndex = -1
- for (let i = 1; i < lines.length; i++) {
- if (lines[i].trim() === '---') {
- endIndex = i
- break
- }
- }
-
- if (endIndex === -1) {
- return { data: {}, body: content, raw: '' }
- }
-
- const yamlLines = lines.slice(1, endIndex)
- const body = lines.slice(endIndex + 1).join('\n')
- const raw = lines.slice(0, endIndex + 1).join('\n')
- const data: Record = {}
-
- for (const line of yamlLines) {
- const match = line.match(/^([\w-]+):\s*(.*)$/)
- if (match) {
- const [, key, value] = match
- if (value === 'true') data[key] = true
- else if (value === 'false') data[key] = false
- else if (/^\d+(\.\d+)?$/.test(value)) data[key] = parseFloat(value)
- else data[key] = value
- }
- }
-
- return { data, body, raw }
-}
-
-function formatFrontmatter(data: Record): string {
- const lines: string[] = ['---']
- for (const [key, value] of Object.entries(data)) {
- lines.push(`${key}: ${value}`)
- }
- lines.push('---')
- return lines.join('\n')
-}
-
function inferTemperature(name: string, description?: string): number {
const sample = `${name} ${description ?? ''}`.toLowerCase()
if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
diff --git a/src/lib/frontmatter.ts b/src/lib/frontmatter.ts
new file mode 100644
index 00000000..6777c80c
--- /dev/null
+++ b/src/lib/frontmatter.ts
@@ -0,0 +1,77 @@
+interface ParsedFrontmatter {
+ data: Record
+ body: string
+ raw: string
+}
+
+export type { ParsedFrontmatter }
+
+export function parseFrontmatter(content: string): ParsedFrontmatter {
+ const lines = content.split(/\r?\n/)
+ if (lines.length === 0 || lines[0].trim() !== '---') {
+ return { data: {}, body: content, raw: '' }
+ }
+
+ let endIndex = -1
+ for (let i = 1; i < lines.length; i++) {
+ if (lines[i].trim() === '---') {
+ endIndex = i
+ break
+ }
+ }
+
+ if (endIndex === -1) {
+ return { data: {}, body: content, raw: '' }
+ }
+
+ const yamlLines = lines.slice(1, endIndex)
+ const body = lines.slice(endIndex + 1).join('\n')
+ const raw = lines.slice(0, endIndex + 1).join('\n')
+ const data: Record = {}
+
+ for (const line of yamlLines) {
+ const match = line.match(/^([\w-]+):\s*(.*)$/)
+ if (match) {
+ const [, key, value] = match
+ if (value === 'true') data[key] = true
+ else if (value === 'false') data[key] = false
+ else if (/^\d+(\.\d+)?$/.test(value)) data[key] = parseFloat(value)
+ else data[key] = value
+ }
+ }
+
+ return { data, body, raw }
+}
+
+export function formatFrontmatter(data: Record): string {
+ const lines: string[] = ['---']
+ for (const [key, value] of Object.entries(data)) {
+ lines.push(`${key}: ${value}`)
+ }
+ lines.push('---')
+ return lines.join('\n')
+}
+
+export function stripFrontmatter(content: string): string {
+ const lines = content.split('\n')
+ let inFrontmatter = false
+ let frontmatterEnded = false
+ const contentLines: string[] = []
+
+ for (const line of lines) {
+ if (line.trim() === '---') {
+ if (inFrontmatter) {
+ frontmatterEnded = true
+ continue
+ }
+ inFrontmatter = true
+ continue
+ }
+
+ if (frontmatterEnded || !inFrontmatter) {
+ contentLines.push(line)
+ }
+ }
+
+ return contentLines.join('\n').trim()
+}
diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts
index a8a72f2e..b79ea62b 100644
--- a/src/lib/skill-tool.ts
+++ b/src/lib/skill-tool.ts
@@ -3,105 +3,10 @@ import path from 'node:path'
import type { ToolDefinition } from '@opencode-ai/plugin'
import { tool } from '@opencode-ai/plugin/tool'
import { convertContent } from './converter.js'
-import type { SkillInfo } from './skills-core.js'
-import { findSkillsInDir, stripFrontmatter } from './skills-core.js'
+import { stripFrontmatter } from './frontmatter.js'
+import type { SkillInfo } from './skills.js'
+import { findSkillsInDir, formatSkillsXml } from './skills.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)
@@ -125,7 +30,7 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
const { bundledSkillsDir, disabledSkills } = options
const getSystematicSkills = (): SkillInfo[] => {
- return findSkillsInDir(bundledSkillsDir, 'bundled', 3)
+ return findSkillsInDir(bundledSkillsDir)
.filter((s) => !disabledSkills.includes(s.name))
.sort((a, b) => a.name.localeCompare(b.name))
}
@@ -139,17 +44,12 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
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
- )
+ return `${baseDescription}\n\n${systematicXml}`
}
let cachedDescription: string | null = null
- const toolDef = tool({
+ return tool({
get description() {
if (cachedDescription == null) {
cachedDescription = buildDescription()
@@ -192,27 +92,10 @@ ${wrapped}`
}
}
- 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/src/lib/skills-core.ts b/src/lib/skills-core.ts
deleted file mode 100644
index d0fe0e3b..00000000
--- a/src/lib/skills-core.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-import fs from 'node:fs'
-import path from 'node:path'
-
-export interface SkillFrontmatter {
- name: string
- description: string
-}
-
-export interface SkillInfo {
- path: string
- skillFile: string
- name: string
- description: string
- sourceType: 'bundled'
-}
-
-export function extractFrontmatter(filePath: string): SkillFrontmatter {
- try {
- const content = fs.readFileSync(filePath, 'utf8')
- const lines = content.split('\n')
-
- let inFrontmatter = false
- let name = ''
- let description = ''
-
- for (const line of lines) {
- if (line.trim() === '---') {
- if (inFrontmatter) break
- inFrontmatter = true
- continue
- }
-
- if (inFrontmatter) {
- const match = line.match(/^(\w+):\s*(.*)$/)
- if (match) {
- const [, key, value] = match
- if (key === 'name') name = value.trim()
- if (key === 'description') description = value.trim()
- }
- }
- }
-
- return { name, description }
- } catch {
- return { name: '', description: '' }
- }
-}
-
-export function stripFrontmatter(content: string): string {
- const lines = content.split('\n')
- let inFrontmatter = false
- let frontmatterEnded = false
- const contentLines: string[] = []
-
- for (const line of lines) {
- if (line.trim() === '---') {
- if (inFrontmatter) {
- frontmatterEnded = true
- continue
- }
- inFrontmatter = true
- continue
- }
-
- if (frontmatterEnded || !inFrontmatter) {
- contentLines.push(line)
- }
- }
-
- return contentLines.join('\n').trim()
-}
-
-export function findSkillsInDir(
- dir: string,
- sourceType: 'bundled',
- maxDepth = 3
-): SkillInfo[] {
- const skills: SkillInfo[] = []
-
- if (!fs.existsSync(dir)) return skills
-
- function recurse(currentDir: string, depth: number) {
- if (depth > maxDepth) return
-
- const entries = fs.readdirSync(currentDir, { withFileTypes: true })
-
- for (const entry of entries) {
- const fullPath = path.join(currentDir, entry.name)
-
- if (entry.isDirectory()) {
- const skillFile = path.join(fullPath, 'SKILL.md')
- if (fs.existsSync(skillFile)) {
- const { name, description } = extractFrontmatter(skillFile)
- skills.push({
- path: fullPath,
- skillFile,
- name: name || entry.name,
- description: description || '',
- sourceType,
- })
- }
- recurse(fullPath, depth + 1)
- }
- }
- }
-
- recurse(dir, 0)
- return skills
-}
-
-export function findAgentsInDir(
- dir: string,
- sourceType: 'bundled',
- maxDepth = 2
-): Array<{ name: string; file: string; sourceType: 'bundled'; category?: string }> {
- const agents: Array<{ name: string; file: string; sourceType: 'bundled'; category?: string }> = []
-
- if (!fs.existsSync(dir)) return agents
-
- function recurse(currentDir: string, depth: number, category?: string) {
- if (depth > maxDepth) return
-
- const entries = fs.readdirSync(currentDir, { withFileTypes: true })
- for (const entry of entries) {
- const fullPath = path.join(currentDir, entry.name)
-
- if (entry.isDirectory()) {
- recurse(fullPath, depth + 1, entry.name)
- } else if (entry.name.endsWith('.md')) {
- agents.push({
- name: entry.name.replace(/\.md$/, ''),
- file: fullPath,
- sourceType,
- category,
- })
- }
- }
- }
-
- recurse(dir, 0)
- return agents
-}
-
-export function findCommandsInDir(
- dir: string,
- sourceType: 'bundled',
- maxDepth = 2
-): Array<{ name: string; file: string; sourceType: 'bundled'; category?: string }> {
- const commands: Array<{ name: string; file: string; sourceType: 'bundled'; category?: string }> = []
-
- if (!fs.existsSync(dir)) return commands
-
- function recurse(currentDir: string, depth: number, category?: string) {
- if (depth > maxDepth) return
-
- const entries = fs.readdirSync(currentDir, { withFileTypes: true })
- for (const entry of entries) {
- const fullPath = path.join(currentDir, entry.name)
-
- if (entry.isDirectory()) {
- recurse(fullPath, depth + 1, entry.name)
- } else if (entry.name.endsWith('.md')) {
- const baseName = entry.name.replace(/\.md$/, '')
- const commandName = category ? `/${category}:${baseName}` : `/${baseName}`
- commands.push({
- name: commandName,
- file: fullPath,
- sourceType,
- category,
- })
- }
- }
- }
-
- recurse(dir, 0)
- return commands
-}
-
-export interface AgentFrontmatter {
- name: string
- description: string
- prompt: string
-}
-
-export interface CommandFrontmatter {
- name: string
- description: string
- argumentHint: string
-}
-
-export function extractAgentFrontmatter(content: string): AgentFrontmatter {
- const lines = content.split('\n')
-
- let inFrontmatter = false
- let name = ''
- let description = ''
-
- for (const line of lines) {
- if (line.trim() === '---') {
- if (inFrontmatter) break
- inFrontmatter = true
- continue
- }
-
- if (inFrontmatter) {
- const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/)
- if (match) {
- const [, key, value] = match
- if (key === 'name') name = value.trim()
- if (key === 'description') description = value.trim()
- }
- }
- }
-
- return { name, description, prompt: stripFrontmatter(content) }
-}
-
-export function extractCommandFrontmatter(content: string): CommandFrontmatter {
- const lines = content.split('\n')
-
- let inFrontmatter = false
- let name = ''
- let description = ''
- let argumentHint = ''
-
- for (const line of lines) {
- if (line.trim() === '---') {
- if (inFrontmatter) break
- inFrontmatter = true
- continue
- }
-
- if (inFrontmatter) {
- const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/)
- if (match) {
- const [, key, value] = match
- if (key === 'name') name = value.trim()
- if (key === 'description') description = value.trim()
- if (key === 'argument-hint') argumentHint = value.trim().replace(/^["']|["']$/g, '')
- }
- }
- }
-
- return { name, description, argumentHint }
-}
diff --git a/src/lib/skills.ts b/src/lib/skills.ts
new file mode 100644
index 00000000..619793be
--- /dev/null
+++ b/src/lib/skills.ts
@@ -0,0 +1,89 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { walkDir } from './walk-dir.js'
+
+export interface SkillFrontmatter {
+ name: string
+ description: string
+}
+
+export interface SkillInfo {
+ path: string
+ skillFile: string
+ name: string
+ description: string
+}
+
+export function extractFrontmatter(filePath: string): SkillFrontmatter {
+ try {
+ const content = fs.readFileSync(filePath, 'utf8')
+ const lines = content.split('\n')
+
+ let inFrontmatter = false
+ let name = ''
+ let description = ''
+
+ for (const line of lines) {
+ if (line.trim() === '---') {
+ if (inFrontmatter) break
+ inFrontmatter = true
+ continue
+ }
+
+ if (inFrontmatter) {
+ const match = line.match(/^(\w+):\s*(.*)$/)
+ if (match) {
+ const [, key, value] = match
+ if (key === 'name') name = value.trim()
+ if (key === 'description') description = value.trim()
+ }
+ }
+ }
+
+ return { name, description }
+ } catch {
+ return { name: '', description: '' }
+ }
+}
+
+export function findSkillsInDir(dir: string, maxDepth = 3): SkillInfo[] {
+ const skills: SkillInfo[] = []
+
+ const entries = walkDir(dir, {
+ maxDepth,
+ filter: (e) => e.isDirectory,
+ })
+
+ for (const entry of entries) {
+ const skillFile = path.join(entry.path, 'SKILL.md')
+ if (fs.existsSync(skillFile)) {
+ const { name, description } = extractFrontmatter(skillFile)
+ skills.push({
+ path: entry.path,
+ skillFile,
+ name: name || entry.name,
+ description: description || '',
+ })
+ }
+ }
+
+ return skills
+}
+
+export 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`
+}
diff --git a/src/lib/walk-dir.ts b/src/lib/walk-dir.ts
new file mode 100644
index 00000000..b3c74d6c
--- /dev/null
+++ b/src/lib/walk-dir.ts
@@ -0,0 +1,49 @@
+import fs from 'node:fs'
+import path from 'node:path'
+
+export interface WalkEntry {
+ path: string
+ name: string
+ isDirectory: boolean
+ depth: number
+ category?: string // parent directory name (undefined for root level)
+}
+
+export interface WalkOptions {
+ maxDepth?: number
+ filter?: (entry: WalkEntry) => boolean
+}
+
+export function walkDir(rootDir: string, options: WalkOptions = {}): WalkEntry[] {
+ const { maxDepth = 3, filter } = options
+ const results: WalkEntry[] = []
+
+ if (!fs.existsSync(rootDir)) return results
+
+ function recurse(currentDir: string, depth: number, category?: string) {
+ if (depth > maxDepth) return
+
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true })
+ for (const entry of entries) {
+ const fullPath = path.join(currentDir, entry.name)
+ const walkEntry: WalkEntry = {
+ path: fullPath,
+ name: entry.name,
+ isDirectory: entry.isDirectory(),
+ depth,
+ category,
+ }
+
+ if (!filter || filter(walkEntry)) {
+ results.push(walkEntry)
+ }
+
+ if (entry.isDirectory()) {
+ recurse(fullPath, depth + 1, entry.name)
+ }
+ }
+ }
+
+ recurse(rootDir, 0)
+ return results
+}
diff --git a/tests/integration/priority.test.ts b/tests/integration/priority.test.ts
deleted file mode 100644
index 38778dd6..00000000
--- a/tests/integration/priority.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-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 * as skillsCore from '../../src/lib/skills-core.ts'
-
-describe('skill resolution', () => {
- let testEnv: {
- tempDir: string
- bundledDir: string
- }
-
- beforeEach(() => {
- const tempBase = fs.mkdtempSync(
- path.join(os.tmpdir(), 'systematic-resolution-'),
- )
-
- testEnv = {
- tempDir: tempBase,
- bundledDir: path.join(tempBase, 'bundled'),
- }
-
- fs.mkdirSync(testEnv.bundledDir, { recursive: true })
- })
-
- afterEach(() => {
- if (testEnv.tempDir) {
- fs.rmSync(testEnv.tempDir, { recursive: true, force: true })
- }
- })
-
- function createSkill(dir: string, name: string, marker: string): void {
- const skillDir = path.join(dir, name)
- fs.mkdirSync(skillDir, { recursive: true })
- fs.writeFileSync(
- path.join(skillDir, 'SKILL.md'),
- `---
-name: ${name}
-description: ${marker} version of the skill
----
-# ${name}
-
-This is the ${marker} version.
-
-PRIORITY_MARKER_${marker.toUpperCase()}_VERSION
-`,
- )
- }
-
- test('findSkillsInDir returns correct sourceType labels', () => {
- createSkill(testEnv.bundledDir, 'bundled-skill', 'bundled')
-
- const bundledSkills = skillsCore.findSkillsInDir(
- testEnv.bundledDir,
- 'bundled',
- )
-
- expect(bundledSkills[0].sourceType).toBe('bundled')
- })
-})
diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts
index 7a163238..0cd46c3c 100644
--- a/tests/unit/plugin.test.ts
+++ b/tests/unit/plugin.test.ts
@@ -103,7 +103,6 @@ describe('CLI functionality', () => {
const output = result.stdout.toString()
expect(result.exitCode).toBe(0)
expect(output).toContain('brainstorming')
- expect(output).toContain('bundled')
})
test('cli list agents shows bundled agents', () => {
@@ -111,7 +110,6 @@ describe('CLI functionality', () => {
const output = result.stdout.toString()
expect(result.exitCode).toBe(0)
expect(output).toContain('architecture-strategist')
- expect(output).toContain('bundled')
})
test('cli list commands shows bundled commands', () => {
@@ -119,7 +117,6 @@ describe('CLI functionality', () => {
const output = result.stdout.toString()
expect(result.exitCode).toBe(0)
expect(output).toContain('/workflows:plan')
- expect(output).toContain('bundled')
})
test('cli config path shows paths', () => {
diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts
index 6899d937..87803268 100644
--- a/tests/unit/skill-tool.test.ts
+++ b/tests/unit/skill-tool.test.ts
@@ -2,13 +2,7 @@ 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'
+import { createSkillTool } from '../../src/lib/skill-tool.ts'
const mockContext = {} as never
@@ -17,38 +11,10 @@ describe('skill-tool', () => {
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', () => {
@@ -105,42 +71,6 @@ description: Disabled
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', () => {
@@ -197,40 +127,7 @@ description: Test
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 () => {
+ test('throws error when skill not found', async () => {
const tool = createSkillTool({
bundledSkillsDir: testDir,
disabledSkills: [],
diff --git a/tests/unit/skills-core.test.ts b/tests/unit/skills.test.ts
similarity index 61%
rename from tests/unit/skills-core.test.ts
rename to tests/unit/skills.test.ts
index 34d2ce05..c777f824 100644
--- a/tests/unit/skills-core.test.ts
+++ b/tests/unit/skills.test.ts
@@ -2,9 +2,12 @@ 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 * as skillsCore from '../../src/lib/skills-core.ts'
+import * as agents from '../../src/lib/agents.ts'
+import * as commands from '../../src/lib/commands.ts'
+import * as frontmatter from '../../src/lib/frontmatter.ts'
+import * as skills from '../../src/lib/skills.ts'
-describe('skills-core', () => {
+describe('skills', () => {
let testDir: string
beforeEach(() => {
@@ -26,7 +29,7 @@ description: A test skill
---
# Skill Content`,
)
- const result = skillsCore.extractFrontmatter(filePath)
+ const result = skills.extractFrontmatter(filePath)
expect(result.name).toBe('test-skill')
expect(result.description).toBe('A test skill')
})
@@ -34,7 +37,7 @@ description: A test skill
test('returns empty strings for missing frontmatter', () => {
const filePath = path.join(testDir, 'test.md')
fs.writeFileSync(filePath, '# Just a heading\nSome content')
- const result = skillsCore.extractFrontmatter(filePath)
+ const result = skills.extractFrontmatter(filePath)
expect(result.name).toBe('')
expect(result.description).toBe('')
})
@@ -47,29 +50,12 @@ description: A test skill
---
# Content`,
)
- const result = skillsCore.extractFrontmatter(filePath)
+ const result = skills.extractFrontmatter(filePath)
expect(result.name).toBe('')
expect(result.description).toBe('')
})
})
- describe('stripFrontmatter', () => {
- test('removes frontmatter from content', () => {
- const content = `---
-name: test
----
-# Content Here`
- const result = skillsCore.stripFrontmatter(content)
- expect(result).toBe('# Content Here')
- })
-
- test('returns content unchanged if no frontmatter', () => {
- const content = '# No frontmatter'
- const result = skillsCore.stripFrontmatter(content)
- expect(result).toBe('# No frontmatter')
- })
- })
-
describe('findSkillsInDir', () => {
test('finds skills in valid directory structure', () => {
const skillDir = path.join(testDir, 'my-skill')
@@ -83,15 +69,14 @@ description: Test skill
# My Skill`,
)
- const skills = skillsCore.findSkillsInDir(testDir, 'bundled')
- expect(skills).toHaveLength(1)
- expect(skills[0].name).toBe('my-skill')
- expect(skills[0].sourceType).toBe('bundled')
+ const result = skills.findSkillsInDir(testDir)
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('my-skill')
})
test('returns empty array for non-existent directory', () => {
- const skills = skillsCore.findSkillsInDir('/nonexistent/path', 'bundled')
- expect(skills).toEqual([])
+ const result = skills.findSkillsInDir('/nonexistent/path')
+ expect(result).toEqual([])
})
test('uses directory name if no name in frontmatter', () => {
@@ -99,11 +84,42 @@ description: Test skill
fs.mkdirSync(skillDir)
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Just content')
- const skills = skillsCore.findSkillsInDir(testDir, 'bundled')
- expect(skills).toHaveLength(1)
- expect(skills[0].name).toBe('unnamed-skill')
+ const result = skills.findSkillsInDir(testDir)
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('unnamed-skill')
})
})
+})
+
+describe('frontmatter', () => {
+ describe('stripFrontmatter', () => {
+ test('removes frontmatter from content', () => {
+ const content = `---
+name: test
+---
+# Content Here`
+ const result = frontmatter.stripFrontmatter(content)
+ expect(result).toBe('# Content Here')
+ })
+
+ test('returns content unchanged if no frontmatter', () => {
+ const content = '# No frontmatter'
+ const result = frontmatter.stripFrontmatter(content)
+ expect(result).toBe('# No frontmatter')
+ })
+ })
+})
+
+describe('agents', () => {
+ let testDir: string
+
+ beforeEach(() => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'systematic-test-'))
+ })
+
+ afterEach(() => {
+ fs.rmSync(testDir, { recursive: true, force: true })
+ })
describe('findAgentsInDir', () => {
test('finds agent markdown files', () => {
@@ -116,12 +132,23 @@ description: Test agent
# My Agent`,
)
- const agents = skillsCore.findAgentsInDir(testDir, 'bundled')
- expect(agents).toHaveLength(1)
- expect(agents[0].name).toBe('my-agent')
- expect(agents[0].sourceType).toBe('bundled')
+ const result = agents.findAgentsInDir(testDir)
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('my-agent')
})
})
+})
+
+describe('commands', () => {
+ let testDir: string
+
+ beforeEach(() => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'systematic-test-'))
+ })
+
+ afterEach(() => {
+ fs.rmSync(testDir, { recursive: true, force: true })
+ })
describe('findCommandsInDir', () => {
test('finds command markdown files', () => {
@@ -134,18 +161,17 @@ description: Test command
# Test Command`,
)
- const commands = skillsCore.findCommandsInDir(testDir, 'bundled')
- expect(commands).toHaveLength(1)
- expect(commands[0].name).toBe('/sys-test')
- expect(commands[0].sourceType).toBe('bundled')
+ const result = commands.findCommandsInDir(testDir)
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('/sys-test')
})
test('handles non-sys commands', () => {
fs.writeFileSync(path.join(testDir, 'other-cmd.md'), '# Other')
- const commands = skillsCore.findCommandsInDir(testDir, 'bundled')
- expect(commands).toHaveLength(1)
- expect(commands[0].name).toBe('/other-cmd')
+ const result = commands.findCommandsInDir(testDir)
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('/other-cmd')
})
})
})
diff --git a/tsconfig.json b/tsconfig.json
index 3b0833be..bf61715a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,19 +1,19 @@
{
"compilerOptions": {
- "target": "ES2022",
+ "target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
- "lib": ["ES2022"],
+ "lib": ["ESNext"],
"types": ["bun-types", "node"],
"strict": true,
"skipLibCheck": true,
- "declaration": false,
- "outDir": "./dist",
- "rootDir": "./src",
+ "declaration": true,
+ "declarationDir": "dist",
+ "outDir": "dist",
+ "rootDir": "src",
"esModuleInterop": true,
"resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true
+ "isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "lib", ".opencode"]