From 4c4e1687da6ee9b7055a91b8e49d610f497bd73c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 3 Feb 2026 14:33:53 +0900 Subject: [PATCH] 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 --- src/hooks/auto-slash-command/detector.ts | 31 +++++++-- src/hooks/auto-slash-command/executor.ts | 17 ++++- src/hooks/auto-slash-command/index.test.ts | 78 ++++++++++++++++++++++ src/hooks/auto-slash-command/index.ts | 63 ++++++++++++++++- src/hooks/auto-slash-command/types.ts | 10 +++ 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/src/hooks/auto-slash-command/detector.ts b/src/hooks/auto-slash-command/detector.ts index 87e17c6ea..c4b8107a1 100644 --- a/src/hooks/auto-slash-command/detector.ts +++ b/src/hooks/auto-slash-command/detector.ts @@ -58,8 +58,31 @@ export function detectSlashCommand(text: string): ParsedSlashCommand | null { export function extractPromptText( parts: Array<{ type: string; text?: string }> ): string { - return parts - .filter((p) => p.type === "text") - .map((p) => p.text || "") - .join(" ") + const textParts = parts.filter((p) => p.type === "text") + const slashPart = textParts.find((p) => (p.text ?? "").trim().startsWith("/")) + if (slashPart?.text) { + 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 } diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index 1ab1d2411..24ca3003e 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -8,13 +8,14 @@ import { getClaudeConfigDir, getOpenCodeConfigDir, } 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 type { ParsedSlashCommand } from "./types" interface CommandScope { - type: "user" | "project" | "opencode" | "opencode-project" | "skill" + type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" } interface CommandMetadata { @@ -111,11 +112,25 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise ({ + 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 skillCommands = skills.map(skillToCommandInfo) return [ + ...builtinCommands, ...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, diff --git a/src/hooks/auto-slash-command/index.test.ts b/src/hooks/auto-slash-command/index.test.ts index fec1198aa..190d14fa3 100644 --- a/src/hooks/auto-slash-command/index.test.ts +++ b/src/hooks/auto-slash-command/index.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test" import type { AutoSlashCommandHookInput, AutoSlashCommandHookOutput, + CommandExecuteBeforeInput, + CommandExecuteBeforeOutput, } from "./types" // 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) }) }) + + 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("") + 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", + }) + ) + }) + }) }) diff --git a/src/hooks/auto-slash-command/index.ts b/src/hooks/auto-slash-command/index.ts index 88b50617a..e5e30d2e9 100644 --- a/src/hooks/auto-slash-command/index.ts +++ b/src/hooks/auto-slash-command/index.ts @@ -1,6 +1,7 @@ import { detectSlashCommand, extractPromptText, + findSlashCommandPartIndex, } from "./detector" import { executeSlashCommand, type ExecutorOptions } from "./executor" import { log } from "../../shared" @@ -11,6 +12,8 @@ import { import type { AutoSlashCommandHookInput, AutoSlashCommandHookOutput, + CommandExecuteBeforeInput, + CommandExecuteBeforeOutput, } from "./types" import type { LoadedSkill } from "../../features/opencode-skill-loader" @@ -20,6 +23,7 @@ export * from "./constants" export * from "./types" const sessionProcessedCommands = new Set() +const sessionProcessedCommandExecutions = new Set() export interface AutoSlashCommandHookOptions { skills?: LoadedSkill[] @@ -37,6 +41,14 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions ): Promise => { 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 ( promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) || promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE) @@ -63,7 +75,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions 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) { return } @@ -85,5 +97,54 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions command: parsed.command, }) }, + + "command.execute.before": async ( + input: CommandExecuteBeforeInput, + output: CommandExecuteBeforeOutput + ): Promise => { + 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, + }) + }, } } diff --git a/src/hooks/auto-slash-command/types.ts b/src/hooks/auto-slash-command/types.ts index 60253e79b..f2bc8617a 100644 --- a/src/hooks/auto-slash-command/types.ts +++ b/src/hooks/auto-slash-command/types.ts @@ -21,3 +21,13 @@ export interface AutoSlashCommandResult { parsedCommand?: ParsedSlashCommand injectedMessage?: string } + +export interface CommandExecuteBeforeInput { + command: string + sessionID: string + arguments: string +} + +export interface CommandExecuteBeforeOutput { + parts: Array<{ type: string; text?: string; [key: string]: unknown }> +}