Skip to content

Commit 490c0b6

Browse files
committed
Add auto-slash-command hook for intercepting and replacing slash commands
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)
1 parent b30c17a commit 490c0b6

File tree

10 files changed

+953
-0
lines changed

10 files changed

+953
-0
lines changed

src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const HookNameSchema = z.enum([
6969
"preemptive-compaction",
7070
"compaction-context-injector",
7171
"claude-code-hooks",
72+
"auto-slash-command",
7273
])
7374

7475
export const BuiltinCommandNameSchema = z.enum([
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const HOOK_NAME = "auto-slash-command" as const
2+
3+
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
4+
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
5+
6+
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/
7+
8+
export const EXCLUDED_COMMANDS = new Set([
9+
"ralph-loop",
10+
"cancel-ralph",
11+
])
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { describe, expect, it } from "bun:test"
2+
import {
3+
parseSlashCommand,
4+
detectSlashCommand,
5+
isExcludedCommand,
6+
removeCodeBlocks,
7+
extractPromptText,
8+
} from "./detector"
9+
10+
describe("auto-slash-command detector", () => {
11+
describe("removeCodeBlocks", () => {
12+
it("should remove markdown code blocks", () => {
13+
// #given text with code blocks
14+
const text = "Hello ```code here``` world"
15+
16+
// #when removing code blocks
17+
const result = removeCodeBlocks(text)
18+
19+
// #then code blocks should be removed
20+
expect(result).toBe("Hello world")
21+
})
22+
23+
it("should remove multiline code blocks", () => {
24+
// #given text with multiline code blocks
25+
const text = `Before
26+
\`\`\`javascript
27+
/command-inside-code
28+
\`\`\`
29+
After`
30+
31+
// #when removing code blocks
32+
const result = removeCodeBlocks(text)
33+
34+
// #then code blocks should be removed
35+
expect(result).toContain("Before")
36+
expect(result).toContain("After")
37+
expect(result).not.toContain("/command-inside-code")
38+
})
39+
40+
it("should handle text without code blocks", () => {
41+
// #given text without code blocks
42+
const text = "Just regular text"
43+
44+
// #when removing code blocks
45+
const result = removeCodeBlocks(text)
46+
47+
// #then text should remain unchanged
48+
expect(result).toBe("Just regular text")
49+
})
50+
})
51+
52+
describe("parseSlashCommand", () => {
53+
it("should parse simple command without args", () => {
54+
// #given a simple slash command
55+
const text = "/commit"
56+
57+
// #when parsing
58+
const result = parseSlashCommand(text)
59+
60+
// #then should extract command correctly
61+
expect(result).not.toBeNull()
62+
expect(result?.command).toBe("commit")
63+
expect(result?.args).toBe("")
64+
})
65+
66+
it("should parse command with arguments", () => {
67+
// #given a slash command with arguments
68+
const text = "/plan create a new feature for auth"
69+
70+
// #when parsing
71+
const result = parseSlashCommand(text)
72+
73+
// #then should extract command and args
74+
expect(result).not.toBeNull()
75+
expect(result?.command).toBe("plan")
76+
expect(result?.args).toBe("create a new feature for auth")
77+
})
78+
79+
it("should parse command with quoted arguments", () => {
80+
// #given a slash command with quoted arguments
81+
const text = '/execute "build the API"'
82+
83+
// #when parsing
84+
const result = parseSlashCommand(text)
85+
86+
// #then should extract command and args
87+
expect(result).not.toBeNull()
88+
expect(result?.command).toBe("execute")
89+
expect(result?.args).toBe('"build the API"')
90+
})
91+
92+
it("should parse command with hyphen in name", () => {
93+
// #given a slash command with hyphen
94+
const text = "/frontend-template-creator project"
95+
96+
// #when parsing
97+
const result = parseSlashCommand(text)
98+
99+
// #then should extract full command name
100+
expect(result).not.toBeNull()
101+
expect(result?.command).toBe("frontend-template-creator")
102+
expect(result?.args).toBe("project")
103+
})
104+
105+
it("should return null for non-slash text", () => {
106+
// #given text without slash
107+
const text = "regular text"
108+
109+
// #when parsing
110+
const result = parseSlashCommand(text)
111+
112+
// #then should return null
113+
expect(result).toBeNull()
114+
})
115+
116+
it("should return null for slash not at start", () => {
117+
// #given text with slash in middle
118+
const text = "some text /command"
119+
120+
// #when parsing
121+
const result = parseSlashCommand(text)
122+
123+
// #then should return null (slash not at start)
124+
expect(result).toBeNull()
125+
})
126+
127+
it("should return null for just a slash", () => {
128+
// #given just a slash
129+
const text = "/"
130+
131+
// #when parsing
132+
const result = parseSlashCommand(text)
133+
134+
// #then should return null
135+
expect(result).toBeNull()
136+
})
137+
138+
it("should return null for slash followed by number", () => {
139+
// #given slash followed by number
140+
const text = "/123"
141+
142+
// #when parsing
143+
const result = parseSlashCommand(text)
144+
145+
// #then should return null (command must start with letter)
146+
expect(result).toBeNull()
147+
})
148+
149+
it("should handle whitespace before slash", () => {
150+
// #given command with leading whitespace
151+
const text = " /commit"
152+
153+
// #when parsing
154+
const result = parseSlashCommand(text)
155+
156+
// #then should parse after trimming
157+
expect(result).not.toBeNull()
158+
expect(result?.command).toBe("commit")
159+
})
160+
})
161+
162+
describe("isExcludedCommand", () => {
163+
it("should exclude ralph-loop", () => {
164+
// #given ralph-loop command
165+
// #when checking exclusion
166+
// #then should be excluded
167+
expect(isExcludedCommand("ralph-loop")).toBe(true)
168+
})
169+
170+
it("should exclude cancel-ralph", () => {
171+
// #given cancel-ralph command
172+
// #when checking exclusion
173+
// #then should be excluded
174+
expect(isExcludedCommand("cancel-ralph")).toBe(true)
175+
})
176+
177+
it("should be case-insensitive for exclusion", () => {
178+
// #given uppercase variants
179+
// #when checking exclusion
180+
// #then should still be excluded
181+
expect(isExcludedCommand("RALPH-LOOP")).toBe(true)
182+
expect(isExcludedCommand("Cancel-Ralph")).toBe(true)
183+
})
184+
185+
it("should not exclude regular commands", () => {
186+
// #given regular commands
187+
// #when checking exclusion
188+
// #then should not be excluded
189+
expect(isExcludedCommand("commit")).toBe(false)
190+
expect(isExcludedCommand("plan")).toBe(false)
191+
expect(isExcludedCommand("execute")).toBe(false)
192+
})
193+
})
194+
195+
describe("detectSlashCommand", () => {
196+
it("should detect slash command in plain text", () => {
197+
// #given plain text with slash command
198+
const text = "/commit fix typo"
199+
200+
// #when detecting
201+
const result = detectSlashCommand(text)
202+
203+
// #then should detect
204+
expect(result).not.toBeNull()
205+
expect(result?.command).toBe("commit")
206+
expect(result?.args).toBe("fix typo")
207+
})
208+
209+
it("should NOT detect slash command inside code block", () => {
210+
// #given slash command inside code block
211+
const text = "```bash\n/command\n```"
212+
213+
// #when detecting
214+
const result = detectSlashCommand(text)
215+
216+
// #then should not detect (only code block content)
217+
expect(result).toBeNull()
218+
})
219+
220+
it("should detect command when text has code blocks elsewhere", () => {
221+
// #given slash command before code block
222+
const text = "/commit fix\n```code```"
223+
224+
// #when detecting
225+
const result = detectSlashCommand(text)
226+
227+
// #then should detect the command
228+
expect(result).not.toBeNull()
229+
expect(result?.command).toBe("commit")
230+
})
231+
232+
it("should NOT detect excluded commands", () => {
233+
// #given excluded command
234+
const text = "/ralph-loop do something"
235+
236+
// #when detecting
237+
const result = detectSlashCommand(text)
238+
239+
// #then should not detect
240+
expect(result).toBeNull()
241+
})
242+
243+
it("should return null for non-command text", () => {
244+
// #given regular text
245+
const text = "Just some regular text"
246+
247+
// #when detecting
248+
const result = detectSlashCommand(text)
249+
250+
// #then should return null
251+
expect(result).toBeNull()
252+
})
253+
})
254+
255+
describe("extractPromptText", () => {
256+
it("should extract text from parts", () => {
257+
// #given message parts
258+
const parts = [
259+
{ type: "text", text: "Hello " },
260+
{ type: "tool_use", id: "123" },
261+
{ type: "text", text: "world" },
262+
]
263+
264+
// #when extracting
265+
const result = extractPromptText(parts)
266+
267+
// #then should join text parts
268+
expect(result).toBe("Hello world")
269+
})
270+
271+
it("should handle empty parts", () => {
272+
// #given empty parts
273+
const parts: Array<{ type: string; text?: string }> = []
274+
275+
// #when extracting
276+
const result = extractPromptText(parts)
277+
278+
// #then should return empty string
279+
expect(result).toBe("")
280+
})
281+
282+
it("should handle parts without text", () => {
283+
// #given parts without text content
284+
const parts = [
285+
{ type: "tool_use", id: "123" },
286+
{ type: "tool_result", output: "result" },
287+
]
288+
289+
// #when extracting
290+
const result = extractPromptText(parts)
291+
292+
// #then should return empty string
293+
expect(result).toBe("")
294+
})
295+
})
296+
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
SLASH_COMMAND_PATTERN,
3+
EXCLUDED_COMMANDS,
4+
} from "./constants"
5+
import type { ParsedSlashCommand } from "./types"
6+
7+
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
8+
9+
export function removeCodeBlocks(text: string): string {
10+
return text.replace(CODE_BLOCK_PATTERN, "")
11+
}
12+
13+
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
14+
const trimmed = text.trim()
15+
16+
if (!trimmed.startsWith("/")) {
17+
return null
18+
}
19+
20+
const match = trimmed.match(SLASH_COMMAND_PATTERN)
21+
if (!match) {
22+
return null
23+
}
24+
25+
const [raw, command, args] = match
26+
return {
27+
command: command.toLowerCase(),
28+
args: args.trim(),
29+
raw,
30+
}
31+
}
32+
33+
export function isExcludedCommand(command: string): boolean {
34+
return EXCLUDED_COMMANDS.has(command.toLowerCase())
35+
}
36+
37+
export function detectSlashCommand(text: string): ParsedSlashCommand | null {
38+
const textWithoutCodeBlocks = removeCodeBlocks(text)
39+
const trimmed = textWithoutCodeBlocks.trim()
40+
41+
if (!trimmed.startsWith("/")) {
42+
return null
43+
}
44+
45+
const parsed = parseSlashCommand(trimmed)
46+
47+
if (!parsed) {
48+
return null
49+
}
50+
51+
if (isExcludedCommand(parsed.command)) {
52+
return null
53+
}
54+
55+
return parsed
56+
}
57+
58+
export function extractPromptText(
59+
parts: Array<{ type: string; text?: string }>
60+
): string {
61+
return parts
62+
.filter((p) => p.type === "text")
63+
.map((p) => p.text || "")
64+
.join(" ")
65+
}

0 commit comments

Comments
 (0)