From 127b0e125a22c109ef92abd31755927c619c543f Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 28 Jan 2026 02:27:34 -0700 Subject: [PATCH] build: modularize codebase and improve type generation Split monolithic skills-core.ts into focused modules (skills, agents, commands, frontmatter, bootstrap, walk-dir). Remove skill-tool hook system. Enable TypeScript declaration generation in build process. Update all imports and tests accordingly. --- package.json | 4 +- src/cli.ts | 55 ++-- src/index.ts | 58 +---- src/lib/agents.ts | 54 ++++ src/lib/bootstrap.ts | 68 +++++ src/lib/commands.ts | 59 +++++ src/lib/config-handler.ts | 33 ++- src/lib/converter.ts | 53 +--- src/lib/frontmatter.ts | 77 ++++++ src/lib/skill-tool.ts | 129 +-------- src/lib/skills-core.ts | 245 ------------------ src/lib/skills.ts | 89 +++++++ src/lib/walk-dir.ts | 49 ++++ tests/integration/priority.test.ts | 60 ----- tests/unit/plugin.test.ts | 3 - tests/unit/skill-tool.test.ts | 107 +------- .../{skills-core.test.ts => skills.test.ts} | 110 +++++--- tsconfig.json | 14 +- 18 files changed, 520 insertions(+), 747 deletions(-) create mode 100644 src/lib/agents.ts create mode 100644 src/lib/bootstrap.ts create mode 100644 src/lib/commands.ts create mode 100644 src/lib/frontmatter.ts delete mode 100644 src/lib/skills-core.ts create mode 100644 src/lib/skills.ts create mode 100644 src/lib/walk-dir.ts delete mode 100644 tests/integration/priority.test.ts rename tests/unit/{skills-core.test.ts => skills.test.ts} (61%) 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"]