fix(slashcommand): support parent config dirs in command execution path to match discovery
This commit is contained in:
@@ -1,86 +1,25 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
import { dirname } from "path"
|
||||||
import { join, basename, dirname } from "path"
|
|
||||||
import {
|
import {
|
||||||
parseFrontmatter,
|
|
||||||
resolveCommandsInText,
|
resolveCommandsInText,
|
||||||
resolveFileReferencesInText,
|
resolveFileReferencesInText,
|
||||||
sanitizeModelField,
|
|
||||||
getClaudeConfigDir,
|
|
||||||
getOpenCodeConfigDir,
|
|
||||||
discoverPluginCommandDefinitions,
|
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { loadBuiltinCommands } from "../../features/builtin-commands"
|
|
||||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
|
||||||
import { isMarkdownFile } from "../../shared/file-utils"
|
|
||||||
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||||
|
import { discoverCommandsSync } from "../../tools/slashcommand"
|
||||||
|
import type { CommandInfo as DiscoveredCommandInfo, CommandMetadata } from "../../tools/slashcommand/types"
|
||||||
import type { ParsedSlashCommand } from "./types"
|
import type { ParsedSlashCommand } from "./types"
|
||||||
|
|
||||||
interface CommandScope {
|
interface SkillCommandInfo {
|
||||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandMetadata {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
argumentHint?: string
|
|
||||||
model?: string
|
|
||||||
agent?: string
|
|
||||||
subtask?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandInfo {
|
|
||||||
name: string
|
name: string
|
||||||
path?: string
|
path?: string
|
||||||
metadata: CommandMetadata
|
metadata: CommandMetadata
|
||||||
content?: string
|
content?: string
|
||||||
scope: CommandScope["type"]
|
scope: "skill"
|
||||||
lazyContentLoader?: LazyContentLoader
|
lazyContentLoader?: LazyContentLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
type CommandInfo = DiscoveredCommandInfo | SkillCommandInfo
|
||||||
if (!existsSync(commandsDir)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
function skillToCommandInfo(skill: LoadedSkill): SkillCommandInfo {
|
||||||
const commands: CommandInfo[] = []
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!isMarkdownFile(entry)) continue
|
|
||||||
|
|
||||||
const commandPath = join(commandsDir, entry.name)
|
|
||||||
const commandName = basename(entry.name, ".md")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = readFileSync(commandPath, "utf-8")
|
|
||||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
|
||||||
|
|
||||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
|
||||||
const metadata: CommandMetadata = {
|
|
||||||
name: commandName,
|
|
||||||
description: data.description || "",
|
|
||||||
argumentHint: data["argument-hint"],
|
|
||||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
|
||||||
agent: data.agent,
|
|
||||||
subtask: Boolean(data.subtask),
|
|
||||||
}
|
|
||||||
|
|
||||||
commands.push({
|
|
||||||
name: commandName,
|
|
||||||
path: commandPath,
|
|
||||||
metadata,
|
|
||||||
content: body,
|
|
||||||
scope,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return commands
|
|
||||||
}
|
|
||||||
|
|
||||||
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
|
||||||
return {
|
return {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
path: skill.path,
|
path: skill.path,
|
||||||
@@ -104,60 +43,30 @@ export interface ExecutorOptions {
|
|||||||
enabledPluginsOverride?: Record<string, boolean>
|
enabledPluginsOverride?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] {
|
function filterDiscoveredCommandsByScope(
|
||||||
const pluginDefinitions = discoverPluginCommandDefinitions(options)
|
commands: DiscoveredCommandInfo[],
|
||||||
|
scope: DiscoveredCommandInfo["scope"],
|
||||||
return Object.entries(pluginDefinitions).map(([name, definition]) => ({
|
): DiscoveredCommandInfo[] {
|
||||||
name,
|
return commands.filter(command => command.scope === scope)
|
||||||
metadata: {
|
|
||||||
name,
|
|
||||||
description: definition.description || "",
|
|
||||||
model: definition.model,
|
|
||||||
agent: definition.agent,
|
|
||||||
subtask: definition.subtask,
|
|
||||||
},
|
|
||||||
content: definition.template,
|
|
||||||
scope: "plugin",
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
const discoveredCommands = discoverCommandsSync(process.cwd(), {
|
||||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
pluginsEnabled: options?.pluginsEnabled,
|
||||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
enabledPluginsOverride: options?.enabledPluginsOverride,
|
||||||
const opencodeGlobalDir = join(configDir, "command")
|
})
|
||||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
|
||||||
|
|
||||||
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
|
||||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
|
||||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
|
||||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
|
||||||
const builtinCommandsMap = loadBuiltinCommands()
|
|
||||||
const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map(cmd => ({
|
|
||||||
name: cmd.name,
|
|
||||||
metadata: {
|
|
||||||
name: cmd.name,
|
|
||||||
description: cmd.description || "",
|
|
||||||
model: cmd.model,
|
|
||||||
agent: cmd.agent,
|
|
||||||
subtask: cmd.subtask,
|
|
||||||
},
|
|
||||||
content: cmd.template,
|
|
||||||
scope: "builtin",
|
|
||||||
}))
|
|
||||||
|
|
||||||
const skills = options?.skills ?? await discoverAllSkills()
|
const skills = options?.skills ?? await discoverAllSkills()
|
||||||
const skillCommands = skills.map(skillToCommandInfo)
|
const skillCommands = skills.map(skillToCommandInfo)
|
||||||
const pluginCommands = discoverPluginCommands(options)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...builtinCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
|
||||||
...opencodeProjectCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
|
||||||
...projectCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
|
||||||
...opencodeGlobalCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
|
||||||
...userCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
|
||||||
...skillCommands,
|
...skillCommands,
|
||||||
...pluginCommands,
|
...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { executeSlashCommand } from "../../hooks/auto-slash-command/executor"
|
||||||
|
import { discoverCommandsSync } from "./command-discovery"
|
||||||
|
|
||||||
|
describe("slashcommand discovery and execution compatibility", () => {
|
||||||
|
let tempDir = ""
|
||||||
|
let originalWorkingDirectory = ""
|
||||||
|
let originalOpencodeConfigDir: string | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), "omo-slashcommand-compat-test-"))
|
||||||
|
originalWorkingDirectory = process.cwd()
|
||||||
|
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalWorkingDirectory)
|
||||||
|
|
||||||
|
if (originalOpencodeConfigDir === undefined) {
|
||||||
|
delete process.env.OPENCODE_CONFIG_DIR
|
||||||
|
} else {
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("executes commands discovered from a parent opencode config dir", async () => {
|
||||||
|
// given
|
||||||
|
const projectDir = join(tempDir, "project")
|
||||||
|
const opencodeRootDir = join(tempDir, "opencode-root")
|
||||||
|
const profileConfigDir = join(opencodeRootDir, "profiles", "codex")
|
||||||
|
const parentCommandDir = join(opencodeRootDir, "command")
|
||||||
|
const commandName = "parent-only-command"
|
||||||
|
|
||||||
|
mkdirSync(projectDir, { recursive: true })
|
||||||
|
mkdirSync(profileConfigDir, { recursive: true })
|
||||||
|
mkdirSync(parentCommandDir, { recursive: true })
|
||||||
|
writeFileSync(
|
||||||
|
join(parentCommandDir, `${commandName}.md`),
|
||||||
|
`---\ndescription: Parent config command\n---\nExecute from parent config.\n`,
|
||||||
|
)
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = profileConfigDir
|
||||||
|
process.chdir(projectDir)
|
||||||
|
|
||||||
|
expect(discoverCommandsSync(projectDir).some(command => command.name === commandName)).toBe(true)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await executeSlashCommand({
|
||||||
|
command: commandName,
|
||||||
|
args: "",
|
||||||
|
raw: `/${commandName}`,
|
||||||
|
}, { skills: [] })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.replacementText).toContain("Execute from parent config.")
|
||||||
|
expect(result.replacementText).toContain("**Scope**: opencode")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user