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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<CommandIn
|
||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "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 skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
return [
|
||||
...builtinCommands,
|
||||
...opencodeProjectCommands,
|
||||
...projectCommands,
|
||||
...opencodeGlobalCommands,
|
||||
|
||||
@@ -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("<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",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
const sessionProcessedCommandExecutions = new Set<string>()
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
@@ -37,6 +41,14 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
||||
): Promise<void> => {
|
||||
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<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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user