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;