refactor(loaders): migrate to async-first pattern for commands and skills

- Remove all sync functions from command loader (async now default)
- Remove sync load functions from skill loader (async now default)
- Add resolveSymlinkAsync to file-utils.ts
- Update all callers to use async versions:
  - config-handler.ts
  - index.ts
  - tools/slashcommand/tools.ts
  - tools/skill/tools.ts
  - hooks/auto-slash-command/executor.ts
  - loader.test.ts
- All 607 tests pass, build succeeds

Generated with assistance of 🤖 [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2026-01-05 14:18:42 +09:00
parent fe11ba294c
commit 7937d72cbf
9 changed files with 187 additions and 491 deletions

View File

@@ -1,6 +1,6 @@
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs" import { promises as fs, type Dirent } from "fs"
import { promises as fsPromises } from "fs"
import { join, basename } from "path" import { join, basename } from "path"
import { homedir } from "os"
import { parseFrontmatter } from "../../shared/frontmatter" import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer" import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils" import { isMarkdownFile } from "../../shared/file-utils"
@@ -8,19 +8,21 @@ import { getClaudeConfigDir } from "../../shared"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function loadCommandsFromDir( async function loadCommandsFromDir(
commandsDir: string, commandsDir: string,
scope: CommandScope, scope: CommandScope,
visited: Set<string> = new Set(), visited: Set<string> = new Set(),
prefix: string = "" prefix: string = ""
): LoadedCommand[] { ): Promise<LoadedCommand[]> {
if (!existsSync(commandsDir)) { try {
await fs.access(commandsDir)
} catch {
return [] return []
} }
let realPath: string let realPath: string
try { try {
realPath = realpathSync(commandsDir) realPath = await fs.realpath(commandsDir)
} catch (error) { } catch (error) {
log(`Failed to resolve command directory: ${commandsDir}`, error) log(`Failed to resolve command directory: ${commandsDir}`, error)
return [] return []
@@ -33,7 +35,7 @@ function loadCommandsFromDir(
let entries: Dirent[] let entries: Dirent[]
try { try {
entries = readdirSync(commandsDir, { withFileTypes: true }) entries = await fs.readdir(commandsDir, { withFileTypes: true })
} catch (error) { } catch (error) {
log(`Failed to read command directory: ${commandsDir}`, error) log(`Failed to read command directory: ${commandsDir}`, error)
return [] return []
@@ -46,7 +48,8 @@ function loadCommandsFromDir(
if (entry.name.startsWith(".")) continue if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name) const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}:${entry.name}` : 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 continue
} }
@@ -57,7 +60,7 @@ function loadCommandsFromDir(
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
try { try {
const content = readFileSync(commandPath, "utf-8") const content = await fs.readFile(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content) const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction> const wrappedTemplate = `<command-instruction>
@@ -106,154 +109,36 @@ function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefi
return result return result
} }
export function loadUserCommands(): Record<string, CommandDefinition> { export async function loadUserCommands(): Promise<Record<string, CommandDefinition>> {
const userCommandsDir = join(getClaudeConfigDir(), "commands") const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = loadCommandsFromDir(userCommandsDir, "user") const commands = await loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands) return commandsToRecord(commands)
} }
export function loadProjectCommands(): Record<string, CommandDefinition> { export async function loadProjectCommands(): Promise<Record<string, CommandDefinition>> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands") const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = loadCommandsFromDir(projectCommandsDir, "project") const commands = await loadCommandsFromDir(projectCommandsDir, "project")
return commandsToRecord(commands) return commandsToRecord(commands)
} }
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> { export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const { homedir } = require("os")
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command") const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode") const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands) return commandsToRecord(commands)
} }
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> { export async function loadOpencodeProjectCommands(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command") const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project") const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands) return commandsToRecord(commands)
} }
async function loadCommandsFromDirAsync( export async function loadAllCommands(): Promise<Record<string, CommandDefinition>> {
commandsDir: string,
scope: CommandScope,
visited: Set<string> = new Set(),
prefix: string = ""
): Promise<LoadedCommand[]> {
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<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
${body.trim()}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`
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<Record<string, CommandDefinition>> {
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = await loadCommandsFromDirAsync(userCommandsDir, "user")
return commandsToRecord(commands)
}
export async function loadProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = await loadCommandsFromDirAsync(projectCommandsDir, "project")
return commandsToRecord(commands)
}
export async function loadOpencodeGlobalCommandsAsync(): Promise<Record<string, CommandDefinition>> {
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<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = await loadCommandsFromDirAsync(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}
export async function loadAllCommandsAsync(): Promise<Record<string, CommandDefinition>> {
const [user, project, global, projectOpencode] = await Promise.all([ const [user, project, global, projectOpencode] = await Promise.all([
loadUserCommandsAsync(), loadUserCommands(),
loadProjectCommandsAsync(), loadProjectCommands(),
loadOpencodeGlobalCommandsAsync(), loadOpencodeGlobalCommands(),
loadOpencodeProjectCommandsAsync(), loadOpencodeProjectCommands(),
]) ])
return { ...projectOpencode, ...global, ...project, ...user } return { ...projectOpencode, ...global, ...project, ...user }
} }

View File

@@ -53,7 +53,7 @@ This is the skill body.
process.chdir(TEST_DIR) process.chdir(TEST_DIR)
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "test-skill") const skill = skills.find(s => s.name === "test-skill")
// #then // #then
@@ -89,7 +89,7 @@ This is a simple skill.
process.chdir(TEST_DIR) process.chdir(TEST_DIR)
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "simple-skill") const skill = skills.find(s => s.name === "simple-skill")
// #then // #then
@@ -122,7 +122,7 @@ Skill with env vars.
process.chdir(TEST_DIR) process.chdir(TEST_DIR)
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "env-skill") const skill = skills.find(s => s.name === "env-skill")
// #then // #then
@@ -149,7 +149,7 @@ Skill body.
process.chdir(TEST_DIR) process.chdir(TEST_DIR)
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) const skills = await discoverSkills({ includeClaudeCodePaths: false })
// #then - when YAML fails, skill uses directory name as fallback // #then - when YAML fails, skill uses directory name as fallback
const skill = skills.find(s => s.name === "bad-yaml-skill") const skill = skills.find(s => s.name === "bad-yaml-skill")
@@ -186,7 +186,7 @@ Skill body.
process.chdir(TEST_DIR) process.chdir(TEST_DIR)
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "ampcode-skill") const skill = skills.find(s => s.name === "ampcode-skill")
// #then // #then
@@ -227,7 +227,7 @@ Skill body.
process.chdir(TEST_DIR) process.chdir(TEST_DIR)
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "priority-skill") const skill = skills.find(s => s.name === "priority-skill")
// #then - mcp.json should take priority // #then - mcp.json should take priority
@@ -259,7 +259,7 @@ Skill body.
process.chdir(TEST_DIR) process.chdir(TEST_DIR)
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "direct-format") const skill = skills.find(s => s.name === "direct-format")
// #then // #then

View File

@@ -1,11 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { promises as fs } from "fs" import { promises as fs } from "fs"
import { join, basename } from "path" import { join, basename } from "path"
import { homedir } from "os" import { homedir } from "os"
import yaml from "js-yaml" import yaml from "js-yaml"
import { parseFrontmatter } from "../../shared/frontmatter" import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer" 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 { getClaudeConfigDir } from "../../shared"
import type { CommandDefinition } from "../claude-code-command-loader/types" import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types" import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
@@ -26,20 +25,17 @@ function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | u
return undefined return undefined
} }
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined { async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | undefined> {
const mcpJsonPath = join(skillDir, "mcp.json") const mcpJsonPath = join(skillDir, "mcp.json")
if (!existsSync(mcpJsonPath)) return undefined
try { try {
const content = readFileSync(mcpJsonPath, "utf-8") const content = await fs.readFile(mcpJsonPath, "utf-8")
const parsed = JSON.parse(content) as Record<string, unknown> const parsed = JSON.parse(content) as Record<string, unknown>
// AmpCode format: { "mcpServers": { "name": { ... } } }
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) { if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
return parsed.mcpServers as SkillMcpConfig return parsed.mcpServers as SkillMcpConfig
} }
// Also support direct format: { "name": { command: ..., args: ... } }
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) { if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
const hasCommandField = Object.values(parsed).some( const hasCommandField = Object.values(parsed).some(
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>) (v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
@@ -59,77 +55,7 @@ function parseAllowedTools(allowedTools: string | undefined): string[] | undefin
return allowedTools.split(/\s+/).filter(Boolean) return allowedTools.split(/\s+/).filter(Boolean)
} }
function loadSkillFromPath( async function loadSkillFromPath(
skillPath: string,
resolvedPath: string,
defaultName: string,
scope: SkillScope
): LoadedSkill | null {
try {
const content = readFileSync(skillPath, "utf-8")
const { data } = parseFrontmatter<SkillMetadata>(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<SkillMetadata>(fileContent)
lazyContent.content = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
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(
skillPath: string, skillPath: string,
resolvedPath: string, resolvedPath: string,
defaultName: string, defaultName: string,
@@ -139,7 +65,7 @@ async function loadSkillFromPathAsync(
const content = await fs.readFile(skillPath, "utf-8") const content = await fs.readFile(skillPath, "utf-8")
const { data } = parseFrontmatter<SkillMetadata>(content) const { data } = parseFrontmatter<SkillMetadata>(content)
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath) const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName const skillName = data.name || defaultName
@@ -198,60 +124,7 @@ $ARGUMENTS
} }
} }
/** async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
* 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<LoadedSkill[]> {
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
const skills: LoadedSkill[] = [] const skills: LoadedSkill[] = []
@@ -261,13 +134,13 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
const entryPath = join(skillsDir, entry.name) const entryPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) { if (entry.isDirectory() || entry.isSymbolicLink()) {
const resolvedPath = resolveSymlink(entryPath) const resolvedPath = await resolveSymlinkAsync(entryPath)
const dirName = entry.name const dirName = entry.name
const skillMdPath = join(resolvedPath, "SKILL.md") const skillMdPath = join(resolvedPath, "SKILL.md")
try { try {
await fs.access(skillMdPath) 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) if (skill) skills.push(skill)
continue continue
} catch { } catch {
@@ -276,7 +149,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
try { try {
await fs.access(namedSkillMdPath) 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) if (skill) skills.push(skill)
continue continue
} catch { } catch {
@@ -287,7 +160,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
if (isMarkdownFile(entry)) { if (isMarkdownFile(entry)) {
const skillName = basename(entry.name, ".md") 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) if (skill) skills.push(skill)
} }
} }
@@ -304,190 +177,86 @@ function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition
return result return result
} }
/** export async function loadUserSkills(): Promise<Record<string, CommandDefinition>> {
* Load skills from Claude Code user directory (~/.claude/skills/)
*/
export function loadUserSkills(): Record<string, CommandDefinition> {
const userSkillsDir = join(getClaudeConfigDir(), "skills") const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user") const skills = await loadSkillsFromDir(userSkillsDir, "user")
return skillsToRecord(skills) return skillsToRecord(skills)
} }
/** export async function loadProjectSkills(): Promise<Record<string, CommandDefinition>> {
* Load skills from Claude Code project directory (.claude/skills/)
*/
export function loadProjectSkills(): Record<string, CommandDefinition> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills") const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = loadSkillsFromDir(projectSkillsDir, "project") const skills = await loadSkillsFromDir(projectSkillsDir, "project")
return skillsToRecord(skills) return skillsToRecord(skills)
} }
/** export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
* Load skills from OpenCode global directory (~/.config/opencode/skill/)
*/
export function loadOpencodeGlobalSkills(): Record<string, CommandDefinition> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill") const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode") const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode")
return skillsToRecord(skills) return skillsToRecord(skills)
} }
/** export async function loadOpencodeProjectSkills(): Promise<Record<string, CommandDefinition>> {
* Load skills from OpenCode project directory (.opencode/skill/)
*/
export function loadOpencodeProjectSkills(): Record<string, CommandDefinition> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill") const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project") const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project")
return skillsToRecord(skills) return skillsToRecord(skills)
} }
/**
* Async version of loadUserSkills
*/
export async function loadUserSkillsAsync(): Promise<Record<string, CommandDefinition>> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = await loadSkillsFromDirAsync(userSkillsDir, "user")
return skillsToRecord(skills)
}
/**
* Async version of loadProjectSkills
*/
export async function loadProjectSkillsAsync(): Promise<Record<string, CommandDefinition>> {
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<Record<string, CommandDefinition>> {
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<Record<string, CommandDefinition>> {
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 { export interface DiscoverSkillsOptions {
includeClaudeCodePaths?: boolean includeClaudeCodePaths?: boolean
} }
/** export async function discoverAllSkills(): Promise<LoadedSkill[]> {
* Discover skills with optional filtering. const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
* When includeClaudeCodePaths is false, only loads from OpenCode paths. discoverOpencodeProjectSkills(),
*/ discoverProjectClaudeSkills(),
export function discoverSkills(options: DiscoverSkillsOptions = {}): LoadedSkill[] { discoverOpencodeGlobalSkills(),
const { includeClaudeCodePaths = true } = options discoverUserClaudeSkills(),
])
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")
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
} }
/** export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
* 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<LoadedSkill[]> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDirAsync(userSkillsDir, "user")
}
export async function discoverProjectClaudeSkillsAsync(): Promise<LoadedSkill[]> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDirAsync(projectSkillsDir, "project")
}
export async function discoverOpencodeGlobalSkillsAsync(): Promise<LoadedSkill[]> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
return loadSkillsFromDirAsync(opencodeSkillsDir, "opencode")
}
export async function discoverOpencodeProjectSkillsAsync(): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
return loadSkillsFromDirAsync(opencodeProjectDir, "opencode-project")
}
export async function discoverAllSkillsAsync(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
const { includeClaudeCodePaths = true } = options const { includeClaudeCodePaths = true } = options
const opencodeProjectSkills = await discoverOpencodeProjectSkillsAsync() const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([
const opencodeGlobalSkills = await discoverOpencodeGlobalSkillsAsync() discoverOpencodeProjectSkills(),
discoverOpencodeGlobalSkills(),
])
if (!includeClaudeCodePaths) { if (!includeClaudeCodePaths) {
return [...opencodeProjectSkills, ...opencodeGlobalSkills] return [...opencodeProjectSkills, ...opencodeGlobalSkills]
} }
const [projectSkills, userSkills] = await Promise.all([ const [projectSkills, userSkills] = await Promise.all([
discoverProjectClaudeSkillsAsync(), discoverProjectClaudeSkills(),
discoverUserClaudeSkillsAsync(), discoverUserClaudeSkills(),
]) ])
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
} }
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
const skills = await discoverSkills(options)
return skills.find(s => s.name === name)
}
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDir(userSkillsDir, "user")
}
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDir(projectSkillsDir, "project")
}
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
}
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
}

View File

@@ -94,7 +94,7 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
} }
} }
function discoverAllCommands(): CommandInfo[] { async function discoverAllCommands(): Promise<CommandInfo[]> {
const userCommandsDir = join(getClaudeConfigDir(), "commands") const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands") const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command") const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
@@ -105,7 +105,7 @@ function discoverAllCommands(): CommandInfo[] {
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
const skills = discoverAllSkills() const skills = await discoverAllSkills()
const skillCommands = skills.map(skillToCommandInfo) const skillCommands = skills.map(skillToCommandInfo)
return [ return [
@@ -117,8 +117,8 @@ function discoverAllCommands(): CommandInfo[] {
] ]
} }
function findCommand(commandName: string): CommandInfo | null { async function findCommand(commandName: string): Promise<CommandInfo | null> {
const allCommands = discoverAllCommands() const allCommands = await discoverAllCommands()
return allCommands.find( return allCommands.find(
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase() (cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
) ?? null ) ?? null
@@ -170,7 +170,7 @@ export interface ExecuteResult {
} }
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> { export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
const command = findCommand(parsed.command) const command = await findCommand(parsed.command)
if (!command) { if (!command) {
return { return {

View File

@@ -34,10 +34,10 @@ import {
} from "./features/context-injector"; } from "./features/context-injector";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import { import {
discoverUserClaudeSkillsAsync, discoverUserClaudeSkills,
discoverProjectClaudeSkillsAsync, discoverProjectClaudeSkills,
discoverOpencodeGlobalSkillsAsync, discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkillsAsync, discoverOpencodeProjectSkills,
mergeSkills, mergeSkills,
} from "./features/opencode-skill-loader"; } from "./features/opencode-skill-loader";
import { createBuiltinSkills } from "./features/builtin-skills"; import { createBuiltinSkills } from "./features/builtin-skills";
@@ -205,10 +205,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
}); });
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false; const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([ const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([
includeClaudeSkills ? discoverUserClaudeSkillsAsync() : Promise.resolve([]), includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]),
discoverOpencodeGlobalSkillsAsync(), discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkillsAsync() : Promise.resolve([]), includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]),
discoverOpencodeProjectSkillsAsync(), discoverOpencodeProjectSkills(),
]); ]);
const mergedSkills = mergeSkills( const mergedSkills = mergeSkills(
builtinSkills, builtinSkills,

View File

@@ -1,16 +1,16 @@
import { createBuiltinAgents } from "../agents"; import { createBuiltinAgents } from "../agents";
import { import {
loadUserCommandsAsync, loadUserCommands,
loadProjectCommandsAsync, loadProjectCommands,
loadOpencodeGlobalCommandsAsync, loadOpencodeGlobalCommands,
loadOpencodeProjectCommandsAsync, loadOpencodeProjectCommands,
} from "../features/claude-code-command-loader"; } from "../features/claude-code-command-loader";
import { loadBuiltinCommands } from "../features/builtin-commands"; import { loadBuiltinCommands } from "../features/builtin-commands";
import { import {
loadUserSkillsAsync, loadUserSkills,
loadProjectSkillsAsync, loadProjectSkills,
loadOpencodeGlobalSkillsAsync, loadOpencodeGlobalSkills,
loadOpencodeProjectSkillsAsync, loadOpencodeProjectSkills,
} from "../features/opencode-skill-loader"; } from "../features/opencode-skill-loader";
import { import {
loadUserAgents, loadUserAgents,
@@ -298,14 +298,14 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
opencodeGlobalSkills, opencodeGlobalSkills,
opencodeProjectSkills, opencodeProjectSkills,
] = await Promise.all([ ] = await Promise.all([
includeClaudeCommands ? loadUserCommandsAsync() : Promise.resolve({}), includeClaudeCommands ? loadUserCommands() : Promise.resolve({}),
includeClaudeCommands ? loadProjectCommandsAsync() : Promise.resolve({}), includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}),
loadOpencodeGlobalCommandsAsync(), loadOpencodeGlobalCommands(),
loadOpencodeProjectCommandsAsync(), loadOpencodeProjectCommands(),
includeClaudeSkills ? loadUserSkillsAsync() : Promise.resolve({}), includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
includeClaudeSkills ? loadProjectSkillsAsync() : Promise.resolve({}), includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}),
loadOpencodeGlobalSkillsAsync(), loadOpencodeGlobalSkills(),
loadOpencodeProjectSkillsAsync(), loadOpencodeProjectSkills(),
]); ]);
config.command = { config.command = {

View File

@@ -1,4 +1,5 @@
import { lstatSync, readlinkSync } from "fs" import { lstatSync, readlinkSync } from "fs"
import { promises as fs } from "fs"
import { resolve } from "path" import { resolve } from "path"
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean { export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
@@ -24,3 +25,16 @@ export function resolveSymlink(filePath: string): string {
return filePath return filePath
} }
} }
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
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
}
}

View File

@@ -129,22 +129,38 @@ async function formatMcpCapabilities(
} }
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition { export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly }) let cachedSkills: LoadedSkill[] | null = null
const skillInfos = skills.map(loadedSkillToInfo) let cachedDescription: string | null = null
const description = skillInfos.length === 0 const getSkills = async (): Promise<LoadedSkill[]> => {
? TOOL_DESCRIPTION_NO_SKILLS if (options.skills) return options.skills
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos) if (cachedSkills) return cachedSkills
cachedSkills = await discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
return cachedSkills
}
const getDescription = async (): Promise<string> => {
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({ return tool({
description, get description() {
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
},
args: { args: {
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
}, },
async execute(args: SkillArgs) { async execute(args: SkillArgs) {
const skill = options.skills const skills = await getSkills()
? skills.find(s => s.name === args.name) const skill = skills.find(s => s.name === args.name)
: skills.find(s => s.name === args.name)
if (!skill) { if (!skill) {
const available = skills.map(s => s.name).join(", ") const available = skills.map(s => s.name).join(", ")

View File

@@ -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<string> { async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
const sections: string[] = [] const sections: string[] = []
@@ -151,15 +138,40 @@ function formatCommandList(items: CommandInfo[]): string {
return lines.join("\n") return lines.join("\n")
} }
export const slashcommand: ToolDefinition = tool({ async function buildDescription(): Promise<string> {
description: `Load a skill to get detailed instructions for a specific task. 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. Skills provide specialized knowledge and step-by-step guidance.
Use this when a task matches an available skill's description. Use this when a task matches an available skill's description.
<available_skills> <available_skills>
${commandListForDescription} ${commandListForDescription}
</available_skills>`, </available_skills>`
}
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: { args: {
command: tool.schema command: tool.schema
@@ -171,7 +183,7 @@ ${commandListForDescription}
async execute(args) { async execute(args) {
const commands = discoverCommandsSync() const commands = discoverCommandsSync()
const skills = discoverAllSkills() const skills = await discoverAllSkills()
const allItems = [ const allItems = [
...commands, ...commands,
...skills.map(skillToCommandInfo), ...skills.map(skillToCommandInfo),