diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index 763bef8..69e95b6 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -1,6 +1,6 @@ -import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs" -import { promises as fsPromises } from "fs" +import { promises as fs, type Dirent } from "fs" import { join, basename } from "path" +import { homedir } from "os" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { isMarkdownFile } from "../../shared/file-utils" @@ -8,19 +8,21 @@ import { getClaudeConfigDir } from "../../shared" import { log } from "../../shared/logger" import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" -function loadCommandsFromDir( +async function loadCommandsFromDir( commandsDir: string, scope: CommandScope, visited: Set = new Set(), prefix: string = "" -): LoadedCommand[] { - if (!existsSync(commandsDir)) { +): Promise { + try { + await fs.access(commandsDir) + } catch { return [] } let realPath: string try { - realPath = realpathSync(commandsDir) + realPath = await fs.realpath(commandsDir) } catch (error) { log(`Failed to resolve command directory: ${commandsDir}`, error) return [] @@ -33,7 +35,7 @@ function loadCommandsFromDir( let entries: Dirent[] try { - entries = readdirSync(commandsDir, { withFileTypes: true }) + entries = await fs.readdir(commandsDir, { withFileTypes: true }) } catch (error) { log(`Failed to read command directory: ${commandsDir}`, error) return [] @@ -46,7 +48,8 @@ function loadCommandsFromDir( if (entry.name.startsWith(".")) continue const subDirPath = join(commandsDir, entry.name) const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name - commands.push(...loadCommandsFromDir(subDirPath, scope, visited, subPrefix)) + const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix) + commands.push(...subCommands) continue } @@ -57,7 +60,7 @@ function loadCommandsFromDir( const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName try { - const content = readFileSync(commandPath, "utf-8") + const content = await fs.readFile(commandPath, "utf-8") const { data, body } = parseFrontmatter(content) const wrappedTemplate = ` @@ -106,154 +109,36 @@ function commandsToRecord(commands: LoadedCommand[]): Record { +export async function loadUserCommands(): Promise> { const userCommandsDir = join(getClaudeConfigDir(), "commands") - const commands = loadCommandsFromDir(userCommandsDir, "user") + const commands = await loadCommandsFromDir(userCommandsDir, "user") return commandsToRecord(commands) } -export function loadProjectCommands(): Record { +export async function loadProjectCommands(): Promise> { const projectCommandsDir = join(process.cwd(), ".claude", "commands") - const commands = loadCommandsFromDir(projectCommandsDir, "project") + const commands = await loadCommandsFromDir(projectCommandsDir, "project") return commandsToRecord(commands) } -export function loadOpencodeGlobalCommands(): Record { - const { homedir } = require("os") +export async function loadOpencodeGlobalCommands(): Promise> { const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command") - const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode") + const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode") return commandsToRecord(commands) } -export function loadOpencodeProjectCommands(): Record { +export async function loadOpencodeProjectCommands(): Promise> { const opencodeProjectDir = join(process.cwd(), ".opencode", "command") - const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project") + const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project") return commandsToRecord(commands) } -async function loadCommandsFromDirAsync( - commandsDir: string, - scope: CommandScope, - visited: Set = new Set(), - prefix: string = "" -): Promise { - try { - await fsPromises.access(commandsDir) - } catch { - return [] - } - - let realPath: string - try { - realPath = await fsPromises.realpath(commandsDir) - } catch (error) { - log(`Failed to resolve command directory: ${commandsDir}`, error) - return [] - } - - if (visited.has(realPath)) { - return [] - } - visited.add(realPath) - - let entries: Dirent[] - try { - entries = await fsPromises.readdir(commandsDir, { withFileTypes: true }) - } catch (error) { - log(`Failed to read command directory: ${commandsDir}`, error) - return [] - } - - const commands: LoadedCommand[] = [] - - for (const entry of entries) { - if (entry.isDirectory()) { - if (entry.name.startsWith(".")) continue - const subDirPath = join(commandsDir, entry.name) - const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name - const subCommands = await loadCommandsFromDirAsync(subDirPath, scope, visited, subPrefix) - commands.push(...subCommands) - continue - } - - if (!isMarkdownFile(entry)) continue - - const commandPath = join(commandsDir, entry.name) - const baseCommandName = basename(entry.name, ".md") - const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName - - try { - const content = await fsPromises.readFile(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const wrappedTemplate = ` - ${body.trim()} - - - - $ARGUMENTS - ` - - const formattedDescription = `(${scope}) ${data.description || ""}` - - const isOpencodeSource = scope === "opencode" || scope === "opencode-project" - const definition: CommandDefinition = { - name: commandName, - description: formattedDescription, - template: wrappedTemplate, - agent: data.agent, - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - subtask: data.subtask, - argumentHint: data["argument-hint"], - handoffs: data.handoffs, - } - - commands.push({ - name: commandName, - path: commandPath, - definition, - scope, - }) - } catch (error) { - log(`Failed to parse command: ${commandPath}`, error) - continue - } - } - - return commands -} - -export async function loadUserCommandsAsync(): Promise> { - const userCommandsDir = join(getClaudeConfigDir(), "commands") - const commands = await loadCommandsFromDirAsync(userCommandsDir, "user") - return commandsToRecord(commands) -} - -export async function loadProjectCommandsAsync(): Promise> { - const projectCommandsDir = join(process.cwd(), ".claude", "commands") - const commands = await loadCommandsFromDirAsync(projectCommandsDir, "project") - return commandsToRecord(commands) -} - -export async function loadOpencodeGlobalCommandsAsync(): Promise> { - const { homedir } = require("os") - const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command") - const commands = await loadCommandsFromDirAsync(opencodeCommandsDir, "opencode") - return commandsToRecord(commands) -} - -export async function loadOpencodeProjectCommandsAsync(): Promise> { - const opencodeProjectDir = join(process.cwd(), ".opencode", "command") - const commands = await loadCommandsFromDirAsync(opencodeProjectDir, "opencode-project") - return commandsToRecord(commands) -} - -export async function loadAllCommandsAsync(): Promise> { +export async function loadAllCommands(): Promise> { const [user, project, global, projectOpencode] = await Promise.all([ - loadUserCommandsAsync(), - loadProjectCommandsAsync(), - loadOpencodeGlobalCommandsAsync(), - loadOpencodeProjectCommandsAsync(), + loadUserCommands(), + loadProjectCommands(), + loadOpencodeGlobalCommands(), + loadOpencodeProjectCommands(), ]) return { ...projectOpencode, ...global, ...project, ...user } } diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index b415957..34b03be 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -53,7 +53,7 @@ This is the skill body. process.chdir(TEST_DIR) try { - const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skills = await discoverSkills({ includeClaudeCodePaths: false }) const skill = skills.find(s => s.name === "test-skill") // #then @@ -89,7 +89,7 @@ This is a simple skill. process.chdir(TEST_DIR) try { - const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skills = await discoverSkills({ includeClaudeCodePaths: false }) const skill = skills.find(s => s.name === "simple-skill") // #then @@ -122,7 +122,7 @@ Skill with env vars. process.chdir(TEST_DIR) try { - const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skills = await discoverSkills({ includeClaudeCodePaths: false }) const skill = skills.find(s => s.name === "env-skill") // #then @@ -149,7 +149,7 @@ Skill body. process.chdir(TEST_DIR) try { - const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skills = await discoverSkills({ includeClaudeCodePaths: false }) // #then - when YAML fails, skill uses directory name as fallback const skill = skills.find(s => s.name === "bad-yaml-skill") @@ -186,7 +186,7 @@ Skill body. process.chdir(TEST_DIR) try { - const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skills = await discoverSkills({ includeClaudeCodePaths: false }) const skill = skills.find(s => s.name === "ampcode-skill") // #then @@ -227,7 +227,7 @@ Skill body. process.chdir(TEST_DIR) try { - const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skills = await discoverSkills({ includeClaudeCodePaths: false }) const skill = skills.find(s => s.name === "priority-skill") // #then - mcp.json should take priority @@ -259,7 +259,7 @@ Skill body. process.chdir(TEST_DIR) try { - const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skills = await discoverSkills({ includeClaudeCodePaths: false }) const skill = skills.find(s => s.name === "direct-format") // #then diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index be9b756..2d59f14 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -1,11 +1,10 @@ -import { existsSync, readdirSync, readFileSync } from "fs" import { promises as fs } from "fs" import { join, basename } from "path" import { homedir } from "os" import yaml from "js-yaml" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" -import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils" +import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import type { CommandDefinition } from "../claude-code-command-loader/types" import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types" @@ -26,20 +25,17 @@ function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | u return undefined } -function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined { +async function loadMcpJsonFromDir(skillDir: string): Promise { const mcpJsonPath = join(skillDir, "mcp.json") - if (!existsSync(mcpJsonPath)) return undefined - + try { - const content = readFileSync(mcpJsonPath, "utf-8") + const content = await fs.readFile(mcpJsonPath, "utf-8") const parsed = JSON.parse(content) as Record - // AmpCode format: { "mcpServers": { "name": { ... } } } if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) { return parsed.mcpServers as SkillMcpConfig } - // Also support direct format: { "name": { command: ..., args: ... } } if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) { const hasCommandField = Object.values(parsed).some( (v) => v && typeof v === "object" && "command" in (v as Record) @@ -59,77 +55,7 @@ function parseAllowedTools(allowedTools: string | undefined): string[] | undefin return allowedTools.split(/\s+/).filter(Boolean) } -function loadSkillFromPath( - skillPath: string, - resolvedPath: string, - defaultName: string, - scope: SkillScope -): LoadedSkill | null { - try { - const content = readFileSync(skillPath, "utf-8") - const { data } = parseFrontmatter(content) - const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) - const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath) - const mcpConfig = mcpJsonMcp || frontmatterMcp - - const skillName = data.name || defaultName - const originalDescription = data.description || "" - const isOpencodeSource = scope === "opencode" || scope === "opencode-project" - const formattedDescription = `(${scope} - Skill) ${originalDescription}` - - // Lazy content loader - only loads template on first use - const lazyContent: LazyContentLoader = { - loaded: false, - content: undefined, - load: async () => { - if (!lazyContent.loaded) { - const fileContent = await fs.readFile(skillPath, "utf-8") - const { body } = parseFrontmatter(fileContent) - lazyContent.content = ` -Base directory for this skill: ${resolvedPath}/ -File references (@path) in this skill are relative to this directory. - -${body.trim()} - - - -$ARGUMENTS -` - lazyContent.loaded = true - } - return lazyContent.content! - }, - } - - const definition: CommandDefinition = { - name: skillName, - description: formattedDescription, - template: "", // Empty at startup, loaded lazily - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - agent: data.agent, - subtask: data.subtask, - argumentHint: data["argument-hint"], - } - - return { - name: skillName, - path: skillPath, - resolvedPath, - definition, - scope, - license: data.license, - compatibility: data.compatibility, - metadata: data.metadata, - allowedTools: parseAllowedTools(data["allowed-tools"]), - mcpConfig, - lazyContent, - } - } catch { - return null - } -} - -async function loadSkillFromPathAsync( +async function loadSkillFromPath( skillPath: string, resolvedPath: string, defaultName: string, @@ -139,7 +65,7 @@ async function loadSkillFromPathAsync( const content = await fs.readFile(skillPath, "utf-8") const { data } = parseFrontmatter(content) const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) - const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath) + const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath) const mcpConfig = mcpJsonMcp || frontmatterMcp const skillName = data.name || defaultName @@ -198,60 +124,7 @@ $ARGUMENTS } } -/** - * Load skills from a directory, supporting BOTH patterns: - * - Directory with SKILL.md: skill-name/SKILL.md - * - Directory with {SKILLNAME}.md: skill-name/{SKILLNAME}.md - * - Direct markdown file: skill-name.md - */ -function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[] { - if (!existsSync(skillsDir)) { - return [] - } - - const entries = readdirSync(skillsDir, { withFileTypes: true }) - const skills: LoadedSkill[] = [] - - for (const entry of entries) { - if (entry.name.startsWith(".")) continue - - const entryPath = join(skillsDir, entry.name) - - if (entry.isDirectory() || entry.isSymbolicLink()) { - const resolvedPath = resolveSymlink(entryPath) - const dirName = entry.name - - const skillMdPath = join(resolvedPath, "SKILL.md") - if (existsSync(skillMdPath)) { - const skill = loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope) - if (skill) skills.push(skill) - continue - } - - const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) - if (existsSync(namedSkillMdPath)) { - const skill = loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope) - if (skill) skills.push(skill) - continue - } - - continue - } - - if (isMarkdownFile(entry)) { - const skillName = basename(entry.name, ".md") - const skill = loadSkillFromPath(entryPath, skillsDir, skillName, scope) - if (skill) skills.push(skill) - } - } - - return skills -} - -/** - * Async version of loadSkillsFromDir using Promise-based fs operations. - */ -async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Promise { +async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise { const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) const skills: LoadedSkill[] = [] @@ -261,13 +134,13 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro const entryPath = join(skillsDir, entry.name) if (entry.isDirectory() || entry.isSymbolicLink()) { - const resolvedPath = resolveSymlink(entryPath) + const resolvedPath = await resolveSymlinkAsync(entryPath) const dirName = entry.name const skillMdPath = join(resolvedPath, "SKILL.md") try { await fs.access(skillMdPath) - const skill = await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, scope) + const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope) if (skill) skills.push(skill) continue } catch { @@ -276,7 +149,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) try { await fs.access(namedSkillMdPath) - const skill = await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, scope) + const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope) if (skill) skills.push(skill) continue } catch { @@ -287,7 +160,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro if (isMarkdownFile(entry)) { const skillName = basename(entry.name, ".md") - const skill = await loadSkillFromPathAsync(entryPath, skillsDir, skillName, scope) + const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope) if (skill) skills.push(skill) } } @@ -304,190 +177,86 @@ function skillsToRecord(skills: LoadedSkill[]): Record { +export async function loadUserSkills(): Promise> { const userSkillsDir = join(getClaudeConfigDir(), "skills") - const skills = loadSkillsFromDir(userSkillsDir, "user") + const skills = await loadSkillsFromDir(userSkillsDir, "user") return skillsToRecord(skills) } -/** - * Load skills from Claude Code project directory (.claude/skills/) - */ -export function loadProjectSkills(): Record { +export async function loadProjectSkills(): Promise> { const projectSkillsDir = join(process.cwd(), ".claude", "skills") - const skills = loadSkillsFromDir(projectSkillsDir, "project") + const skills = await loadSkillsFromDir(projectSkillsDir, "project") return skillsToRecord(skills) } -/** - * Load skills from OpenCode global directory (~/.config/opencode/skill/) - */ -export function loadOpencodeGlobalSkills(): Record { +export async function loadOpencodeGlobalSkills(): Promise> { const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill") - const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode") + const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode") return skillsToRecord(skills) } -/** - * Load skills from OpenCode project directory (.opencode/skill/) - */ -export function loadOpencodeProjectSkills(): Record { +export async function loadOpencodeProjectSkills(): Promise> { const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") - const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project") + const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project") return skillsToRecord(skills) } -/** - * Async version of loadUserSkills - */ -export async function loadUserSkillsAsync(): Promise> { - const userSkillsDir = join(getClaudeConfigDir(), "skills") - const skills = await loadSkillsFromDirAsync(userSkillsDir, "user") - return skillsToRecord(skills) -} - -/** - * Async version of loadProjectSkills - */ -export async function loadProjectSkillsAsync(): Promise> { - const projectSkillsDir = join(process.cwd(), ".claude", "skills") - const skills = await loadSkillsFromDirAsync(projectSkillsDir, "project") - return skillsToRecord(skills) -} - -/** - * Async version of loadOpencodeGlobalSkills - */ -export async function loadOpencodeGlobalSkillsAsync(): Promise> { - const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill") - const skills = await loadSkillsFromDirAsync(opencodeSkillsDir, "opencode") - return skillsToRecord(skills) -} - -/** - * Async version of loadOpencodeProjectSkills - */ -export async function loadOpencodeProjectSkillsAsync(): Promise> { - const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") - const skills = await loadSkillsFromDirAsync(opencodeProjectDir, "opencode-project") - return skillsToRecord(skills) -} - -/** - * Discover all skills from all sources with priority ordering. - * Priority order: opencode-project > project > opencode > user - * - * @returns Array of LoadedSkill objects for use in slashcommand discovery - */ -export function discoverAllSkills(): LoadedSkill[] { - const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") - const projectDir = join(process.cwd(), ".claude", "skills") - const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill") - const userDir = join(getClaudeConfigDir(), "skills") - - const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project") - const projectSkills = loadSkillsFromDir(projectDir, "project") - const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode") - const userSkills = loadSkillsFromDir(userDir, "user") - - return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] -} - export interface DiscoverSkillsOptions { includeClaudeCodePaths?: boolean } -/** - * Discover skills with optional filtering. - * When includeClaudeCodePaths is false, only loads from OpenCode paths. - */ -export function discoverSkills(options: DiscoverSkillsOptions = {}): LoadedSkill[] { - const { includeClaudeCodePaths = true } = options - - const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") - const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill") - - const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project") - const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode") - - if (!includeClaudeCodePaths) { - return [...opencodeProjectSkills, ...opencodeGlobalSkills] - } - - const projectDir = join(process.cwd(), ".claude", "skills") - const userDir = join(getClaudeConfigDir(), "skills") - - const projectSkills = loadSkillsFromDir(projectDir, "project") - const userSkills = loadSkillsFromDir(userDir, "user") +export async function discoverAllSkills(): Promise { + const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([ + discoverOpencodeProjectSkills(), + discoverProjectClaudeSkills(), + discoverOpencodeGlobalSkills(), + discoverUserClaudeSkills(), + ]) return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] } -/** - * Get a skill by name from all available sources. - */ -export function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): LoadedSkill | undefined { - const skills = discoverSkills(options) - return skills.find(s => s.name === name) -} - -export function discoverUserClaudeSkills(): LoadedSkill[] { - const userSkillsDir = join(getClaudeConfigDir(), "skills") - return loadSkillsFromDir(userSkillsDir, "user") -} - -export function discoverProjectClaudeSkills(): LoadedSkill[] { - const projectSkillsDir = join(process.cwd(), ".claude", "skills") - return loadSkillsFromDir(projectSkillsDir, "project") -} - -export function discoverOpencodeGlobalSkills(): LoadedSkill[] { - const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill") - return loadSkillsFromDir(opencodeSkillsDir, "opencode") -} - -export function discoverOpencodeProjectSkills(): LoadedSkill[] { - const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") - return loadSkillsFromDir(opencodeProjectDir, "opencode-project") -} - -export async function discoverUserClaudeSkillsAsync(): Promise { - const userSkillsDir = join(getClaudeConfigDir(), "skills") - return loadSkillsFromDirAsync(userSkillsDir, "user") -} - -export async function discoverProjectClaudeSkillsAsync(): Promise { - const projectSkillsDir = join(process.cwd(), ".claude", "skills") - return loadSkillsFromDirAsync(projectSkillsDir, "project") -} - -export async function discoverOpencodeGlobalSkillsAsync(): Promise { - const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill") - return loadSkillsFromDirAsync(opencodeSkillsDir, "opencode") -} - -export async function discoverOpencodeProjectSkillsAsync(): Promise { - const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") - return loadSkillsFromDirAsync(opencodeProjectDir, "opencode-project") -} - -export async function discoverAllSkillsAsync(options: DiscoverSkillsOptions = {}): Promise { +export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise { const { includeClaudeCodePaths = true } = options - const opencodeProjectSkills = await discoverOpencodeProjectSkillsAsync() - const opencodeGlobalSkills = await discoverOpencodeGlobalSkillsAsync() + const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([ + discoverOpencodeProjectSkills(), + discoverOpencodeGlobalSkills(), + ]) if (!includeClaudeCodePaths) { return [...opencodeProjectSkills, ...opencodeGlobalSkills] } const [projectSkills, userSkills] = await Promise.all([ - discoverProjectClaudeSkillsAsync(), - discoverUserClaudeSkillsAsync(), + discoverProjectClaudeSkills(), + discoverUserClaudeSkills(), ]) return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] } + +export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise { + const skills = await discoverSkills(options) + return skills.find(s => s.name === name) +} + +export async function discoverUserClaudeSkills(): Promise { + const userSkillsDir = join(getClaudeConfigDir(), "skills") + return loadSkillsFromDir(userSkillsDir, "user") +} + +export async function discoverProjectClaudeSkills(): Promise { + const projectSkillsDir = join(process.cwd(), ".claude", "skills") + return loadSkillsFromDir(projectSkillsDir, "project") +} + +export async function discoverOpencodeGlobalSkills(): Promise { + const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill") + return loadSkillsFromDir(opencodeSkillsDir, "opencode") +} + +export async function discoverOpencodeProjectSkills(): Promise { + const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") + return loadSkillsFromDir(opencodeProjectDir, "opencode-project") +} diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index 01dde50..e291473 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -94,7 +94,7 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo { } } -function discoverAllCommands(): CommandInfo[] { +async function discoverAllCommands(): Promise { const userCommandsDir = join(getClaudeConfigDir(), "commands") const projectCommandsDir = join(process.cwd(), ".claude", "commands") const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command") @@ -105,7 +105,7 @@ function discoverAllCommands(): CommandInfo[] { const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") - const skills = discoverAllSkills() + const skills = await discoverAllSkills() const skillCommands = skills.map(skillToCommandInfo) return [ @@ -117,8 +117,8 @@ function discoverAllCommands(): CommandInfo[] { ] } -function findCommand(commandName: string): CommandInfo | null { - const allCommands = discoverAllCommands() +async function findCommand(commandName: string): Promise { + const allCommands = await discoverAllCommands() return allCommands.find( (cmd) => cmd.name.toLowerCase() === commandName.toLowerCase() ) ?? null @@ -170,7 +170,7 @@ export interface ExecuteResult { } export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise { - const command = findCommand(parsed.command) + const command = await findCommand(parsed.command) if (!command) { return { diff --git a/src/index.ts b/src/index.ts index 9e188e6..75b4c57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,10 +34,10 @@ import { } from "./features/context-injector"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; import { - discoverUserClaudeSkillsAsync, - discoverProjectClaudeSkillsAsync, - discoverOpencodeGlobalSkillsAsync, - discoverOpencodeProjectSkillsAsync, + discoverUserClaudeSkills, + discoverProjectClaudeSkills, + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, mergeSkills, } from "./features/opencode-skill-loader"; import { createBuiltinSkills } from "./features/builtin-skills"; @@ -205,10 +205,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }); const includeClaudeSkills = pluginConfig.claude_code?.skills !== false; const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([ - includeClaudeSkills ? discoverUserClaudeSkillsAsync() : Promise.resolve([]), - discoverOpencodeGlobalSkillsAsync(), - includeClaudeSkills ? discoverProjectClaudeSkillsAsync() : Promise.resolve([]), - discoverOpencodeProjectSkillsAsync(), + includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), + discoverOpencodeGlobalSkills(), + includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), + discoverOpencodeProjectSkills(), ]); const mergedSkills = mergeSkills( builtinSkills, diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 3c9dd2b..8d23245 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -1,16 +1,16 @@ import { createBuiltinAgents } from "../agents"; import { - loadUserCommandsAsync, - loadProjectCommandsAsync, - loadOpencodeGlobalCommandsAsync, - loadOpencodeProjectCommandsAsync, + loadUserCommands, + loadProjectCommands, + loadOpencodeGlobalCommands, + loadOpencodeProjectCommands, } from "../features/claude-code-command-loader"; import { loadBuiltinCommands } from "../features/builtin-commands"; import { - loadUserSkillsAsync, - loadProjectSkillsAsync, - loadOpencodeGlobalSkillsAsync, - loadOpencodeProjectSkillsAsync, + loadUserSkills, + loadProjectSkills, + loadOpencodeGlobalSkills, + loadOpencodeProjectSkills, } from "../features/opencode-skill-loader"; import { loadUserAgents, @@ -298,14 +298,14 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { opencodeGlobalSkills, opencodeProjectSkills, ] = await Promise.all([ - includeClaudeCommands ? loadUserCommandsAsync() : Promise.resolve({}), - includeClaudeCommands ? loadProjectCommandsAsync() : Promise.resolve({}), - loadOpencodeGlobalCommandsAsync(), - loadOpencodeProjectCommandsAsync(), - includeClaudeSkills ? loadUserSkillsAsync() : Promise.resolve({}), - includeClaudeSkills ? loadProjectSkillsAsync() : Promise.resolve({}), - loadOpencodeGlobalSkillsAsync(), - loadOpencodeProjectSkillsAsync(), + includeClaudeCommands ? loadUserCommands() : Promise.resolve({}), + includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}), + loadOpencodeGlobalCommands(), + loadOpencodeProjectCommands(), + includeClaudeSkills ? loadUserSkills() : Promise.resolve({}), + includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}), + loadOpencodeGlobalSkills(), + loadOpencodeProjectSkills(), ]); config.command = { diff --git a/src/shared/file-utils.ts b/src/shared/file-utils.ts index d55bd05..cfeda81 100644 --- a/src/shared/file-utils.ts +++ b/src/shared/file-utils.ts @@ -1,4 +1,5 @@ import { lstatSync, readlinkSync } from "fs" +import { promises as fs } from "fs" import { resolve } from "path" export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean { @@ -24,3 +25,16 @@ export function resolveSymlink(filePath: string): string { return filePath } } + +export async function resolveSymlinkAsync(filePath: string): Promise { + try { + const stats = await fs.lstat(filePath) + if (stats.isSymbolicLink()) { + const linkTarget = await fs.readlink(filePath) + return resolve(filePath, "..", linkTarget) + } + return filePath + } catch { + return filePath + } +} diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 50868c3..abb6d1c 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -129,22 +129,38 @@ async function formatMcpCapabilities( } export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition { - const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly }) - const skillInfos = skills.map(loadedSkillToInfo) + let cachedSkills: LoadedSkill[] | null = null + let cachedDescription: string | null = null - const description = skillInfos.length === 0 - ? TOOL_DESCRIPTION_NO_SKILLS - : TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos) + const getSkills = async (): Promise => { + if (options.skills) return options.skills + if (cachedSkills) return cachedSkills + cachedSkills = await discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly }) + return cachedSkills + } + + const getDescription = async (): Promise => { + if (cachedDescription) return cachedDescription + const skills = await getSkills() + const skillInfos = skills.map(loadedSkillToInfo) + cachedDescription = skillInfos.length === 0 + ? TOOL_DESCRIPTION_NO_SKILLS + : TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos) + return cachedDescription + } + + getDescription() return tool({ - description, + get description() { + return cachedDescription ?? TOOL_DESCRIPTION_PREFIX + }, args: { name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), }, async execute(args: SkillArgs) { - const skill = options.skills - ? skills.find(s => s.name === args.name) - : skills.find(s => s.name === args.name) + const skills = await getSkills() + const skill = skills.find(s => s.name === args.name) if (!skill) { const available = skills.map(s => s.name).join(", ") diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 2092eb1..9a822bc 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -83,19 +83,6 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo { } } -const availableCommands = discoverCommandsSync() -const availableSkills = discoverAllSkills() -const availableItems = [ - ...availableCommands, - ...availableSkills.map(skillToCommandInfo), -] -const commandListForDescription = availableItems - .map((cmd) => { - const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" - return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})` - }) - .join("\n") - async function formatLoadedCommand(cmd: CommandInfo): Promise { const sections: string[] = [] @@ -151,15 +138,40 @@ function formatCommandList(items: CommandInfo[]): string { return lines.join("\n") } -export const slashcommand: ToolDefinition = tool({ - description: `Load a skill to get detailed instructions for a specific task. +async function buildDescription(): Promise { + const availableCommands = discoverCommandsSync() + const availableSkills = await discoverAllSkills() + const availableItems = [ + ...availableCommands, + ...availableSkills.map(skillToCommandInfo), + ] + const commandListForDescription = availableItems + .map((cmd) => { + const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" + return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})` + }) + .join("\n") + + return `Load a skill to get detailed instructions for a specific task. Skills provide specialized knowledge and step-by-step guidance. Use this when a task matches an available skill's description. ${commandListForDescription} -`, +` +} + +let cachedDescription: string | null = null + +export const slashcommand: ToolDefinition = tool({ + get description() { + if (!cachedDescription) { + cachedDescription = "Loading available commands and skills..." + buildDescription().then(desc => { cachedDescription = desc }) + } + return cachedDescription + }, args: { command: tool.schema @@ -171,7 +183,7 @@ ${commandListForDescription} async execute(args) { const commands = discoverCommandsSync() - const skills = discoverAllSkills() + const skills = await discoverAllSkills() const allItems = [ ...commands, ...skills.map(skillToCommandInfo),