Compare commits

...

3 Commits

Author SHA1 Message Date
Ben Brandt
8efe828c76 feat: link the skills of --add-dir roots to their SKILL.md for AIR clients 2026-10-23 21:41:39 +02:00
Ben Brandt
0ad3d2063b fix: don't link a scoped skill to the unscoped skill of the same name 2026-10-23 21:35:53 +02:00
dependabot[bot]
0fe22ae5a1 fix: bump fast-uri from 3.1.8 to 3.1.9 (#1410)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to
3.1.9.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fastify/fast-uri/releases">fast-uri's
releases</a>.</em></p>
<blockquote>
<h2>v3.1.9</h2>
<h2>Security</h2>
<p>This security release fixes four URI parsing and normalization
vulnerabilities in fast-uri 3.x. Applications that process untrusted URI
input should upgrade to v3.1.9.</p>
<ul>
<li><strong>Medium — URI scheme spoofing through percent-encoded
schemes</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-w74x-v6f9-j3xg">GHSA-w74x-v6f9-j3xg</a>,
CVE-2026-110306): percent-encoded scheme input is now rejected instead
of being decoded into a live scheme.</li>
<li><strong>Medium — Host confusion during domain-host
canonicalization</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-wjr9-j795-jxcr">GHSA-wjr9-j795-jxcr</a>,
CVE-2026-110180): malformed domain hosts containing ambiguous delimiters
or stripped control characters are now rejected instead of being
silently rewritten.</li>
<li><strong>Low — Incorrect relative-reference normalization</strong>
(<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-xhq7-xmqj-769h">GHSA-xhq7-xmqj-769h</a>,
CVE-2026-110095): dot-segment removal now preserves the resolution
semantics of relative references.</li>
<li><strong>Low — Scheme injection in suffix references</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-333x-w4r6-6crp">GHSA-333x-w4r6-6crp</a>,
CVE-2026-110725): first-segment colons in path-only suffix references
are now escaped so the output is not reparsed as a scheme.</li>
</ul>
<p>Upgrade with:</p>
<pre lang="sh"><code>npm install fast-uri@3.1.9
</code></pre>
<p>Thanks to <a href="https://github.com/zekth"><code>@​zekth</code></a>
for reporting the scheme, host, and relative-reference issues, and to <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
for finding the suffix-reference issue and reviewing the
remediations.</p>
<h2>What's Changed</h2>
<ul>
<li>ci: enable trusted publishing releases (v3.x) by <a
href="https://github.com/mcollina"><code>@​mcollina</code></a> in <a
href="https://redirect.github.com/fastify/fast-uri/pull/229">fastify/fast-uri#229</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9">https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0def70338a"><code>0def703</code></a>
Bumped v3.1.9</li>
<li><a
href="a1d0da2f5c"><code>a1d0da2</code></a>
test: generate only relative path references</li>
<li><a
href="9eb8759a98"><code>9eb8759</code></a>
fix: preserve relative paths in suffix and relative resolution</li>
<li><a
href="54c30f7589"><code>54c30f7</code></a>
docs: note suffix-reference authority ambiguity</li>
<li><a
href="39e6493c13"><code>39e6493</code></a>
fix: escape a first-segment colon in suffix references</li>
<li><a
href="745183c1d9"><code>745183c</code></a>
test: cover first-segment colon in suffix references</li>
<li><a
href="ddfc6a5112"><code>ddfc6a5</code></a>
fix(SEC-03): reject non reg-name characters in domain hosts</li>
<li><a
href="d86a8d29f4"><code>d86a8d2</code></a>
fix: keep a colon-first segment behind &quot;./&quot; in relative
normalization</li>
<li><a
href="eb483b5da2"><code>eb483b5</code></a>
test: cover colon-first relative references in normalization</li>
<li><a
href="b110a3571c"><code>b110a35</code></a>
fix(SEC-02): preserve meaning of relative references during
normalization</li>
<li>Additional commits viewable in <a
href="https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for fast-uri since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.8&new-version=3.1.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 20:54:32 +02:00
6 changed files with 200 additions and 35 deletions

6
package-lock.json generated
View File

@@ -2045,9 +2045,9 @@
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"funding": [
{
"type": "github",

View File

@@ -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<string, string | undefined>;
/** 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<Session, "cwd" | "additionalDirectories">,
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
skillPaths?: Map<string, string | undefined>,
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<Session, "cwd" | "additionalDirectories">,
): 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<string>;
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<string, ModelUsage>, 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 ?? []),
];
}

View File

@@ -12902,6 +12902,8 @@ describe("skill commands in available_commands_update", () => {
async function advertisedCommands(
clientCapabilities: Record<string, unknown>,
change?: () => Promise<void>,
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 <basename>:<name>", 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({});

View File

@@ -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;
@@ -4659,6 +4659,53 @@ describe("Skill tool rendering", () => {
expect(skillMeta("reviewer:audit", root).path).toBe(file);
});
it("never resolves a prefix:name skill to the unscoped skill of that name", () => {
// Claude Code 2.1.309 offers an --add-dir skill whose name is taken as `<folder>:<name>`.
writeSkill(".claude/skills/review");
writeSkill(".claude/skills/audit", "audit");
expect(skillMeta("repoB:review", root)).toEqual({ name: "repoB:review" });
expect(skillMeta("repoB:audit", root)).toEqual({ name: "repoB:audit" });
expect(skillMeta("review", root).path).toBe(
path.join(root, ".claude/skills/review/SKILL.md"),
);
});
describe("in an --add-dir root", () => {
const cwd = () => path.join(root, "w/repoA");
it("resolves <basename>:<name> to the root's skill and <name> 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 <parent>/<basename> 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" });

View File

@@ -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<ToolUse, "id" | "name">, input: unknown, cwd?: string) {
partialRefinement(
toolUse: Pick<ToolUse, "id" | "name">,
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<string, unknown>)
@@ -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 } : {}),

View File

@@ -388,6 +388,28 @@ function escapesContainer(segment: string): boolean {
return path.isAbsolute(segment) || segment.split(/[/\\]/).includes("..");
}
/**
* The folder whose `.claude/skills` holds the `<scope>:<name>` 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 `<parent>/<basename>` 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 `<label>:<name>` when another skill holds the name
* (see {@link addDirSkillBase}).
*/
export function resolveSkillPath(
skillName: string,
cwd?: string,
scans?: SkillScanCache,
additionalDirectories?: readonly string[],
): string | undefined {
if (!cwd) {
return undefined;
@@ -450,10 +477,21 @@ export function resolveSkillPath(
containers.push(path.join(marketplaces, marketplace, "plugins", scope, "skills"));
}
};
const addDirs = (additionalDirectories ?? []).map((dir) => path.resolve(cwd, dir));
if (scope !== undefined) {
const base = addDirSkillBase(scope, addDirs);
if (base !== undefined) addContainers(base);
}
addScopedContainers(cwd);
addScopedContainers(os.homedir());
addContainers(cwd);
addContainers(os.homedir());
// A `<prefix>:<name>` command is never the unscoped `<name>` skill. Since Claude Code 2.1.309 an
// `--add-dir` skill whose name is taken is offered as `<folder>:<name>`, and `<cwd>/.claude/skills/<name>`
// is then the other skill that holds the name.
if (scope === undefined) {
addContainers(cwd);
addContainers(os.homedir());
for (const dir of addDirs) addContainers(dir);
}
for (const container of containers) {
const candidate = path.join(container, name, "SKILL.md");
let stats: Stats;