feat(auto-slash-command): add builtin commands support and improve part extraction

- Add builtin commands to command discovery with 'builtin' scope
- Improve extractPromptText to prioritize slash command parts
- Add findSlashCommandPartIndex helper for locating slash commands
- Add CommandExecuteBefore hook support
This commit is contained in:
YeonGyu-Kim 2026-02-03 14:33:53 +09:00
parent f030992755
commit 4c4e1687da
5 changed files with 193 additions and 6 deletions

View File

@ -58,8 +58,31 @@ export function detectSlashCommand(text: string): ParsedSlashCommand | null {
export function extractPromptText( export function extractPromptText(
parts: Array<{ type: string; text?: string }> parts: Array<{ type: string; text?: string }>
): string { ): string {
return parts const textParts = parts.filter((p) => p.type === "text")
.filter((p) => p.type === "text") const slashPart = textParts.find((p) => (p.text ?? "").trim().startsWith("/"))
.map((p) => p.text || "") if (slashPart?.text) {
.join(" ") return slashPart.text
}
const nonSyntheticParts = textParts.filter(
(p) => !(p as { synthetic?: boolean }).synthetic
)
if (nonSyntheticParts.length > 0) {
return nonSyntheticParts.map((p) => p.text || "").join(" ")
}
return textParts.map((p) => p.text || "").join(" ")
}
export function findSlashCommandPartIndex(
parts: Array<{ type: string; text?: string }>
): number {
for (let idx = 0; idx < parts.length; idx += 1) {
const part = parts[idx]
if (part.type !== "text") continue
if ((part.text ?? "").trim().startsWith("/")) {
return idx
}
}
return -1
} }

View File

@ -8,13 +8,14 @@ import {
getClaudeConfigDir, getClaudeConfigDir,
getOpenCodeConfigDir, getOpenCodeConfigDir,
} from "../../shared" } from "../../shared"
import { loadBuiltinCommands } from "../../features/builtin-commands"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils" 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 type { ParsedSlashCommand } from "./types" import type { ParsedSlashCommand } from "./types"
interface CommandScope { interface CommandScope {
type: "user" | "project" | "opencode" | "opencode-project" | "skill" type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin"
} }
interface CommandMetadata { interface CommandMetadata {
@ -111,11 +112,25 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-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)
return [ return [
...builtinCommands,
...opencodeProjectCommands, ...opencodeProjectCommands,
...projectCommands, ...projectCommands,
...opencodeGlobalCommands, ...opencodeGlobalCommands,

View File

@ -2,6 +2,8 @@ import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test"
import type { import type {
AutoSlashCommandHookInput, AutoSlashCommandHookInput,
AutoSlashCommandHookOutput, AutoSlashCommandHookOutput,
CommandExecuteBeforeInput,
CommandExecuteBeforeOutput,
} from "./types" } from "./types"
// Import real shared module to avoid mock leaking to other test files // Import real shared module to avoid mock leaking to other test files
@ -251,4 +253,80 @@ describe("createAutoSlashCommandHook", () => {
expect(output.parts[0].text).toBe(originalText) expect(output.parts[0].text).toBe(originalText)
}) })
}) })
describe("command.execute.before hook", () => {
function createCommandInput(command: string, args: string = ""): CommandExecuteBeforeInput {
return {
command,
sessionID: `test-session-cmd-${Date.now()}-${Math.random()}`,
arguments: args,
}
}
function createCommandOutput(text?: string): CommandExecuteBeforeOutput {
return {
parts: text ? [{ type: "text", text }] : [],
}
}
it("should not modify output for unknown command", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("nonexistent-command-xyz")
const output = createCommandOutput("original text")
const originalText = output.parts[0].text
//#when
await hook["command.execute.before"](input, output)
//#then
expect(output.parts[0].text).toBe(originalText)
})
it("should add text part when parts array is empty and command is unknown", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("nonexistent-command-abc")
const output = createCommandOutput()
//#when
await hook["command.execute.before"](input, output)
//#then
expect(output.parts.length).toBe(0)
})
it("should inject template for known builtin commands like ralph-loop", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("ralph-loop")
const output = createCommandOutput("original")
//#when
await hook["command.execute.before"](input, output)
//#then
expect(output.parts[0].text).toContain("<auto-slash-command>")
expect(output.parts[0].text).toContain("/ralph-loop Command")
})
it("should pass command arguments correctly", async () => {
//#given
const hook = createAutoSlashCommandHook()
const input = createCommandInput("some-command", "arg1 arg2 arg3")
const output = createCommandOutput("original")
//#when
await hook["command.execute.before"](input, output)
//#then
expect(logMock).toHaveBeenCalledWith(
"[auto-slash-command] command.execute.before received",
expect.objectContaining({
command: "some-command",
arguments: "arg1 arg2 arg3",
})
)
})
})
}) })

