Compare commits

...

2 Commits

Author SHA1 Message Date
mark.tkachenko
35b6e938bf fix: take skill paths from getContextUsage instead of parsing descriptions
Claude Code reports each skill's SKILL.md path in getContextUsage().skills.skillFrontmatter.
2026-10-28 17:23:37 +01:00
mark.tkachenko
72547977cb fix: mark plugin, bundled and nested Claude skills as skills for AIR
Read skills and plugin paths from system/init; resolve nested skills from the (from <dir>/.claude/skills) description.
2026-10-28 16:35:29 +01:00
2 changed files with 447 additions and 3 deletions

View File

@@ -1148,6 +1148,20 @@ 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 skill files Claude Code reports in `getContextUsage().skills.skillFrontmatter`,
* keyed by the skill name as the slash command is named (`plug:pskill`).
* `path` is the SKILL.md file, absent for a skill bundled with the CLI and
* on remote connections. Filled by
* {@link ClaudeAcpAgent.refreshSkillFiles}; undefined until the first read. */
skillFiles?: Map<string, { path?: string }>;
/** True once the first `system`/init has started the first skill file read,
* so the per-turn init re-emit does not start another. */
skillFilesRequested?: boolean;
/** A skill file read is running; a trigger meanwhile sets `skillFilesRerun`. */
skillFilesRefreshing?: boolean;
/** A trigger came in during a running read, which may have read the state
* from before it, so one more read follows. */
skillFilesRerun?: boolean;
/** 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;
@@ -6179,6 +6193,12 @@ export class ClaudeAcpAgent {
message.fast_mode_state,
message.fast_mode_disabled_reason,
);
// The skill files are read once per session here; commands_changed
// reads them again. Not awaited, the read takes about a second.
if (!session.skillFilesRequested) {
session.skillFilesRequested = true;
void this.refreshSkillFiles(params.sessionId);
}
// Terminal-bound slash commands (absent when none, and on
// older CLIs). The session/new advertisement runs before any
// init frame can be observed, so the first latch (or a
@@ -6515,9 +6535,11 @@ export class ClaudeAcpAgent {
this.toolCallCapabilities.air.client ? session.cwd : undefined,
session.skillPaths,
session.query,
session.skillFiles,
),
},
});
void this.refreshSkillFiles(params.sessionId);
break;
}
case "mirror_error": {
@@ -10981,9 +11003,54 @@ export class ClaudeAcpAgent {
this.toolCallCapabilities.air.client ? session.cwd : undefined,
(session.skillPaths ??= new Map()),
session.query,
session.skillFiles,
);
}
/**
* Reads the skill files from Claude Code's own skill list
* (`getContextUsage().skills.skillFrontmatter`) and, when they differ from
* the last read, re-publishes the slash commands with the skill paths. Only
* for AIR clients, the only ones that use the paths.
*
* Callers do not await it: the control request takes about 1 s on the first
* call, and it must not delay the turn or the first command list. A trigger
* during a running read does not start a parallel request; one more read
* runs after it. Errors are logged, never thrown. Older CLIs lack the method.
*/
private async refreshSkillFiles(sessionId: string): Promise<void> {
const session = this.sessions[sessionId];
if (!session || !this.toolCallCapabilities.air.client) return;
if (typeof session.query.getContextUsage !== "function") return;
if (session.skillFilesRefreshing) {
session.skillFilesRerun = true;
return;
}
session.skillFilesRefreshing = true;
try {
do {
session.skillFilesRerun = false;
const usage = await session.query.getContextUsage();
if (this.sessions[sessionId] !== session) return;
const skillFiles = new Map<string, { path?: string }>();
for (const skill of usage.skills?.skillFrontmatter ?? []) {
// A prompt command of `.claude/commands` (`foo.md`) is no skill, even if Claude Code lists it here.
if (skill.path && path.basename(skill.path) !== "SKILL.md") continue;
skillFiles.set(skill.name, { path: skill.path });
}
if (JSON.stringify([...skillFiles]) !== JSON.stringify([...(session.skillFiles ?? [])])) {
session.skillFiles = skillFiles;
session.skillPaths = new Map();
await this.sendAvailableCommandsUpdate(sessionId);
}
} while (session.skillFilesRerun);
} catch (error) {
this.logger.error(`Failed to read the skill files: ${error}`);
} finally {
session.skillFilesRefreshing = false;
}
}
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
if (!this.sessions[sessionId]) return;
await this.client.sessionUpdate({
@@ -13268,6 +13335,10 @@ function getAvailableSlashCommands(
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
skillPaths?: Map<string, string | undefined>,
query?: Query,
// The skill files Claude Code reports (`getContextUsage().skills.skillFrontmatter`).
// A listed skill gets its path from here; one that is not listed, or has no
// path (remote connections), is looked up on disk.
skillFiles?: ReadonlyMap<string, { path?: string }>,
): AvailableCommand[] {
// The container listings of this one pass, so a list of a hundred commands lists each container once.
const skillScans: SkillScanCache = new Map();
@@ -13319,10 +13390,12 @@ function getAvailableSlashCommands(
if (mcpPrompt) {
name = `mcp:${name.replace(" (MCP)", "")}`;
}
const skillFile = airSkillCwd && !mcpPrompt ? skillFiles?.get(command.name) : undefined;
const skillMdPath =
airSkillCwd && !command.builtin && !mcpPrompt
skillFile?.path ??
(airSkillCwd && !command.builtin && !mcpPrompt
? skillPath(command.name, airSkillCwd)
: undefined;
: undefined);
return {
name,
description: command.description || "",
@@ -13335,7 +13408,10 @@ function getAvailableSlashCommands(
skillMdPath,
),
}
: {}),
: skillFile
? // A skill without a file (bundled with the CLI, like `simplify`, or a remote connection): kind only.
{ _meta: withAirMeta(undefined, AIR_KIND_KEY, "skill") }
: {}),
};
})
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));

