generated from nhcarrigan/template
feat(slash-commands): expose Claude Code CLI built-ins in command menu
Adds /simplify, /loop, and /batch as CLI-sourced slash commands so they appear in the autocomplete menu with a 'CLI' badge and forward correctly to Claude Code instead of triggering 'Unknown command' errors. Introduces source?: 'cli' field to the SlashCommand interface to distinguish app-level commands from CLI pass-through entries. Resolves #208.
This commit is contained in:
@@ -87,10 +87,13 @@ describe("slashCommands", () => {
|
|||||||
expect(commandNames).toContain("search");
|
expect(commandNames).toContain("search");
|
||||||
expect(commandNames).toContain("summarise");
|
expect(commandNames).toContain("summarise");
|
||||||
expect(commandNames).toContain("skill");
|
expect(commandNames).toContain("skill");
|
||||||
|
expect(commandNames).toContain("simplify");
|
||||||
|
expect(commandNames).toContain("loop");
|
||||||
|
expect(commandNames).toContain("batch");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has 7 commands total", () => {
|
it("has 10 commands total", () => {
|
||||||
expect(slashCommands.length).toBe(7);
|
expect(slashCommands.length).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each command has required properties", () => {
|
it("each command has required properties", () => {
|
||||||
@@ -160,6 +163,45 @@ describe("slashCommands", () => {
|
|||||||
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
||||||
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("simplify command has correct metadata and source", () => {
|
||||||
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify");
|
||||||
|
expect(simplifyCmd).toBeDefined();
|
||||||
|
expect(simplifyCmd!.source).toBe("cli");
|
||||||
|
expect(simplifyCmd!.usage).toBe("/simplify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loop command has correct metadata and source", () => {
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop");
|
||||||
|
expect(loopCmd).toBeDefined();
|
||||||
|
expect(loopCmd!.source).toBe("cli");
|
||||||
|
expect(loopCmd!.usage).toBe("/loop [interval] [command]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("batch command has correct metadata and source", () => {
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch");
|
||||||
|
expect(batchCmd).toBeDefined();
|
||||||
|
expect(batchCmd!.source).toBe("cli");
|
||||||
|
expect(batchCmd!.usage).toBe("/batch [tasks]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("app commands do not have source set", () => {
|
||||||
|
const appCommandNames = ["cd", "clear", "new", "help", "search", "summarise", "skill"];
|
||||||
|
appCommandNames.forEach((name) => {
|
||||||
|
const cmd = slashCommands.find((c) => c.name === name);
|
||||||
|
expect(cmd).toBeDefined();
|
||||||
|
expect(cmd!.source).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cli commands have source set to 'cli'", () => {
|
||||||
|
const cliCommandNames = ["simplify", "loop", "batch"];
|
||||||
|
cliCommandNames.forEach((name) => {
|
||||||
|
const cmd = slashCommands.find((c) => c.name === name);
|
||||||
|
expect(cmd).toBeDefined();
|
||||||
|
expect(cmd!.source).toBe("cli");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseSlashCommand", () => {
|
describe("parseSlashCommand", () => {
|
||||||
@@ -342,6 +384,19 @@ describe("slashCommands", () => {
|
|||||||
expect(names).toContain("search");
|
expect(names).toContain("search");
|
||||||
expect(names).toContain("summarise");
|
expect(names).toContain("summarise");
|
||||||
expect(names).toContain("skill");
|
expect(names).toContain("skill");
|
||||||
|
expect(names).toContain("simplify");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns /loop for /l prefix", () => {
|
||||||
|
const result = getMatchingCommands("/l");
|
||||||
|
const names = result.map((cmd) => cmd.name);
|
||||||
|
expect(names).toContain("loop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns /batch for /b prefix", () => {
|
||||||
|
const result = getMatchingCommands("/b");
|
||||||
|
const names = result.map((cmd) => cmd.name);
|
||||||
|
expect(names).toContain("batch");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is case insensitive", () => {
|
it("is case insensitive", () => {
|
||||||
@@ -412,6 +467,19 @@ describe("slashCommands", () => {
|
|||||||
expect(testCommand.description).toBe("A test command");
|
expect(testCommand.description).toBe("A test command");
|
||||||
expect(testCommand.usage).toBe("/test [arg]");
|
expect(testCommand.usage).toBe("/test [arg]");
|
||||||
expect(typeof testCommand.execute).toBe("function");
|
expect(typeof testCommand.execute).toBe("function");
|
||||||
|
expect(testCommand.source).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can create a cli-sourced slash command object", () => {
|
||||||
|
const cliCommand: SlashCommand = {
|
||||||
|
name: "cli-test",
|
||||||
|
description: "A CLI command",
|
||||||
|
usage: "/cli-test",
|
||||||
|
source: "cli",
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cliCommand.source).toBe("cli");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("execute can be async function", () => {
|
it("execute can be async function", () => {
|
||||||
@@ -715,6 +783,88 @@ describe("slashCommands", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("/simplify execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
||||||
|
await simplifyCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /simplify prompt to Claude when there is an active conversation", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
||||||
|
await simplifyCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/simplify",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/loop execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||||
|
await loopCmd.execute("5m /help");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /loop with args when args are provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||||
|
await loopCmd.execute("5m /help");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/loop 5m /help",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /loop without args when no args provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||||
|
await loopCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/loop",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/batch execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||||
|
await batchCmd.execute("task1, task2");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /batch with args when args are provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||||
|
await batchCmd.execute("task1, task2");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/batch task1, task2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends /batch without args when no args provided", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||||
|
await batchCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: "/batch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("/cd success path", () => {
|
describe("/cd success path", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface SlashCommand {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
usage: string;
|
usage: string;
|
||||||
|
/** "cli" = built into Claude Code CLI; omitted = Hikari app command */
|
||||||
|
source?: "cli";
|
||||||
execute: (args: string) => Promise<void> | void;
|
execute: (args: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +233,50 @@ export const slashCommands: SlashCommand[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "simplify",
|
||||||
|
description: "Review changed code for reuse, quality, and efficiency (Claude Code built-in)",
|
||||||
|
usage: "/simplify",
|
||||||
|
source: "cli",
|
||||||
|
execute: async () => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await invoke("send_prompt", { conversationId, message: "/simplify" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loop",
|
||||||
|
description: "Run a prompt or slash command on a recurring interval (Claude Code built-in)",
|
||||||
|
usage: "/loop [interval] [command]",
|
||||||
|
source: "cli",
|
||||||
|
execute: async (args: string) => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = args.trim() ? `/loop ${args.trim()}` : "/loop";
|
||||||
|
await invoke("send_prompt", { conversationId, message });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "batch",
|
||||||
|
description: "Process multiple tasks in a single Claude Code session (Claude Code built-in)",
|
||||||
|
usage: "/batch [tasks]",
|
||||||
|
source: "cli",
|
||||||
|
execute: async (args: string) => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = args.trim() ? `/batch ${args.trim()}` : "/batch";
|
||||||
|
await invoke("send_prompt", { conversationId, message });
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "skill",
|
name: "skill",
|
||||||
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
>
|
>
|
||||||
<span class="command-name">/{command.name}</span>
|
<span class="command-name">/{command.name}</span>
|
||||||
<span class="command-description">{command.description}</span>
|
<span class="command-description">{command.description}</span>
|
||||||
|
{#if command.source === "cli"}
|
||||||
|
<span class="cli-badge">CLI</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,5 +85,19 @@
|
|||||||
.command-description {
|
.command-description {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 15%, transparent);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user