Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down Expand Up @@ -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),
],
},
});
}
Expand Down Expand Up @@ -2403,15 +2430,15 @@ async function getAvailableModels(
}

function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] {
const UNSUPPORTED_COMMANDS = [
const unsupportedSlashCommands = new Set([
"cost",
"keybindings-help",
"login",
"logout",
"output-style:new",
"release-notes",
"todos",
];
]);

return commands
.map((command) => {
Expand All @@ -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<AvailableCommand[]> {
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 {
Expand Down
85 changes: 85 additions & 0 deletions src/tests/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*!";
Expand Down