View File

@@ -13841,6 +13841,374 @@ describe("skill commands in available_commands_update", () => {
});
});
describe("skill files from getContextUsage in available_commands_update", () => {
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), "acp-skill-files-"));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
const skillEntry = (entry: Record<string, unknown>) => ({
skills: { skillFrontmatter: [entry] },
});
/** Lets the background skill file reads and their updates finish. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
/** One turn that emits `inits` init frames, then `changes` commands_changed frames for
* `commands`. `getContextUsage` is put on the query only when given. */
async function runTurn(options: {
capabilities: Record<string, unknown>;
commands: Array<Record<string, unknown>>;
getContextUsage?: ReturnType<typeof vi.fn>;
inits?: number;
changes?: number;
}) {
const sessionUpdate = vi.fn(async () => {});
const logged: string[] = [];
const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, {
log: () => {},
error: (...args: unknown[]) => logged.push(args.join(" ")),
});
await agent.initialize({ protocolVersion: 1, clientCapabilities: options.capabilities });
async function* generator(input: Pushable<any>) {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
for (let i = 0; i < (options.inits ?? 1); i++) {
yield { type: "system", subtype: "init", session_id: "test-session" };
}
for (let i = 0; i < (options.changes ?? 0); i++) {
yield {
type: "system",
subtype: "commands_changed",
session_id: "test-session",
commands: options.commands,
};
}
yield {
type: "result",
subtype: "success",
stop_reason: null,
is_error: false,
result: "",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
injectGeneratorSession(agent, generator, { cwd });
Object.assign(agent.sessions["test-session"].query, {
supportedCommands: vi.fn(async () => options.commands),
...(options.getContextUsage ? { getContextUsage: options.getContextUsage } : {}),
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
await flush();
const commandUpdates = () =>
sessionUpdate.mock.calls
.map((c: any[]) => (c[0] as { update: any }).update)
.filter((u) => u.sessionUpdate === "available_commands_update");
return { commandUpdates, logged };
}
/** The `_meta` of the command `name` in the last available_commands_update. */
function lastMeta(commandUpdates: () => any[], name: string) {
const updates = commandUpdates();
return updates[updates.length - 1].availableCommands.find((c: any) => c.name === name)?._meta;
}
/** The air `_meta` of a skill, with a path when there is one. */
const skillMeta = (skillPath?: string) => ({
jetbrains: { air: { version: 1, kind: "skill", ...(skillPath ? { skillPath } : {}) } },
});
/**
* Scenario:
* 1. An AIR session lists the command `plug:pskill`.
* 2. getContextUsage reports it with source "plugin" and a path in the plugin directory.
* 3. The first init triggers the read.
* Expected:
* - The last update marks `plug:pskill` as a skill with that path.
*/
it("takes the path of a plugin skill from getContextUsage", async () => {
const pluginSkill = "/plugins/plug/skills/pskill/SKILL.md";
const getContextUsage = vi.fn(async () =>
skillEntry({ name: "plug:pskill", source: "plugin", pluginName: "plug", path: pluginSkill }),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "plug:pskill", description: "Plugin skill", argumentHint: "" }],
getContextUsage,
});
expect(lastMeta(commandUpdates, "plug:pskill")).toEqual(skillMeta(pluginSkill));
});
/**
* Scenario:
* 1. An AIR session lists the prompt command `deploy`.
* 2. getContextUsage reports it with a `.claude/commands/deploy.md` path.
* 3. commands_changed sends the command list.
* Expected:
* - `deploy` has no `_meta`: a prompt command is no skill.
*/
it("does not mark a prompt command that getContextUsage lists with a .md path", async () => {
const getContextUsage = vi.fn(async () =>
skillEntry({
name: "deploy",
source: "projectSettings",
path: "/proj/.claude/commands/deploy.md",
}),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "deploy", description: "Deploy", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(lastMeta(commandUpdates, "deploy")).toBeUndefined();
});
/**
* Scenario:
* 1. An AIR session lists the command `nested`.
* 2. getContextUsage lists no skills on the first read (init) and the nested one on the second.
* 3. commands_changed arrives after the first read.
* Expected:
* - Two reads happened.
* - The first update, from commands_changed, has no `_meta` on `nested`.
* - The last update marks `nested` with the path from the second read.
*/
it("gives a nested skill reported after commands_changed its path", async () => {
const nestedSkill = "/proj/sub/.claude/skills/nested/SKILL.md";
const getContextUsage = vi
.fn()
.mockResolvedValueOnce({ skills: { skillFrontmatter: [] } })
.mockResolvedValue(
skillEntry({ name: "nested", source: "projectSettings", path: nestedSkill }),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "nested", description: "Nested skill", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(getContextUsage).toHaveBeenCalledTimes(2);
expect(commandUpdates()[0].availableCommands[0]._meta).toBeUndefined();
expect(lastMeta(commandUpdates, "nested")).toEqual(skillMeta(nestedSkill));
});
/**
* Scenario:
* 1. An AIR session lists the builtin commands `simplify` and `compact`.
* 2. getContextUsage reports `simplify` with source "built-in" and no path; `compact` is absent.
* Expected:
* - `simplify` has kind "skill" and no skillPath.
* - `compact` has no `_meta`.
*/
it("marks a built-in skill with the kind only and leaves compact unmarked", async () => {
const getContextUsage = vi.fn(async () => skillEntry({ name: "simplify", source: "built-in" }));
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [
{ name: "simplify", description: "Simplify", argumentHint: "", builtin: true },
{ name: "compact", description: "Compact", argumentHint: "", builtin: true },
],
getContextUsage,
});
expect(lastMeta(commandUpdates, "simplify")).toEqual(skillMeta());
expect(lastMeta(commandUpdates, "compact")).toBeUndefined();
});
/**
* Scenario:
* 1. cwd/.claude/skills/review/SKILL.md exists.
* 2. An AIR session lists `review`; getContextUsage reports no skills.
* Expected:
* - `review` is marked as a skill with the path found in cwd/.claude/skills.
*/
it("looks up a command that getContextUsage does not list on disk", async () => {
const reviewSkill = path.join(cwd, ".claude", "skills", "review", "SKILL.md");
await mkdir(path.dirname(reviewSkill), { recursive: true });
await writeFile(reviewSkill, "# review\n");
const getContextUsage = vi.fn(async () => ({ skills: { skillFrontmatter: [] } }));
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(lastMeta(commandUpdates, "review")).toEqual(skillMeta(reviewSkill));
});
/**
* Scenario:
* 1. cwd/.claude/skills/review/SKILL.md exists.
* 2. The query has no getContextUsage.
* 3. An AIR session gets a commands_changed.
* Expected:
* - `review` still gets its path from the disk lookup.
* - No error is logged.
*/
it("lists the commands as before when getContextUsage is missing", async () => {
const reviewSkill = path.join(cwd, ".claude", "skills", "review", "SKILL.md");
await mkdir(path.dirname(reviewSkill), { recursive: true });
await writeFile(reviewSkill, "# review\n");
const { commandUpdates, logged } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
changes: 1,
});
expect(lastMeta(commandUpdates, "review")).toEqual(skillMeta(reviewSkill));
expect(logged.filter((line) => line.includes("skill files"))).toEqual([]);
});
/**
* Scenario:
* 1. cwd/.claude/skills/review/SKILL.md exists.
* 2. getContextUsage rejects.
* 3. An AIR session gets a commands_changed.
* Expected:
* - The prompt completes.
* - The error is logged.
* - `review` still gets its path from the disk lookup.
*/
it("logs a failing getContextUsage and keeps the turn going", async () => {
const reviewSkill = path.join(cwd, ".claude", "skills", "review", "SKILL.md");
await mkdir(path.dirname(reviewSkill), { recursive: true });
await writeFile(reviewSkill, "# review\n");
const getContextUsage = vi.fn(async () => {
throw new Error("control request failed");
});
const { commandUpdates, logged } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(logged.some((line) => line.includes("control request failed"))).toBe(true);
expect(lastMeta(commandUpdates, "review")).toEqual(skillMeta(reviewSkill));
});
/**
* Scenario:
* 1. A client without the AIR capability runs a turn with init and commands_changed.
* Expected:
* - getContextUsage is not called.
* - No command has `_meta`.
*/
it("does not call getContextUsage for a client that is not AIR", async () => {
const getContextUsage = vi.fn(async () =>
skillEntry({ name: "review", source: "projectSettings", path: "/x/SKILL.md" }),
);
const { commandUpdates } = await runTurn({
capabilities: {},
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(getContextUsage).not.toHaveBeenCalled();
const metas = commandUpdates().flatMap((u) => u.availableCommands.map((c: any) => c._meta));
expect(metas.every((meta) => meta === undefined)).toBe(true);
});
/**
* Scenario:
* 1. An AIR session gets three init frames and then one commands_changed.
* 2. Every getContextUsage read returns the same skill list.
* Expected:
* - Two updates: the one for commands_changed and the one for the first read.
* - getContextUsage ran exactly twice: once for init, once for commands_changed.
*/
it("sends no extra update for an identical result and reads once for repeated inits", async () => {
const getContextUsage = vi.fn(async () =>
skillEntry({ name: "review", source: "projectSettings", path: "/p/review/SKILL.md" }),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
inits: 3,
changes: 1,
});
expect(commandUpdates()).toHaveLength(2);
expect(getContextUsage).toHaveBeenCalledTimes(2);
});
/**
* Scenario:
* 1. The first getContextUsage call stays pending.
* 2. An AIR session gets an init and two commands_changed meanwhile.
* 3. The pending call then resolves.
* Expected:
* - Only one call is made while the first is pending.
* - After it resolves, exactly one more call is made.
*/
it("coalesces triggers during a running read into one more read", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
const getContextUsage = vi.fn(async () => {
if (getContextUsage.mock.calls.length === 1) await gate;
return { skills: { skillFrontmatter: [] } };
});
await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 2,
});
expect(getContextUsage).toHaveBeenCalledTimes(1);
release();
await vi.waitFor(() => expect(getContextUsage).toHaveBeenCalledTimes(2));
});
});
describe("logout", () => {
function createMockAgent() {
const mockClient = {