diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 22d4f77b..3064093a 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -102,6 +102,7 @@ import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/re import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import type { Stats } from "node:fs"; +import { existsSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -899,6 +900,12 @@ export type ToolUpdateMeta = { transcripts need a namespaced marker instead of inferring from `toolName` or the generic `think` kind. */ subagent?: true; + /* For Skill tool calls: the name of the skill being loaded (e.g. "commits"). + Lets clients render a "Load skill: " block without parsing the title. */ + skill?: string; + /* For Skill tool calls: absolute path of that skill's SKILL.md, when it could be + located on disk. Lets clients turn the rendered skill name into a link to it. */ + skillPath?: string; }; /* Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol. */ terminal_info?: { @@ -7668,10 +7675,13 @@ function shouldEmitToolCall(toolName: string): boolean { * are kept out of ACP's standard `title`, which clients may use as the shell * command preview, while still giving clients access to Claude's concise * human-readable title. */ -function claudeCodeMetaFromToolUse(toolUse: { - name: string; - input?: unknown; -}): NonNullable { +function claudeCodeMetaFromToolUse( + toolUse: { + name: string; + input?: unknown; + }, + cwd?: string, +): NonNullable { const description = toolUse.name === "Bash" && toolUse.input !== null && @@ -7680,13 +7690,58 @@ function claudeCodeMetaFromToolUse(toolUse: { typeof toolUse.input.description === "string" ? toolUse.input.description : undefined; + const skillName = + toolUse.name === "Skill" + ? (toolUse.input as { skill?: string } | null | undefined)?.skill + : undefined; + const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined; return { toolName: toolUse.name, ...(description ? { title: description } : {}), ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true as const }), + ...(skillName ? { skill: skillName } : {}), + ...(skillPath ? { skillPath } : {}), }; } +/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */ +const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"] as const; + +/** + * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one. + * + * The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills + * actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and + * for a `:` spelling either a plugin (`.claude/plugins//skills/`) or a + * directory-scoped skill (`/.claude/skills/`), which share that spelling. Only a path that exists + * on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file. + */ +function resolveSkillPath(skillName: string, cwd?: string): string | undefined { + if (!cwd) { + return undefined; + } + const colon = skillName.indexOf(":"); + const scope = colon < 0 ? undefined : skillName.slice(0, colon); + const name = colon < 0 ? skillName : skillName.slice(colon + 1); + if (!name) { + return undefined; + } + const candidates: string[] = []; + const addCandidates = (base: string) => { + for (const container of SKILL_CONTAINER_DIRS) { + candidates.push(path.join(base, container, name, "SKILL.md")); + } + }; + if (scope) { + // A `:` skill is either directory-scoped or a plugin's; both spellings look identical. + addCandidates(path.join(cwd, scope)); + candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md")); + } + addCandidates(cwd); + addCandidates(os.homedir()); + return candidates.find((candidate) => existsSync(candidate)); +} + /** Build the `tool_call` (or, with `refine`, the `tool_call_update`) * notification for a tool_use. Shared by every site that surfaces a tool call: * the streamed tool_use path (first encounter → tool_call, later encounter → @@ -7703,7 +7758,7 @@ function toolCallNotification( ): SessionNotification["update"] { if (refine) { return { - _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) } satisfies ToolUpdateMeta, + _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) } satisfies ToolUpdateMeta, toolCallId: toolUse.id, sessionUpdate: "tool_call_update", rawInput, @@ -7712,7 +7767,7 @@ function toolCallNotification( } return { _meta: { - claudeCode: claudeCodeMetaFromToolUse(toolUse), + claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd), ...(toolUse.name === "Bash" && supportsTerminalOutput ? { terminal_info: { terminal_id: toolUse.id } } : {}), @@ -7748,7 +7803,7 @@ function streamedInputRefinement( ); return { _meta: { - claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }), + claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd), } satisfies ToolUpdateMeta, toolCallId: toolUse.id, sessionUpdate: "tool_call_update", diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 8d385c08..62585cf7 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; import { ClientCapabilities } from "@agentclientprotocol/sdk"; import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources"; import { @@ -3174,3 +3177,147 @@ describe("structured tool_use_result rendering (Read/Bash/WebSearch)", () => { }); }); }); + +describe("Skill tool rendering", () => { + const mockLogger: Logger = { log: () => {}, error: () => {} }; + + describe("toolInfoFromToolUse", () => { + it("sets title to 'Load skill: ' and returns empty content", () => { + const info = toolInfoFromToolUse( + { name: "Skill", id: "toolu_1", input: { skill: "commits" } }, + false, + ); + expect(info.title).toBe("Load skill: commits"); + expect(info.kind).toBe("other"); + expect(info.content).toEqual([]); + }); + + it("falls back to 'Load skill' when skill name is absent", () => { + const info = toolInfoFromToolUse({ name: "Skill", id: "toolu_2", input: {} }, false); + expect(info.title).toBe("Load skill"); + expect(info.content).toEqual([]); + }); + + it("does not throw when input is undefined", () => { + const info = toolInfoFromToolUse({ name: "Skill", id: "toolu_3", input: undefined }, false); + expect(info.title).toBe("Load skill"); + expect(info.content).toEqual([]); + }); + }); + + describe("toolUpdateFromToolResult", () => { + it("suppresses the raw 'Launching skill' result text", () => { + const toolUse = { + type: "tool_use", + id: "toolu_4", + name: "Skill", + input: { skill: "commits" }, + }; + const toolResult = { + type: "tool_result" as const, + tool_use_id: "toolu_4", + content: "Launching skill: commits", + is_error: false, + }; + const update = toolUpdateFromToolResult(toolResult, toolUse, false); + expect(update).toEqual({}); + }); + }); + + describe("_meta.claudeCode.skill in tool_call notification", () => { + it("includes skill name in _meta.claudeCode when Skill tool is invoked", () => { + const notifications = toAcpNotifications( + [ + { type: "tool_use", id: "toolu_5", name: "Skill", input: { skill: "commits", args: "" } }, + ] as any, + "assistant", + "test-session", + {}, + {} as AcpClient, + mockLogger, + ); + expect(notifications[0]?.update).toMatchObject({ + sessionUpdate: "tool_call", + _meta: { claudeCode: { toolName: "Skill", skill: "commits" } }, + }); + }); + + it("omits skill from _meta.claudeCode when skill name is missing", () => { + const notifications = toAcpNotifications( + [{ type: "tool_use", id: "toolu_6", name: "Skill", input: {} }] as any, + "assistant", + "test-session", + {}, + {} as AcpClient, + mockLogger, + ); + const meta = (notifications[0]?.update as any)?._meta?.claudeCode; + expect(meta).toBeDefined(); + expect(meta.skill).toBeUndefined(); + }); + }); + + describe("_meta.claudeCode.skillPath", () => { + const skillMeta = (skill: string, cwd?: string) => + ( + toAcpNotifications( + [{ type: "tool_use", id: "toolu_skill_path", name: "Skill", input: { skill } }] as any, + "assistant", + "test-session", + {}, + {} as AcpClient, + mockLogger, + cwd ? { cwd } : undefined, + )[0]?.update as any + )?._meta?.claudeCode; + + let root: string; + + beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "acp-skill-path-")); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const writeSkill = (relativeDir: string) => { + const dir = path.join(root, relativeDir); + mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "SKILL.md"); + writeFileSync(file, "# skill\n"); + return file; + }; + + it("resolves a project-level .claude/skills skill", () => { + const file = writeSkill(".claude/skills/commits"); + expect(skillMeta("commits", root).skillPath).toBe(file); + }); + + it("resolves a project-level .agents/skills skill", () => { + const file = writeSkill(".agents/skills/commits"); + expect(skillMeta("commits", root).skillPath).toBe(file); + }); + + it("resolves a directory-scoped skill spelled prefix:name", () => { + const file = writeSkill("apps/web/.claude/skills/deploy"); + expect(skillMeta("apps/web:deploy", root).skillPath).toBe(file); + }); + + it("resolves a plugin skill spelled plugin:name", () => { + const file = writeSkill(".claude/plugins/reviewer/skills/audit"); + expect(skillMeta("reviewer:audit", root).skillPath).toBe(file); + }); + + it("omits skillPath when no known layout holds the skill", () => { + const meta = skillMeta("nonexistent", root); + expect(meta.skill).toBe("nonexistent"); + expect(meta.skillPath).toBeUndefined(); + }); + + it("omits skillPath when the session has no cwd", () => { + writeSkill(".claude/skills/commits"); + expect(skillMeta("commits").skillPath).toBeUndefined(); + }); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index c9c5ac79..7ef20565 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -441,6 +441,16 @@ export function toolInfoFromToolUse( }; } + case "Skill": { + const input = toolUse.input as { skill?: string; args?: string } | undefined; + const skillName = input?.skill; + return { + title: skillName ? `Load skill: ${skillName}` : "Load skill", + kind: "other", + content: [], + }; + } + case "AskUserQuestion": { const input = toolUse.input as Partial | undefined; const questions = Array.isArray(input?.questions) ? input.questions : []; @@ -863,6 +873,10 @@ export function toolUpdateFromToolResult( ); } + case "Skill": { + return {}; + } + case "Edit": // Edit is handled in hooks case "Write": { return {};