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:
parent
f030992755
commit
4c4e1687da
@ -58,8 +58,31 @@ export function detectSlashCommand(text: string): ParsedSlashCommand | null {
|
|||||||
export function extractPromptText(
|
export function extractPromptText(
|
||||||
parts: Array<{ type: string; text?: string }>
|
parts: Array<{ type: string; text?: string }>
|
||||||
): string {
|
): string {
|
||||||
return parts
|
const textParts = parts.filter((p) => p.type === "text")
|
||||||
.filter((p) => p.type === "text")
|
const slashPart = textParts.find((p) => (p.text ?? "").trim().startsWith("/"))
|
||||||
.map((p) => p.text || "")
|
if (slashPart?.text) {
|
||||||
.join(" ")
|
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,
|
getClaudeConfigDir,
|
||||||
getOpenCodeConfigDir,
|
getOpenCodeConfigDir,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
|
import { loadBuiltinCommands } from "../../features/builtin-commands"
|
||||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||||
import { isMarkdownFile } from "../../shared/file-utils"
|
import { isMarkdownFile } from "../../shared/file-utils"
|
||||||
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||||
import type { ParsedSlashCommand } from "./types"
|
import type { ParsedSlashCommand } from "./types"
|
||||||
|
|
||||||
interface CommandScope {
|
interface CommandScope {
|
||||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill"
|
type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommandMetadata {
|
interface CommandMetadata {
|
||||||
@ -111,11 +112,25 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
|
|||||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
||||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-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 skills = options?.skills ?? await discoverAllSkills()
|
||||||
const skillCommands = skills.map(skillToCommandInfo)
|
const skillCommands = skills.map(skillToCommandInfo)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...builtinCommands,
|
||||||
...opencodeProjectCommands,
|
...opencodeProjectCommands,
|
||||||
...projectCommands,
|
...projectCommands,
|
||||||
...opencodeGlobalCommands,
|
...opencodeGlobalCommands,
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test"
|
|||||||
import type {
|
import type {
|
||||||
AutoSlashCommandHookInput,
|
AutoSlashCommandHookInput,
|
||||||
AutoSlashCommandHookOutput,
|
AutoSlashCommandHookOutput,
|
||||||
|
CommandExecuteBeforeInput,
|
||||||
|
CommandExecuteBeforeOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
// Import real shared module to avoid mock leaking to other test files
|
// 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)
|
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 {
|
import {
|
||||||
detectSlashCommand,
|
detectSlashCommand,
|
||||||
extractPromptText,
|
extractPromptText,
|
||||||
|
findSlashCommandPartIndex,
|
||||||
} from "./detector"
|
} from "./detector"
|
||||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
@ -11,6 +12,8 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AutoSlashCommandHookInput,
|
AutoSlashCommandHookInput,
|
||||||
AutoSlashCommandHookOutput,
|
AutoSlashCommandHookOutput,
|
||||||
|
CommandExecuteBeforeInput,
|
||||||
|
CommandExecuteBeforeOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||||
|
|
||||||
@ -20,6 +23,7 @@ export * from "./constants"
|
|||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
||||||
const sessionProcessedCommands = new Set<string>()
|
const sessionProcessedCommands = new Set<string>()
|
||||||
|
const sessionProcessedCommandExecutions = new Set<string>()
|
||||||
|
|
||||||
export interface AutoSlashCommandHookOptions {
|
export interface AutoSlashCommandHookOptions {
|
||||||
skills?: LoadedSkill[]
|
skills?: LoadedSkill[]
|
||||||
@ -37,6 +41,14 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const promptText = extractPromptText(output.parts)
|
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 (
|
if (
|
||||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
||||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
||||||
@ -63,7 +75,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
|||||||
|
|
||||||
const result = await executeSlashCommand(parsed, executorOptions)
|
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) {
|
if (idx < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -85,5 +97,54 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
|
|||||||
command: parsed.command,
|
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
|
parsedCommand?: ParsedSlashCommand
|
||||||
injectedMessage?: string
|
injectedMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommandExecuteBeforeInput {
|
||||||
|
command: string
|
||||||
|
sessionID: string
|
||||||
|
arguments: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandExecuteBeforeOutput {
|
||||||
|
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user