View File

@ -1,6 +1,7 @@
import { import {
detectSlashCommand, detectSlashCommand,
extractPromptText, extractPromptText,
findSlashCommandPartIndex,
} from "./detector" } from "./detector"
import { executeSlashCommand, type ExecutorOptions } from "./executor" import { executeSlashCommand, type ExecutorOptions } from "./executor"
import { log } from "../../shared" import { log } from "../../shared"
@ -11,6 +12,8 @@ import {
import type { import type {
AutoSlashCommandHookInput, AutoSlashCommandHookInput,
AutoSlashCommandHookOutput, AutoSlashCommandHookOutput,
CommandExecuteBeforeInput,
CommandExecuteBeforeOutput,
} from "./types" } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader" import type { LoadedSkill } from "../../features/opencode-skill-loader"
@ -20,6 +23,7 @@ export * from "./constants"
export * from "./types" export * from "./types"
const sessionProcessedCommands = new Set<string>() const sessionProcessedCommands = new Set<string>()
const sessionProcessedCommandExecutions = new Set<string>()
export interface AutoSlashCommandHookOptions { export interface AutoSlashCommandHookOptions {
skills?: LoadedSkill[] skills?: LoadedSkill[]
@ -37,6 +41,14 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
): Promise<void> => { ): Promise<void> => {
const promptText = extractPromptText(output.parts) const promptText = extractPromptText(output.parts)
// Debug logging to diagnose slash command issues
if (promptText.startsWith("/")) {
log(`[auto-slash-command] chat.message hook received slash command`, {
sessionID: input.sessionID,
promptText: promptText.slice(0, 100),
})
}
if ( if (
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) || promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE) promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
@ -63,7 +75,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
const result = await executeSlashCommand(parsed, executorOptions) const result = await executeSlashCommand(parsed, executorOptions)
const idx = output.parts.findIndex((p) => p.type === "text" && p.text) const idx = findSlashCommandPartIndex(output.parts)
if (idx < 0) { if (idx < 0) {
return return
} }
@ -85,5 +97,54 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
command: parsed.command, command: parsed.command,
}) })
}, },
"command.execute.before": async (
input: CommandExecuteBeforeInput,
output: CommandExecuteBeforeOutput
): Promise<void> => {
const commandKey = `${input.sessionID}:${input.command}:${Date.now()}`
if (sessionProcessedCommandExecutions.has(commandKey)) {
return
}
log(`[auto-slash-command] command.execute.before received`, {
sessionID: input.sessionID,
command: input.command,
arguments: input.arguments,
})
const parsed = {
command: input.command,
args: input.arguments || "",
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
}
const result = await executeSlashCommand(parsed, executorOptions)
if (!result.success || !result.replacementText) {
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
sessionID: input.sessionID,
command: input.command,
error: result.error,
})
return
}
sessionProcessedCommandExecutions.add(commandKey)
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
const idx = findSlashCommandPartIndex(output.parts)
if (idx >= 0) {
output.parts[idx].text = taggedContent
} else {
output.parts.unshift({ type: "text", text: taggedContent })
}
log(`[auto-slash-command] command.execute.before - injected template`, {
sessionID: input.sessionID,
command: input.command,
})
},
} }
} }

View File

@ -21,3 +21,13 @@ export interface AutoSlashCommandResult {
parsedCommand?: ParsedSlashCommand parsedCommand?: ParsedSlashCommand
injectedMessage?: string injectedMessage?: string
} }
export interface CommandExecuteBeforeInput {
command: string
sessionID: string
arguments: string
}
export interface CommandExecuteBeforeOutput {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}