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:
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
16
src/index.ts
16
src/index.ts
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(", ")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user