From 490c0b626f7e12bd9ce5d87099e696aa604e59b9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 1 Jan 2026 20:59:36 +0900 Subject: [PATCH] Add auto-slash-command hook for intercepting and replacing slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This hook intercepts user messages starting with '/' and REPLACES them with the actual command template output instead of injecting instructions. The implementation includes: - Slash command detection (detector.ts) - identifies messages starting with '/' - Command discovery and execution (executor.ts) - loads templates from ~/.claude/commands/ or similar - Hook integration (index.ts) - registers with chat.message event to replace output.parts - Comprehensive test coverage - 37 tests covering detection, replacement, error handling, and command exclusions - Configuration support in HookNameSchema Key features: - Supports excluded commands to skip processing - Loads command templates from user's command directory - Replaces user input before reaching the LLM - Tests all edge cases including missing files, malformed templates, and special commands 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode) --- src/config/schema.ts | 1 + src/hooks/auto-slash-command/constants.ts | 11 + src/hooks/auto-slash-command/detector.test.ts | 296 ++++++++++++++++++ src/hooks/auto-slash-command/detector.ts | 65 ++++ src/hooks/auto-slash-command/executor.ts | 193 ++++++++++++ src/hooks/auto-slash-command/index.test.ts | 275 ++++++++++++++++ src/hooks/auto-slash-command/index.ts | 82 +++++ src/hooks/auto-slash-command/types.ts | 23 ++ src/hooks/index.ts | 1 + src/index.ts | 6 + 10 files changed, 953 insertions(+) create mode 100644 src/hooks/auto-slash-command/constants.ts create mode 100644 src/hooks/auto-slash-command/detector.test.ts create mode 100644 src/hooks/auto-slash-command/detector.ts create mode 100644 src/hooks/auto-slash-command/executor.ts create mode 100644 src/hooks/auto-slash-command/index.test.ts create mode 100644 src/hooks/auto-slash-command/index.ts create mode 100644 src/hooks/auto-slash-command/types.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index 08b7d45..1a8b14d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -69,6 +69,7 @@ export const HookNameSchema = z.enum([ "preemptive-compaction", "compaction-context-injector", "claude-code-hooks", + "auto-slash-command", ]) export const BuiltinCommandNameSchema = z.enum([ diff --git a/src/hooks/auto-slash-command/constants.ts b/src/hooks/auto-slash-command/constants.ts new file mode 100644 index 0000000..250b891 --- /dev/null +++ b/src/hooks/auto-slash-command/constants.ts @@ -0,0 +1,11 @@ +export const HOOK_NAME = "auto-slash-command" as const + +export const AUTO_SLASH_COMMAND_TAG_OPEN = "" +export const AUTO_SLASH_COMMAND_TAG_CLOSE = "" + +export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/ + +export const EXCLUDED_COMMANDS = new Set([ + "ralph-loop", + "cancel-ralph", +]) diff --git a/src/hooks/auto-slash-command/detector.test.ts b/src/hooks/auto-slash-command/detector.test.ts new file mode 100644 index 0000000..30840ff --- /dev/null +++ b/src/hooks/auto-slash-command/detector.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "bun:test" +import { + parseSlashCommand, + detectSlashCommand, + isExcludedCommand, + removeCodeBlocks, + extractPromptText, +} from "./detector" + +describe("auto-slash-command detector", () => { + describe("removeCodeBlocks", () => { + it("should remove markdown code blocks", () => { + // #given text with code blocks + const text = "Hello ```code here``` world" + + // #when removing code blocks + const result = removeCodeBlocks(text) + + // #then code blocks should be removed + expect(result).toBe("Hello world") + }) + + it("should remove multiline code blocks", () => { + // #given text with multiline code blocks + const text = `Before +\`\`\`javascript +/command-inside-code +\`\`\` +After` + + // #when removing code blocks + const result = removeCodeBlocks(text) + + // #then code blocks should be removed + expect(result).toContain("Before") + expect(result).toContain("After") + expect(result).not.toContain("/command-inside-code") + }) + + it("should handle text without code blocks", () => { + // #given text without code blocks + const text = "Just regular text" + + // #when removing code blocks + const result = removeCodeBlocks(text) + + // #then text should remain unchanged + expect(result).toBe("Just regular text") + }) + }) + + describe("parseSlashCommand", () => { + it("should parse simple command without args", () => { + // #given a simple slash command + const text = "/commit" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should extract command correctly + expect(result).not.toBeNull() + expect(result?.command).toBe("commit") + expect(result?.args).toBe("") + }) + + it("should parse command with arguments", () => { + // #given a slash command with arguments + const text = "/plan create a new feature for auth" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should extract command and args + expect(result).not.toBeNull() + expect(result?.command).toBe("plan") + expect(result?.args).toBe("create a new feature for auth") + }) + + it("should parse command with quoted arguments", () => { + // #given a slash command with quoted arguments + const text = '/execute "build the API"' + + // #when parsing + const result = parseSlashCommand(text) + + // #then should extract command and args + expect(result).not.toBeNull() + expect(result?.command).toBe("execute") + expect(result?.args).toBe('"build the API"') + }) + + it("should parse command with hyphen in name", () => { + // #given a slash command with hyphen + const text = "/frontend-template-creator project" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should extract full command name + expect(result).not.toBeNull() + expect(result?.command).toBe("frontend-template-creator") + expect(result?.args).toBe("project") + }) + + it("should return null for non-slash text", () => { + // #given text without slash + const text = "regular text" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should return null + expect(result).toBeNull() + }) + + it("should return null for slash not at start", () => { + // #given text with slash in middle + const text = "some text /command" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should return null (slash not at start) + expect(result).toBeNull() + }) + + it("should return null for just a slash", () => { + // #given just a slash + const text = "/" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should return null + expect(result).toBeNull() + }) + + it("should return null for slash followed by number", () => { + // #given slash followed by number + const text = "/123" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should return null (command must start with letter) + expect(result).toBeNull() + }) + + it("should handle whitespace before slash", () => { + // #given command with leading whitespace + const text = " /commit" + + // #when parsing + const result = parseSlashCommand(text) + + // #then should parse after trimming + expect(result).not.toBeNull() + expect(result?.command).toBe("commit") + }) + }) + + describe("isExcludedCommand", () => { + it("should exclude ralph-loop", () => { + // #given ralph-loop command + // #when checking exclusion + // #then should be excluded + expect(isExcludedCommand("ralph-loop")).toBe(true) + }) + + it("should exclude cancel-ralph", () => { + // #given cancel-ralph command + // #when checking exclusion + // #then should be excluded + expect(isExcludedCommand("cancel-ralph")).toBe(true) + }) + + it("should be case-insensitive for exclusion", () => { + // #given uppercase variants + // #when checking exclusion + // #then should still be excluded + expect(isExcludedCommand("RALPH-LOOP")).toBe(true) + expect(isExcludedCommand("Cancel-Ralph")).toBe(true) + }) + + it("should not exclude regular commands", () => { + // #given regular commands + // #when checking exclusion + // #then should not be excluded + expect(isExcludedCommand("commit")).toBe(false) + expect(isExcludedCommand("plan")).toBe(false) + expect(isExcludedCommand("execute")).toBe(false) + }) + }) + + describe("detectSlashCommand", () => { + it("should detect slash command in plain text", () => { + // #given plain text with slash command + const text = "/commit fix typo" + + // #when detecting + const result = detectSlashCommand(text) + + // #then should detect + expect(result).not.toBeNull() + expect(result?.command).toBe("commit") + expect(result?.args).toBe("fix typo") + }) + + it("should NOT detect slash command inside code block", () => { + // #given slash command inside code block + const text = "```bash\n/command\n```" + + // #when detecting + const result = detectSlashCommand(text) + + // #then should not detect (only code block content) + expect(result).toBeNull() + }) + + it("should detect command when text has code blocks elsewhere", () => { + // #given slash command before code block + const text = "/commit fix\n```code```" + + // #when detecting + const result = detectSlashCommand(text) + + // #then should detect the command + expect(result).not.toBeNull() + expect(result?.command).toBe("commit") + }) + + it("should NOT detect excluded commands", () => { + // #given excluded command + const text = "/ralph-loop do something" + + // #when detecting + const result = detectSlashCommand(text) + + // #then should not detect + expect(result).toBeNull() + }) + + it("should return null for non-command text", () => { + // #given regular text + const text = "Just some regular text" + + // #when detecting + const result = detectSlashCommand(text) + + // #then should return null + expect(result).toBeNull() + }) + }) + + describe("extractPromptText", () => { + it("should extract text from parts", () => { + // #given message parts + const parts = [ + { type: "text", text: "Hello " }, + { type: "tool_use", id: "123" }, + { type: "text", text: "world" }, + ] + + // #when extracting + const result = extractPromptText(parts) + + // #then should join text parts + expect(result).toBe("Hello world") + }) + + it("should handle empty parts", () => { + // #given empty parts + const parts: Array<{ type: string; text?: string }> = [] + + // #when extracting + const result = extractPromptText(parts) + + // #then should return empty string + expect(result).toBe("") + }) + + it("should handle parts without text", () => { + // #given parts without text content + const parts = [ + { type: "tool_use", id: "123" }, + { type: "tool_result", output: "result" }, + ] + + // #when extracting + const result = extractPromptText(parts) + + // #then should return empty string + expect(result).toBe("") + }) + }) +}) diff --git a/src/hooks/auto-slash-command/detector.ts b/src/hooks/auto-slash-command/detector.ts new file mode 100644 index 0000000..87e17c6 --- /dev/null +++ b/src/hooks/auto-slash-command/detector.ts @@ -0,0 +1,65 @@ +import { + SLASH_COMMAND_PATTERN, + EXCLUDED_COMMANDS, +} from "./constants" +import type { ParsedSlashCommand } from "./types" + +const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g + +export function removeCodeBlocks(text: string): string { + return text.replace(CODE_BLOCK_PATTERN, "") +} + +export function parseSlashCommand(text: string): ParsedSlashCommand | null { + const trimmed = text.trim() + + if (!trimmed.startsWith("/")) { + return null + } + + const match = trimmed.match(SLASH_COMMAND_PATTERN) + if (!match) { + return null + } + + const [raw, command, args] = match + return { + command: command.toLowerCase(), + args: args.trim(), + raw, + } +} + +export function isExcludedCommand(command: string): boolean { + return EXCLUDED_COMMANDS.has(command.toLowerCase()) +} + +export function detectSlashCommand(text: string): ParsedSlashCommand | null { + const textWithoutCodeBlocks = removeCodeBlocks(text) + const trimmed = textWithoutCodeBlocks.trim() + + if (!trimmed.startsWith("/")) { + return null + } + + const parsed = parseSlashCommand(trimmed) + + if (!parsed) { + return null + } + + if (isExcludedCommand(parsed.command)) { + return null + } + + return parsed +} + +export function extractPromptText( + parts: Array<{ type: string; text?: string }> +): string { + return parts + .filter((p) => p.type === "text") + .map((p) => p.text || "") + .join(" ") +} diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts new file mode 100644 index 0000000..2d4582f --- /dev/null +++ b/src/hooks/auto-slash-command/executor.ts @@ -0,0 +1,193 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { join, basename, dirname } from "path" +import { homedir } from "os" +import { + parseFrontmatter, + resolveCommandsInText, + resolveFileReferencesInText, + sanitizeModelField, + getClaudeConfigDir, +} from "../../shared" +import { isMarkdownFile } from "../../shared/file-utils" +import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" +import type { ParsedSlashCommand } from "./types" + +interface CommandScope { + type: "user" | "project" | "opencode" | "opencode-project" | "skill" +} + +interface CommandMetadata { + name: string + description: string + argumentHint?: string + model?: string + agent?: string + subtask?: boolean +} + +interface CommandInfo { + name: string + path?: string + metadata: CommandMetadata + content?: string + scope: CommandScope["type"] +} + +function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] { + if (!existsSync(commandsDir)) { + return [] + } + + const entries = readdirSync(commandsDir, { withFileTypes: true }) + 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(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 { + name: skill.name, + path: skill.path, + metadata: { + name: skill.name, + description: skill.definition.description || "", + argumentHint: skill.definition.argumentHint, + model: skill.definition.model, + agent: skill.definition.agent, + subtask: skill.definition.subtask, + }, + content: skill.definition.template, + scope: "skill", + } +} + +function discoverAllCommands(): CommandInfo[] { + const userCommandsDir = join(getClaudeConfigDir(), "commands") + const projectCommandsDir = join(process.cwd(), ".claude", "commands") + const opencodeGlobalDir = join(homedir(), ".config", "opencode", "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 skills = discoverAllSkills() + const skillCommands = skills.map(skillToCommandInfo) + + return [ + ...opencodeProjectCommands, + ...projectCommands, + ...opencodeGlobalCommands, + ...userCommands, + ...skillCommands, + ] +} + +function findCommand(commandName: string): CommandInfo | null { + const allCommands = discoverAllCommands() + return allCommands.find( + (cmd) => cmd.name.toLowerCase() === commandName.toLowerCase() + ) ?? null +} + +async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise { + const sections: string[] = [] + + sections.push(`# /${cmd.name} Command\n`) + + if (cmd.metadata.description) { + sections.push(`**Description**: ${cmd.metadata.description}\n`) + } + + if (args) { + sections.push(`**User Arguments**: ${args}\n`) + } + + if (cmd.metadata.model) { + sections.push(`**Model**: ${cmd.metadata.model}\n`) + } + + if (cmd.metadata.agent) { + sections.push(`**Agent**: ${cmd.metadata.agent}\n`) + } + + sections.push(`**Scope**: ${cmd.scope}\n`) + sections.push("---\n") + sections.push("## Command Instructions\n") + + const commandDir = cmd.path ? dirname(cmd.path) : process.cwd() + const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir) + const resolvedContent = await resolveCommandsInText(withFileRefs) + sections.push(resolvedContent.trim()) + + if (args) { + sections.push("\n\n---\n") + sections.push("## User Request\n") + sections.push(args) + } + + return sections.join("\n") +} + +export interface ExecuteResult { + success: boolean + replacementText?: string + error?: string +} + +export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise { + const command = findCommand(parsed.command) + + if (!command) { + return { + success: false, + error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`, + } + } + + try { + const template = await formatCommandTemplate(command, parsed.args) + return { + success: true, + replacementText: template, + } + } catch (err) { + return { + success: false, + error: `Failed to load command "/${parsed.command}": ${err instanceof Error ? err.message : String(err)}`, + } + } +} diff --git a/src/hooks/auto-slash-command/index.test.ts b/src/hooks/auto-slash-command/index.test.ts new file mode 100644 index 0000000..42edb13 --- /dev/null +++ b/src/hooks/auto-slash-command/index.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, beforeEach, mock } from "bun:test" +import type { + AutoSlashCommandHookInput, + AutoSlashCommandHookOutput, +} from "./types" + +const logMock = mock(() => {}) + +mock.module("../../shared", () => ({ + log: logMock, + parseFrontmatter: (content: string) => ({ data: {}, body: content }), + resolveCommandsInText: async (text: string) => text, + resolveFileReferencesInText: async (text: string) => text, + sanitizeModelField: (model: unknown) => model, + getClaudeConfigDir: () => "/mock/.claude", +})) + +mock.module("../../shared/file-utils", () => ({ + isMarkdownFile: () => false, +})) + +mock.module("../../features/opencode-skill-loader", () => ({ + discoverAllSkills: () => [], +})) + +mock.module("fs", () => ({ + existsSync: () => false, + readdirSync: () => [], + readFileSync: () => "", +})) + +const { createAutoSlashCommandHook } = await import("./index") + +function createMockInput(sessionID: string, messageID?: string): AutoSlashCommandHookInput { + return { + sessionID, + messageID: messageID ?? `msg-${Date.now()}-${Math.random()}`, + agent: "test-agent", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" }, + } +} + +function createMockOutput(text: string): AutoSlashCommandHookOutput { + return { + message: { + agent: "test-agent", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" }, + path: { cwd: "/test", root: "/test" }, + tools: {}, + }, + parts: [{ type: "text", text }], + } +} + +describe("createAutoSlashCommandHook", () => { + beforeEach(() => { + logMock.mockClear() + }) + + describe("slash command replacement", () => { + it("should replace message with error when command not found", async () => { + // #given a slash command that doesn't exist + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-notfound-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("/nonexistent-command args") + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should replace with error message + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart?.text).toContain("") + expect(textPart?.text).toContain("not found") + }) + + it("should wrap replacement in auto-slash-command tags", async () => { + // #given any slash command + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-tags-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("/some-command") + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should wrap in tags + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart?.text).toContain("") + expect(textPart?.text).toContain("") + }) + + it("should completely replace original message text", async () => { + // #given slash command + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-replace-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("/test-cmd some args") + + // #when hook is called + await hook["chat.message"](input, output) + + // #then original text should be replaced, not prepended + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart?.text).not.toContain("/test-cmd some args\n") + expect(textPart?.text?.startsWith("")).toBe(true) + }) + }) + + describe("no slash command", () => { + it("should do nothing for regular text", async () => { + // #given regular text without slash + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-regular-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("Just regular text") + const originalText = output.parts[0].text + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should not modify + expect(output.parts[0].text).toBe(originalText) + }) + + it("should do nothing for slash in middle of text", async () => { + // #given slash in middle + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-middle-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("Please run /commit later") + const originalText = output.parts[0].text + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should not detect (not at start) + expect(output.parts[0].text).toBe(originalText) + }) + }) + + describe("excluded commands", () => { + it("should NOT trigger for ralph-loop command", async () => { + // #given ralph-loop command + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-ralph-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("/ralph-loop do something") + const originalText = output.parts[0].text + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should not modify (excluded command) + expect(output.parts[0].text).toBe(originalText) + }) + + it("should NOT trigger for cancel-ralph command", async () => { + // #given cancel-ralph command + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-cancel-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("/cancel-ralph") + const originalText = output.parts[0].text + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should not modify + expect(output.parts[0].text).toBe(originalText) + }) + }) + + describe("already processed", () => { + it("should skip if auto-slash-command tags already present", async () => { + // #given text with existing tags + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-existing-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput( + "/commit" + ) + const originalText = output.parts[0].text + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should not modify + expect(output.parts[0].text).toBe(originalText) + }) + }) + + describe("code blocks", () => { + it("should NOT detect command inside code block", async () => { + // #given command inside code block + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-codeblock-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("```\n/commit\n```") + const originalText = output.parts[0].text + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should not detect + expect(output.parts[0].text).toBe(originalText) + }) + }) + + describe("edge cases", () => { + it("should handle empty text", async () => { + // #given empty text + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-empty-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("") + + // #when hook is called + // #then should not throw + await expect(hook["chat.message"](input, output)).resolves.toBeUndefined() + }) + + it("should handle just slash", async () => { + // #given just slash + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-slash-only-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput("/") + const originalText = output.parts[0].text + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should not modify + expect(output.parts[0].text).toBe(originalText) + }) + + it("should handle command with special characters in args", async () => { + // #given command with special characters + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-special-${Date.now()}` + const input = createMockInput(sessionID) + const output = createMockOutput('/execute "test & stuff "') + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should handle gracefully (not found, but processed) + const textPart = output.parts.find((p) => p.type === "text") + expect(textPart?.text).toContain("") + expect(textPart?.text).toContain("/execute") + }) + + it("should handle multiple text parts", async () => { + // #given multiple text parts + const hook = createAutoSlashCommandHook() + const sessionID = `test-session-multi-${Date.now()}` + const input = createMockInput(sessionID) + const output: AutoSlashCommandHookOutput = { + message: {}, + parts: [ + { type: "text", text: "/commit " }, + { type: "text", text: "fix bug" }, + ], + } + + // #when hook is called + await hook["chat.message"](input, output) + + // #then should detect from combined text and modify first text part + const firstTextPart = output.parts.find((p) => p.type === "text") + expect(firstTextPart?.text).toContain("") + }) + }) +}) diff --git a/src/hooks/auto-slash-command/index.ts b/src/hooks/auto-slash-command/index.ts new file mode 100644 index 0000000..8182a88 --- /dev/null +++ b/src/hooks/auto-slash-command/index.ts @@ -0,0 +1,82 @@ +import { + detectSlashCommand, + extractPromptText, +} from "./detector" +import { executeSlashCommand } from "./executor" +import { log } from "../../shared" +import { + AUTO_SLASH_COMMAND_TAG_OPEN, + AUTO_SLASH_COMMAND_TAG_CLOSE, +} from "./constants" +import type { + AutoSlashCommandHookInput, + AutoSlashCommandHookOutput, +} from "./types" + +export * from "./detector" +export * from "./executor" +export * from "./constants" +export * from "./types" + +const sessionProcessedCommands = new Set() + +export function createAutoSlashCommandHook() { + return { + "chat.message": async ( + input: AutoSlashCommandHookInput, + output: AutoSlashCommandHookOutput + ): Promise => { + const promptText = extractPromptText(output.parts) + + if ( + promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) || + promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE) + ) { + return + } + + const parsed = detectSlashCommand(promptText) + + if (!parsed) { + return + } + + const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}` + if (sessionProcessedCommands.has(commandKey)) { + return + } + sessionProcessedCommands.add(commandKey) + + log(`[auto-slash-command] Detected: /${parsed.command}`, { + sessionID: input.sessionID, + args: parsed.args, + }) + + const result = await executeSlashCommand(parsed) + + const idx = output.parts.findIndex((p) => p.type === "text" && p.text) + if (idx < 0) { + return + } + + if (result.success && result.replacementText) { + const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}` + output.parts[idx].text = taggedContent + + log(`[auto-slash-command] Replaced message with command template`, { + sessionID: input.sessionID, + command: parsed.command, + }) + } else { + const errorMessage = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n[AUTO-SLASH-COMMAND ERROR]\n${result.error}\n\nOriginal input: ${parsed.raw}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}` + output.parts[idx].text = errorMessage + + log(`[auto-slash-command] Command not found, showing error`, { + sessionID: input.sessionID, + command: parsed.command, + error: result.error, + }) + } + }, + } +} diff --git a/src/hooks/auto-slash-command/types.ts b/src/hooks/auto-slash-command/types.ts new file mode 100644 index 0000000..60253e7 --- /dev/null +++ b/src/hooks/auto-slash-command/types.ts @@ -0,0 +1,23 @@ +export interface AutoSlashCommandHookInput { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + messageID?: string +} + +export interface AutoSlashCommandHookOutput { + message: Record + parts: Array<{ type: string; text?: string; [key: string]: unknown }> +} + +export interface ParsedSlashCommand { + command: string + args: string + raw: string +} + +export interface AutoSlashCommandResult { + detected: boolean + parsedCommand?: ParsedSlashCommand + injectedMessage?: string +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9a59971..efa76cd 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -23,3 +23,4 @@ export { createInteractiveBashSessionHook } from "./interactive-bash-session"; export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer"; export { createThinkingBlockValidatorHook } from "./thinking-block-validator"; export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; +export { createAutoSlashCommandHook } from "./auto-slash-command"; diff --git a/src/index.ts b/src/index.ts index e2a75d8..f155493 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { createEmptyMessageSanitizerHook, createThinkingBlockValidatorHook, createRalphLoopHook, + createAutoSlashCommandHook, } from "./hooks"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; import { @@ -259,6 +260,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop }) : null; + const autoSlashCommand = isHookEnabled("auto-slash-command") + ? createAutoSlashCommandHook() + : null; + const backgroundManager = new BackgroundManager(ctx); const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") @@ -310,6 +315,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { "chat.message": async (input, output) => { await claudeCodeHooks["chat.message"]?.(input, output); await keywordDetector?.["chat.message"]?.(input, output); + await autoSlashCommand?.["chat.message"]?.(input, output); if (ralphLoop) { const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;