From 8efe828c7696f351b5ec91ac0f5190d8a1ad0783 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 23 Oct 2026 21:41:39 +0200 Subject: [PATCH] feat: link the skills of --add-dir roots to their SKILL.md for AIR clients --- src/acp-agent.ts | 71 ++++++++++++++++++------- src/tests/acp-agent.test.ts | 28 +++++++++- src/tests/tools.test.ts | 40 +++++++++++++- src/tool-calls/renderer.ts | 37 +++++++++---- src/tool-calls/reporters/interaction.ts | 33 ++++++++++++ 5 files changed, 179 insertions(+), 30 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index e9049160..a9e60f15 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -973,7 +973,12 @@ type Turn = { * while the CLI starts. The session record then takes the same objects. */ type ReplayState = Pick< Session, - "cwd" | "taskState" | "forwardSubagentText" | "messageIdToUuid" | "sessionFailureState" + | "cwd" + | "additionalDirectories" + | "taskState" + | "forwardSubagentText" + | "messageIdToUuid" + | "sessionFailureState" >; /** A replay that runs before its session record exists. */ @@ -1118,6 +1123,10 @@ export type Session = { * the command name. A `commands_changed` clears it, because the skill * files can have changed then. */ skillPaths?: Map; + /** The `--add-dir` roots the SDK got: `_meta.claudeCode.options` ones, then + * the ACP `additionalDirectories`. Skill commands of these roots resolve to + * their SKILL.md for AIR clients. */ + additionalDirectories?: string[]; /** Serialized `system`/init `plugin_errors` last logged, so the per-turn * init re-emit logs a plugin load failure once, not every turn. */ loggedPluginErrors?: string; @@ -3054,6 +3063,7 @@ export class ClaudeAcpAgent { const pending: PendingReplay = { state: { cwd: params.cwd, + additionalDirectories: sessionAdditionalDirectories(params), taskState: new Map(), forwardSubagentText: this.forwardsSubagentText(params._meta), messageIdToUuid: new Map(), @@ -5392,6 +5402,7 @@ export class ClaudeAcpAgent { toolCallCapabilities: this.toolCallCapabilities, parentToolUseId: message.parent_tool_use_id, cwd: session.cwd, + additionalDirectories: session.additionalDirectories, taskState: session.taskState, emittedToolCalls: session.emittedToolCalls, toolCallFields: toolCallFieldsOf(session), @@ -6346,7 +6357,7 @@ export class ClaudeAcpAgent { availableCommands: getAvailableSlashCommands( message.commands, session.terminalSlashCommands, - this.toolCallCapabilities.air.client ? session.cwd : undefined, + this.toolCallCapabilities.air.client ? session : undefined, session.skillPaths, session.query, ), @@ -9586,6 +9597,7 @@ export class ClaudeAcpAgent { clientCapabilities: this.clientCapabilities, toolCallCapabilities: this.toolCallCapabilities, cwd: replayState()?.cwd, + additionalDirectories: replayState()?.additionalDirectories, taskState: replayState()?.taskState, messageId: replayMessageId, parentToolUseId, @@ -10071,7 +10083,7 @@ export class ClaudeAcpAgent { (session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId); const update = new AcpToolCallRenderer(this.toolCallCapabilities).toolCall( { id: toolCallId, name: toolName, input: toolInput }, - { cwd: session.cwd, previewContent }, + { cwd: session.cwd, additionalDirectories: session.additionalDirectories, previewContent }, ); if (parentToolUseId) stampParentToolUseId(update, parentToolUseId); toolCallFieldsOf(session).apply(update); @@ -10580,7 +10592,7 @@ export class ClaudeAcpAgent { return getAvailableSlashCommands( commands, session.terminalSlashCommands, - this.toolCallCapabilities.air.client ? session.cwd : undefined, + this.toolCallCapabilities.air.client ? session : undefined, (session.skillPaths ??= new Map()), session.query, ); @@ -11310,12 +11322,7 @@ export class ClaudeAcpAgent { // Resolve every workspace root once. The native checkpoint report uses // this same set for lexical path validation, and the SDK receives it below. - const acpAdditionalDirectories = - params.additionalDirectories ?? sessionMeta?.additionalRoots ?? []; - const additionalDirectories = [ - ...(userProvidedOptions?.additionalDirectories ?? []), - ...acpAdditionalDirectories, - ]; + const additionalDirectories = sessionAdditionalDirectories(params); const fileChangeReporter = supportsAgentFileChangeReport(this.clientCapabilities) ? createNativeFileChangeReporter({ @@ -11872,6 +11879,7 @@ export class ClaudeAcpAgent { settingsManager.getApiKeyHelper(configuredSettingsObject), )?.kind, fileChangeReporter, + additionalDirectories, }; timing.phase("register"); this.refreshContextWindowInBackground(sessionId, this.sessions[sessionId]); @@ -12734,19 +12742,24 @@ function getAvailableSlashCommands( // the CLI's own terminal, which ACP clients aren't) — filtered alongside // the static list. Raw CLI names, matched before the MCP rename. terminalCommands?: readonly string[], - // The session cwd of an AIR client, or undefined for every other client. - // AIR gets the kind and the SKILL.md path of each skill command. - airSkillCwd?: string, + // The session cwd and `--add-dir` roots of an AIR client, or undefined for + // every other client. AIR gets the kind and the SKILL.md path of each skill + // command. + airSkillRoots?: Pick, // The resolved SKILL.md paths of the session, keyed by the cwd and the name. skillPaths?: Map, query?: Query, ): AvailableCommand[] { // The container listings of this one pass, so a list of a hundred commands lists each container once. const skillScans: SkillScanCache = new Map(); - const skillPath = (name: string, cwd: string): string | undefined => { + const skillPath = ( + name: string, + { cwd, additionalDirectories }: Pick, + ): string | undefined => { + // A session's roots never change, so the cwd and the name are key enough. const key = `${cwd}\0${name}`; if (skillPaths?.has(key)) return skillPaths.get(key); - const resolved = resolveSkillPath(name, cwd, skillScans); + const resolved = resolveSkillPath(name, cwd, skillScans, additionalDirectories); skillPaths?.set(key, resolved); return resolved; }; @@ -12792,8 +12805,8 @@ function getAvailableSlashCommands( name = `mcp:${name.replace(" (MCP)", "")}`; } const skillMdPath = - airSkillCwd && !command.builtin && !mcpPrompt - ? skillPath(command.name, airSkillCwd) + airSkillRoots && !command.builtin && !mcpPrompt + ? skillPath(command.name, airSkillRoots) : undefined; return { name, @@ -13129,6 +13142,8 @@ export function toAcpNotifications( toolCallCapabilities?: ToolCallClientCapabilities; parentToolUseId?: string | null; cwd?: string; + // The session's `--add-dir` roots, where a Skill tool's SKILL.md can also be. + additionalDirectories?: readonly string[]; taskState?: TaskState; // Tracks tool_use ids already emitted as a `tool_call` so a permission // request (which emits the tool_call eagerly) and the streamed tool_use @@ -13335,10 +13350,11 @@ export function toAcpNotifications( ? // Already surfaced (full assistant message after streaming, or a // permission request emitted it first): refine it with the // complete input. - renderer.refinement(toolUse, options?.cwd) + renderer.refinement(toolUse, options?.cwd, options?.additionalDirectories) : // First surface (streaming content_block_start or replay). renderer.toolCall(toolUse, { cwd: options?.cwd, + additionalDirectories: options?.additionalDirectories, inputComplete: options?.inputComplete, }); } @@ -13562,6 +13578,7 @@ export function streamEventToAcpNotifications( // See toAcpNotifications. toolCallCapabilities?: ToolCallClientCapabilities; cwd?: string; + additionalDirectories?: readonly string[]; taskState?: TaskState; emittedToolCalls?: Set; toolCallFields?: ToolCallFieldTracker; @@ -13577,6 +13594,7 @@ export function streamEventToAcpNotifications( toolCallCapabilities: options?.toolCallCapabilities, parentToolUseId: message.parent_tool_use_id, cwd: options?.cwd, + additionalDirectories: options?.additionalDirectories, taskState: options?.taskState, emittedToolCalls: options?.emittedToolCalls, toolCallFields: options?.toolCallFields, @@ -13643,6 +13661,7 @@ export function streamEventToAcpNotifications( streamedInput, input, options?.cwd, + options?.additionalDirectories, ); if (message.parent_tool_use_id) stampParentToolUseId(update, message.parent_tool_use_id); applyMessageId(update, options?.messageId); @@ -13973,3 +13992,19 @@ function getMatchingModelUsage(modelUsage: Record, currentMo return { key: bestKey, usage: modelUsage[bestKey] }; } } + +/** + * The `--add-dir` roots of a session: the `_meta.claudeCode.options` ones, + * then the ACP `additionalDirectories`, or the legacy `_meta.additionalRoots` + * for clients that do not send the ACP field. + */ +function sessionAdditionalDirectories(params: { + additionalDirectories?: string[] | null; + _meta?: { [key: string]: unknown } | null; +}): string[] { + const meta = params._meta as NewSessionMeta | undefined; + return [ + ...(meta?.claudeCode?.options?.additionalDirectories ?? []), + ...(params.additionalDirectories ?? meta?.additionalRoots ?? []), + ]; +} diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 95e63bed..b1832ad0 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -12902,6 +12902,8 @@ describe("skill commands in available_commands_update", () => { async function advertisedCommands( clientCapabilities: Record, change?: () => Promise, + additionalDirectories?: string[], + extraCommands: Array<{ name: string; description: string; argumentHint: string }> = [], ) { const sessionUpdate = vi.fn(async () => {}); const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, { @@ -12934,6 +12936,7 @@ describe("skill commands in available_commands_update", () => { }, { name: "missing", description: "No SKILL.md", argumentHint: "" }, { name: "review (MCP)", description: "An MCP prompt", argumentHint: "" }, + ...extraCommands, ], }; yield commandsChanged; @@ -12965,7 +12968,7 @@ describe("skill commands in available_commands_update", () => { }; yield { type: "system", subtype: "session_state_changed", state: "idle" }; } - injectGeneratorSession(agent, generator, { cwd }); + injectGeneratorSession(agent, generator, { cwd, additionalDirectories }); await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] }); @@ -13001,6 +13004,29 @@ describe("skill commands in available_commands_update", () => { }); }); + it("resolves a skill of an --add-dir root, whose name another skill holds, as :", async () => { + const addDir = path.join(cwd, "..", `${path.basename(cwd)}-repoB`); + const addDirSkill = path.join(addDir, ".claude", "skills", "review", "SKILL.md"); + await mkdir(path.dirname(addDirSkill), { recursive: true }); + await writeFile(addDirSkill, "# review\n"); + try { + const commands = await advertisedCommands( + AIR_CLIENT_CAPABILITIES, + undefined, + [addDir], + [{ name: `${path.basename(addDir)}:review`, description: "", argumentHint: "" }], + ); + expect(commands.find((c) => c.name === "review")?._meta).toEqual({ + jetbrains: { air: { version: 1, kind: "skill", skillPath } }, + }); + expect(commands.find((c) => c.name === `${path.basename(addDir)}:review`)?._meta).toEqual({ + jetbrains: { air: { version: 1, kind: "skill", skillPath: addDirSkill } }, + }); + } finally { + await rm(addDir, { recursive: true, force: true }); + } + }); + it("gives a client that is not AIR no _meta", async () => { const commands = await advertisedCommands({}); diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 3deb1e86..e2b10af5 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -4575,7 +4575,7 @@ describe("Skill tool rendering", () => { }); describe("_meta.jetbrains.air.skill.path", () => { - const skillMeta = (skill: string, cwd?: string) => + const skillMeta = (skill: string, cwd?: string, additionalDirectories?: string[]) => ( toAcpNotifications( [{ type: "tool_use", id: "toolu_skill_path", name: "Skill", input: { skill } }] as any, @@ -4584,7 +4584,7 @@ describe("Skill tool rendering", () => { {}, {} as AcpClient, mockLogger, - { clientCapabilities: AIR_CLIENT, ...(cwd ? { cwd } : {}) }, + { clientCapabilities: AIR_CLIENT, ...(cwd ? { cwd } : {}), additionalDirectories }, )[0]?.update as any )?._meta?.jetbrains?.air?.skill; @@ -4670,6 +4670,42 @@ describe("Skill tool rendering", () => { ); }); + describe("in an --add-dir root", () => { + const cwd = () => path.join(root, "w/repoA"); + + it("resolves : to the root's skill and to the cwd's", () => { + const own = writeSkill("w/repoA/.claude/skills/review"); + const added = writeSkill("w/repoB/.claude/skills/review"); + expect(skillMeta("repoB:review", cwd(), ["../repoB"]).path).toBe(added); + expect(skillMeta("review", cwd(), ["../repoB"]).path).toBe(own); + }); + + it("labels a root / when another root has its basename", () => { + writeSkill("w/one/app/.claude/skills/deploy"); + const second = writeSkill("w/two/app/.claude/skills/deploy"); + const roots = [path.join(root, "w/one/app"), path.join(root, "w/two/app")]; + expect(skillMeta("two/app:deploy", cwd(), roots).path).toBe(second); + expect(skillMeta("app:deploy", cwd(), roots)).toEqual({ name: "app:deploy" }); + }); + + it("resolves a skill of a .claude folder below the root by its path", () => { + const nested = writeSkill("w/repoB/tools/.claude/skills/lint"); + expect(skillMeta("repoB/tools:lint", cwd(), ["../repoB"]).path).toBe(nested); + }); + + it("resolves a root's skill that no other skill names by its plain name", () => { + const added = writeSkill("w/repoB/.claude/skills/release"); + expect(skillMeta("release", cwd(), ["../repoB"]).path).toBe(added); + }); + + it("never leaves the root for a scope with ..", () => { + writeSkill("w/outside/.claude/skills/review"); + expect(skillMeta("repoB/../outside:review", cwd(), ["../repoB"])).toEqual({ + name: "repoB/../outside:review", + }); + }); + }); + it("omits the path when no known layout holds the skill", () => { const meta = skillMeta("nonexistent", root); expect(meta).toEqual({ name: "nonexistent" }); diff --git a/src/tool-calls/renderer.ts b/src/tool-calls/renderer.ts index 1afa13bc..46610e47 100644 --- a/src/tool-calls/renderer.ts +++ b/src/tool-calls/renderer.ts @@ -236,13 +236,18 @@ export class AcpToolCallRenderer { */ toolCall( toolUse: ToolUse, - options: { cwd?: string; inputComplete?: boolean; previewContent?: ToolCallContent[] } = {}, + options: { + cwd?: string; + additionalDirectories?: readonly string[]; + inputComplete?: boolean; + previewContent?: ToolCallContent[]; + } = {}, ): ToolCallUpdate { const facts = this.facts(toolUse, options.cwd); const isMcp = toolUse.name.startsWith("mcp__"); return { _meta: { - ...this.toolUseMeta(toolUse, options.cwd), + ...this.toolUseMeta(toolUse, options.cwd, options.additionalDirectories), ...(this.hasTerminal(facts) ? { terminal_info: { terminal_id: toolUse.id } } : {}), // Only AIR gets the MCP flag. The upstream adapter does not send it. ...(isMcp && this.capabilities.air.client ? { is_mcp_tool_call: true } : {}), @@ -264,10 +269,14 @@ export class AcpToolCallRenderer { } /** The report of a tool call whose input is complete now. */ - refinement(toolUse: ToolUse, cwd?: string): ToolCallUpdate { + refinement( + toolUse: ToolUse, + cwd?: string, + additionalDirectories?: readonly string[], + ): ToolCallUpdate { const facts = this.facts(toolUse, cwd); return { - _meta: this.toolUseMeta(toolUse, cwd), + _meta: this.toolUseMeta(toolUse, cwd, additionalDirectories), toolCallId: toolUse.id, sessionUpdate: "tool_call_update", rawInput: this.rawInput(facts, toolUse.input), @@ -285,10 +294,15 @@ export class AcpToolCallRenderer { * invalid. AIR gets no `rawInput`, which goes out once it is complete. Every * other client gets the partial input as `rawInput`. */ - partialRefinement(toolUse: Pick, input: unknown, cwd?: string) { + partialRefinement( + toolUse: Pick, + input: unknown, + cwd?: string, + additionalDirectories?: readonly string[], + ) { const facts = this.facts({ name: toolUse.name, input }, cwd); return { - _meta: this.toolUseMeta({ name: toolUse.name, input }, cwd), + _meta: this.toolUseMeta({ name: toolUse.name, input }, cwd, additionalDirectories), toolCallId: toolUse.id, sessionUpdate: "tool_call_update", ...(this.capabilities.air.client ? {} : { rawInput: input }), @@ -760,9 +774,14 @@ export class AcpToolCallRenderer { * The `_meta` of a tool use report: the tool name, and for AIR the tool * flags under `_meta.jetbrains.air`. A shell description stays out of the * standard `title`, which clients use as the command preview. Every other - * client gets only the upstream tool name. + * client gets only the upstream tool name. [additionalDirectories] are the + * session's `--add-dir` roots, where a Skill tool's SKILL.md can also be. */ - toolUseMeta(toolUse: { name: string; input?: unknown }, cwd?: string): ToolUpdateMeta { + toolUseMeta( + toolUse: { name: string; input?: unknown }, + cwd?: string, + additionalDirectories?: readonly string[], + ): ToolUpdateMeta { const input = toolUse.input !== null && typeof toolUse.input === "object" ? (toolUse.input as Record) @@ -779,7 +798,7 @@ export class AcpToolCallRenderer { if (description) meta = withAirMeta(meta, AIR_COMMAND_TITLE_KEY, description); if (subagent) meta = withAirMeta(meta, AIR_SUBAGENT_KEY, true); if (skillName) { - const skillPath = resolveSkillPath(skillName, cwd); + const skillPath = resolveSkillPath(skillName, cwd, undefined, additionalDirectories); meta = withAirMeta(meta, AIR_SKILL_KEY, { name: skillName, ...(skillPath ? { path: skillPath } : {}), diff --git a/src/tool-calls/reporters/interaction.ts b/src/tool-calls/reporters/interaction.ts index d93e8d5a..8350de93 100644 --- a/src/tool-calls/reporters/interaction.ts +++ b/src/tool-calls/reporters/interaction.ts @@ -388,6 +388,28 @@ function escapesContainer(segment: string): boolean { return path.isAbsolute(segment) || segment.split(/[/\\]/).includes(".."); } +/** + * The folder whose `.claude/skills` holds the `:` skill of an `--add-dir` root, as Claude Code + * 2.1.309+ spells it. The scope starts with the root's label: its basename, or `/` when + * another root has the same basename. A skill in a `.claude` folder below the root adds the path to that + * folder. With `/w/repoB` added, `repoB:review` is `/w/repoB/.claude/skills/review` and `repoB/tools:lint` + * is `/w/repoB/tools/.claude/skills/lint`. + */ +function addDirSkillBase(scope: string, addDirs: readonly string[]): string | undefined { + for (const dir of addDirs) { + const basename = path.basename(dir); + const parent = path.basename(path.dirname(dir)); + const sharesBasename = addDirs.some( + (other) => other !== dir && path.basename(other) === basename, + ); + const label = sharesBasename && parent !== "" ? `${parent}/${basename}` : basename; + if (label === "") continue; + if (scope === label) return dir; + if (scope.startsWith(`${label}/`)) return path.join(dir, scope.slice(label.length + 1)); + } + return undefined; +} + /** * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one. * @@ -404,11 +426,16 @@ function escapesContainer(segment: string): boolean { * * [scans] holds the container listings of one resolution pass. Pass the same cache for every command of one * list, and each container is listed once instead of once per command. + * + * [additionalDirectories] are the session's `--add-dir` roots, relative ones taken from [cwd]. Claude Code + * offers a skill of such a root by its plain name, or as `