diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 93ba6ef..025f537 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -731,6 +731,26 @@ export class ClaudeAcpAgent implements Agent { // message. Mark promptReplayed=true so their result isn't consumed as a // background task result. const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : ""; + if (firstText.trimStart().split(/\s+/, 1)[0].toLowerCase() === "/skills") { + const commands = await getAvailableSkillSlashCommands(session.query); + const lines = commands.map((command) => + command.description ? `- /${command.name}: ${command.description}` : `- /${command.name}`, + ); + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: + lines.length > 0 + ? ["Available skills and plugins:", ...lines].join("\n") + : "No skills or plugins configured.", + }, + }, + }); + return { stopReason: "end_turn", usage: sessionUsage(session) }; + } const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]); @@ -1580,7 +1600,14 @@ export class ClaudeAcpAgent implements Agent { sessionId, update: { sessionUpdate: "available_commands_update", - availableCommands: getAvailableSlashCommands(commands), + availableCommands: [ + { + name: "skills", + description: "List available skills and plugins.", + input: null, + }, + ...getAvailableSlashCommands(commands), + ], }, }); } @@ -2403,7 +2430,7 @@ async function getAvailableModels( } function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] { - const UNSUPPORTED_COMMANDS = [ + const unsupportedSlashCommands = new Set([ "cost", "keybindings-help", "login", @@ -2411,7 +2438,7 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] "output-style:new", "release-notes", "todos", - ]; + ]); return commands .map((command) => { @@ -2432,7 +2459,21 @@ function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] input, }; }) - .filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name)); + .filter((command: AvailableCommand) => !unsupportedSlashCommands.has(command.name)); +} + +async function getAvailableSkillSlashCommands(sessionQuery: Query): Promise { + const [commands, contextUsage] = await Promise.all([ + sessionQuery.supportedCommands(), + sessionQuery.getContextUsage(), + ]); + const skillNames = new Set( + (contextUsage.skills?.skillFrontmatter ?? []) + .filter((skill) => skill.source !== "builtin") + .map((skill) => skill.name), + ); + + return getAvailableSlashCommands(commands).filter((command) => skillNames.has(command.name)); } function formatUriAsLink(uri: string): string { diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 2afe331..b5706cf 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1226,6 +1226,91 @@ describe("isLocalCommandMetadata", () => { }); }); +describe("skills slash command", () => { + function createAgentWithSession(commands: any[], skillFrontmatter: any[] = []) { + const updates: any[] = []; + const agent = new ClaudeAcpAgent({ + sessionUpdate: async (notification: any) => { + updates.push(notification); + }, + } as unknown as AgentSideConnection); + const query = { + supportedCommands: vi.fn().mockResolvedValue(commands), + getContextUsage: vi.fn().mockResolvedValue({ + skills: { + skillFrontmatter, + }, + }), + }; + + agent.sessions["test-session"] = { + query: query as any, + } as any; + + return { agent, query, updates }; + } + + it("advertises /skills", async () => { + const { agent, updates } = createAgentWithSession([]); + + await (agent as any).sendAvailableCommandsUpdate("test-session"); + + expect(updates[0].update.availableCommands[0]).toEqual({ + name: "skills", + description: "List available skills and plugins.", + input: null, + }); + }); + + it("handles /skills locally", async () => { + const { agent, query, updates } = createAgentWithSession( + [ + { name: "build", description: "Build", argumentHint: "" }, + { name: "clear", description: "Start a new session", argumentHint: "" }, + { name: "deploy", description: "", argumentHint: "" }, + { name: "usage", description: "Show session usage", argumentHint: "" }, + ], + [ + { name: "build", source: "user", tokens: 1 }, + { name: "deploy", source: "plugin", tokens: 1 }, + ], + ); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/skills" }], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(query.supportedCommands).toHaveBeenCalledTimes(1); + expect(query.getContextUsage).toHaveBeenCalledTimes(1); + expect(updates[0].update.content.text).toBe( + "Available skills and plugins:\n- /build: Build\n- /deploy", + ); + }); + + it("reports no configured skills when only built-ins are available", async () => { + const { agent, query, updates } = createAgentWithSession( + [ + { name: "clear", description: "Start a new session", argumentHint: "" }, + { name: "compact", description: "Summarize context", argumentHint: "" }, + { name: "review", description: "Review a pull request", argumentHint: "" }, + ], + [{ name: "review", source: "builtin", tokens: 1 }], + ); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "/skills" }], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(query.supportedCommands).toHaveBeenCalledTimes(1); + expect(query.getContextUsage).toHaveBeenCalledTimes(1); + expect(updates[0].update.content.text).toBe("No skills or plugins configured."); + }); +}); + describe("escape markdown", () => { it("should escape markdown characters", () => { let text = "Hello *world*!";