mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
3 Commits
experiment
...
fix/add-di
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8efe828c76 | ||
|
|
0ad3d2063b | ||
|
|
0fe22ae5a1 |
6
package-lock.json
generated
6
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user