From c29d73e7abe3d060ff74a00cebdf995c939f0683 Mon Sep 17 00:00:00 2001 From: Corey Baines Date: Wed, 9 Sep 2026 12:00:21 +1000 Subject: [PATCH 1/2] fix(claude): invoke composer skills and filter unavailable commands Adapt pingdotgg/t3code#9128 to Pathway orchestration v2 and native composers. Co-authored-by: Rodrigo Brechard Co-authored-by: Theo Browne --- .../AgentThreadComposerSuggestions.swift | 4 +- .../views/NewAgentThreadSuggestions.swift | 4 +- .../Adapters/ClaudeAdapterV2.test.ts | 198 +++++++ .../Adapters/ClaudeAdapterV2.ts | 48 +- .../Drivers/ClaudeSkillDispatch.test.ts | 41 ++ .../provider/Drivers/ClaudeSkillDispatch.ts | 78 +++ .../src/provider/Drivers/ClaudeSkills.test.ts | 486 +++++++++++++++++- .../src/provider/Drivers/ClaudeSkills.ts | 263 +++++++++- .../src/provider/Layers/ClaudeProvider.ts | 9 +- .../provider/Layers/ProviderRegistry.test.ts | 49 ++ apps/web/src/components/chat/ChatComposer.tsx | 5 +- .../chat/composerSlashCommandSearch.test.ts | 28 + .../chat/composerSlashCommandSearch.ts | 8 +- apps/web/src/providerSkillSearch.test.ts | 9 + apps/web/src/providerSkillSearch.ts | 2 +- docs/user/composer.md | 15 + packages/contracts/src/server.ts | 4 + 17 files changed, 1218 insertions(+), 33 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts diff --git a/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift b/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift index f0e2b6794..b0f0379ef 100644 --- a/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift +++ b/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift @@ -87,7 +87,7 @@ struct AgentThreadComposerSuggestions: View { } case .skill: candidates = (provider["skills"]?.arrayValue ?? []).compactMap { value in - guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, + guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, fields["userInvocable"]?.boolValue != false, let name = fields["name"]?.stringValue else { return nil } return .init(id: "skill:\(name)", title: fields["displayName"]?.stringValue ?? name, detail: fields["shortDescription"]?.stringValue ?? fields["description"]?.stringValue ?? "$\(name)", @@ -104,7 +104,7 @@ struct AgentThreadComposerSuggestions: View { ] } } - commands += (provider["slashCommands"]?.arrayValue ?? []).compactMap { value in + commands += (trigger.range.location == 0 ? provider["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in guard let fields = value.objectValue, let name = fields["name"]?.stringValue else { return nil } return .init(id: "command:\(name)", title: "/\(name)", detail: fields["description"]?.stringValue ?? fields["input"]?.objectValue?["hint"]?.stringValue ?? "Provider command", diff --git a/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift b/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift index a9fe6bb2c..3adbeb786 100644 --- a/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift +++ b/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift @@ -45,7 +45,7 @@ struct NewAgentThreadSuggestions: View { } } case .skill: candidates = (provider["skills"]?.arrayValue ?? []).compactMap { value in - guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, let name = fields["name"]?.stringValue else { return nil } + guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, fields["userInvocable"]?.boolValue != false, let name = fields["name"]?.stringValue else { return nil } return .init(id: "skill:\(name)", title: fields["displayName"]?.stringValue ?? name, detail: fields["shortDescription"]?.stringValue ?? fields["description"]?.stringValue ?? "$\(name)", symbol: "sparkles", action: .insert("$\(name) ")) } @@ -55,7 +55,7 @@ struct NewAgentThreadSuggestions: View { candidates += [.init(id: "builtin:plan", title: "/plan", detail: "Plan the work", symbol: "list.bullet.clipboard", action: .mode("plan")), .init(id: "builtin:default", title: "/default", detail: "Work mode", symbol: "text.bubble", action: .mode("default"))] } - candidates += (provider["slashCommands"]?.arrayValue ?? []).compactMap { value in + candidates += (trigger.range.location == 0 ? provider["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in guard let fields = value.objectValue, let name = fields["name"]?.stringValue else { return nil } return .init(id: "command:\(name)", title: "/\(name)", detail: fields["description"]?.stringValue ?? "Provider command", symbol: "terminal", action: .insert("/\(name) ")) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index dbaf31017..29c802503 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -839,6 +839,204 @@ describe("ClaudeAdapterV2 attachments", () => { ), ); + for (const scenario of [ + { + name: "a mid-message skill", + text: "ok, now $review the changes", + leading: "Ultrathink:\nok, now", + command: "/review the changes", + image: false, + }, + { + name: "a skill with an image", + text: "$review this image", + leading: "Ultrathink:", + command: "/review this image", + image: true, + }, + { + name: "a native slash command with an image", + text: "/compact focus on auth", + leading: "", + command: "/compact focus on auth", + image: true, + }, + ]) { + it.effect(`dispatches ${scenario.name} on initial turns and live steering`, () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const path = yield* Path.Path; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "pathway-claude-v2-attachments-", + }); + const skillHome = path.join(attachmentsDir, "claude-home"); + yield* fileSystem.makeDirectory(path.join(skillHome, "skills", "review"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(skillHome, "skills", "review", "SKILL.md"), + "---\ndisable-model-invocation: true\n---\nReview the work.", + ); + const offeredMessages: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: skillHome }, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-attachments"), + open: () => + Effect.succeed({ + messages: Stream.never, + offer: (message) => + Effect.sync(() => { + offeredMessages.push(message); + }), + setModel: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-attachments"); + const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const attachment = ChatImageAttachment.make({ + type: "image", + id: ChatAttachmentId.make( + "thread-claude-attachments-12345678-1234-1234-1234-123456789abc", + ), + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + }); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(attachment)), + Uint8Array.from([1, 2, 3, 4]), + ); + const attemptId = RunAttemptId.make("attempt-claude-attachments"); + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId, + text: scenario.text, + attachments: scenario.image ? [attachment] : [], + }), + ); + + const expectedImageBlock = { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "AQIDBA==", + }, + } as const; + const expectedAttachmentPath = path.join( + attachmentsDir, + attachmentRelativePath(attachment), + ); + const expectedBlocks: Exclude = [ + ...(scenario.leading ? [{ type: "text" as const, text: scenario.leading }] : []), + ...(scenario.image ? [expectedImageBlock] : []), + { + type: "text", + text: + scenario.command + + (scenario.image + ? `\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]` + : ""), + }, + ]; + assert.deepEqual(offeredMessages[0]?.message.content, expectedBlocks); + + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run-claude-attachments"), + providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-claude-attachments-steer"), + text: scenario.text, + attachments: scenario.image ? [attachment] : [], + }, + }); + + assert.equal(offeredMessages[1]?.priority, "now"); + assert.deepEqual(offeredMessages[1]?.message.content, expectedBlocks); + + // A stale composer pick must respect settings changed during this session. + yield* fileSystem.writeFileString( + path.join(skillHome, "settings.json"), + '{"skillOverrides":{"review":"off"}}', + ); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run-claude-disabled"), + providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-claude-disabled"), + text: "please $review", + attachments: [], + }, + }); + assert.equal(offeredMessages[2]?.message.content, "Ultrathink:\nplease $review"); + yield* fileSystem.writeFileString(path.join(skillHome, "settings.json"), "{}"); + yield* fileSystem.writeFileString( + path.join(skillHome, "skills", "review", "SKILL.md"), + "---\nuser-invocable: false\n---\nContext.", + ); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run-claude-agent-only"), + providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-claude-agent-only"), + text: "please $review and $UNKNOWN", + attachments: [], + }, + }); + assert.equal( + offeredMessages[3]?.message.content, + "Ultrathink:\nplease $review and $UNKNOWN", + ); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + } + it.effect("rejects unsupported image types before opening a provider query", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 452d269f3..d83619bc2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,3 +1,4 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import { type CanUseTool, forkSession as forkClaudeSession, @@ -77,6 +78,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; import { ServerConfig } from "../../config.ts"; import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; +import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; +import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; @@ -1167,8 +1170,24 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: readonly priority?: SDKUserMessage["priority"]; readonly attachmentsDir: string; readonly fileSystem: FileSystem.FileSystem; + readonly settings: ClaudeSettings; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | null; }) { - if (input.attachments.length === 0) { + const skills = input.text.includes("$") + ? yield* discoverClaudeSkills(input.settings, input.cwd ?? undefined, input.environment).pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + Effect.provide(NodePath.layer), + ) + : []; + const skillNames = new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ); + const skillDispatch = planClaudeSkillDispatch(input.text, skillNames); + const isSlashCommand = /^\/[^\s/]+(?:\s|$)/u.test(input.text); + if (input.attachments.length === 0 && !skillDispatch && !isSlashCommand) { return makeClaudeUserMessage({ text: input.text, ...(input.priority === undefined ? {} : { priority: input.priority }), @@ -1192,8 +1211,21 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: .join("\n\n"); const content: Array = []; - if (textWithAttachmentPaths.length > 0) { - content.push({ type: "text", text: textWithAttachmentPaths }); + // Claude expands only the final command block. Keep effort instructions and + // images before it so neither can turn a selected command into plain text. + const dispatch = skillDispatch + ? { + ...skillDispatch, + commandText: [skillDispatch.commandText, attachmentPathLines.join("\n")] + .filter((part) => part.length > 0) + .join("\n\n"), + } + : isSlashCommand + ? { leadingText: undefined, commandText: textWithAttachmentPaths } + : undefined; + const leadingText = dispatch ? (dispatch.leadingText ?? "") : textWithAttachmentPaths; + if (leadingText.length > 0) { + content.push({ type: "text", text: leadingText }); } for (const attachment of input.attachments) { @@ -1236,6 +1268,10 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: }); } + if (dispatch) { + content.push({ type: "text", text: dispatch.commandText }); + } + return { type: "user", message: { @@ -5196,6 +5232,9 @@ export function makeClaudeAdapterV2( const userMessage = isContinuationTurn ? null : yield* makeClaudeUserMessageWithAttachments({ + settings: adapterOptions.settings, + environment: adapterOptions.environment, + cwd: turnInput.runtimePolicy.cwd, text: applyClaudePromptEffortPrefix( turnInput.message.text, compileClaudeModelSelection(turnInput.modelSelection).promptEffort, @@ -5378,6 +5417,9 @@ export function makeClaudeAdapterV2( }); } const userMessage = yield* makeClaudeUserMessageWithAttachments({ + settings: adapterOptions.settings, + environment: adapterOptions.environment, + cwd: currentTurn.input.runtimePolicy.cwd, text: applyClaudePromptEffortPrefix( turnInput.message.text, compileClaudeModelSelection(currentTurn.input.modelSelection).promptEffort, diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts new file mode 100644 index 000000000..99074c8b0 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; + +const SKILLS = new Set(["implement", "review", "re-release-version"]); + +describe("planClaudeSkillDispatch", () => { + it("leaves a prompt without a known skill untouched", () => { + expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined(); + // Not a discovered skill, so it stays prose rather than becoming a command. + expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); + }); + + it("moves a mid-prompt mention into a trailing slash command", () => { + expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ + leadingText: "ok, now", + commandText: "/implement all the tickets", + skillName: "implement", + }); + }); + + it("keeps a mention that opens the prompt as a single command block", () => { + expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({ + leadingText: undefined, + commandText: "/review\nfocus on auth", + skillName: "review", + }); + }); + + it("dispatches the last mention and rewrites earlier ones inline", () => { + expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ + leadingText: "/review the diff, then", + commandText: "/implement the fixes", + skillName: "implement", + }); + }); + + it("ignores a dollar token glued to other text", () => { + expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts new file mode 100644 index 000000000..a008e0f9e --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -0,0 +1,78 @@ +/** + * ClaudeSkillDispatch — turns `$skill` mentions in a composer prompt into the + * slash invocation Claude Code actually runs. + * + * The composer inserts `$name` for every provider. Codex parses that natively; + * Claude Code does not, and treats it as prose. Claude Code's only user-side + * invocation is a text block whose first character is `/`: the harness + * expands `/name args` into the SKILL.md body, and every character after the + * name (newlines included) arrives as `ARGUMENTS`. Verified against the CLI in + * stream-json mode, which is what the Agent SDK uses: + * + * - The check runs on the LAST text block of the message. Earlier text + * blocks are preserved verbatim, and image blocks may sit before it. + * - Leading whitespace, or a `/name` that starts a later line of the same + * block, is literal text. + * - Only one skill expands per message; a second `/x` becomes argument text + * (anthropics/claude-code#87113). The model still starts the rest through + * its Skill tool when it reads `/name` in the prompt, so earlier mentions + * are rewritten to `/name` inline. + * + * So one mention anywhere in the prompt becomes a guaranteed invocation, and + * the user's text on either side is kept in order. + * + * @module provider/Drivers/ClaudeSkillDispatch + */ + +/** + * Same token shape the composer and timeline chips recognise + * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a + * dispatched skill are always the same set. + */ +const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +export interface ClaudeSkillDispatch { + /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ + readonly leadingText: string | undefined; + /** `/name` plus the trailing text, ready to be the message's last text block. */ + readonly commandText: string; + readonly skillName: string; +} + +/** + * Split `prompt` around the last `$skill` mention that names a known skill. + * Returns `undefined` when there is nothing to dispatch, in which case the + * prompt should go out unchanged. Mentions that do not match a discovered + * skill stay literal: a `$HOME` in prose must not become a command. + */ +export function planClaudeSkillDispatch( + prompt: string, + skillNames: ReadonlySet, +): ClaudeSkillDispatch | undefined { + const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { + const name = match[2] ?? ""; + if (!skillNames.has(name)) return []; + const start = (match.index ?? 0) + (match[1]?.length ?? 0); + return [{ name, start, end: start + name.length + 1 }]; + }); + const last = mentions.at(-1); + if (!last) { + return undefined; + } + + const leading = prompt.slice(0, last.start); + const trailing = prompt.slice(last.end); + const leadingWithInlineSlashes = mentions + .slice(0, -1) + .reduceRight( + (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`, + leading, + ) + .trimEnd(); + + return { + leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, + commandText: `/${last.name}${trailing}`.trimEnd(), + skillName: last.name, + }; +} diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 2045d7bbc..30168cef0 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -66,7 +66,67 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("prefers project skills over user skills on name collisions", () => + it.effect("ignores .agents/skills, which Claude Code does not load", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + // Verified against the CLI: `/review` here is answered with + // `Unknown command`, so offering it would dispatch a dead command. + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "review", + ["---", "name: review", "description: Review the changes.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, []); + }), + ); + + it.effect("prefers user skills on name collisions even with a stray .agents copy", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(configDir, "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "user", + description: "User deploy.", + }, + ]); + }), + ); + + it.effect("prefers user skills over project skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -88,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.equal(skills.length, 1); - assert.equal(skills[0]?.scope, "project"); - assert.equal(skills[0]?.description, "Project deploy."); + assert.equal(skills[0]?.scope, "user"); + assert.equal(skills[0]?.description, "User deploy."); }), ); @@ -188,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("marks skills that only the user can invoke", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "re-release-version", + [ + "---", + "name: re-release-version", + "description: Move the current tag forward.", + "disable-model-invocation: true", + "---", + "", + "# Body", + ].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "release-version", + ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join( + "\n", + ), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.equal( + skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly, + true, + ); + assert.equal( + skills.find((skill) => skill.name === "release-version")?.userInvocationOnly, + undefined, + ); + }), + ); + + it.effect("disables skills switched off by skillOverrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + for (const name of ["kept", "off-by-user", "off-by-project"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + + yield* fs.makeDirectory(configDir, { recursive: true }); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "off-by-user": "off", "kept": "on" } }', + ); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.json"), + '{ "skillOverrides": { "off-by-project": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["kept", true], + ["off-by-project", false], + ["off-by-user", false], + ], + ); + }), + ); + + it.effect("ignores unreadable settings when resolving skillOverrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "kept", + ["---", "name: kept", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString(path.join(configDir, "settings.json"), "{ not json"); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["kept", true]], + ); + }), + ); + + it.effect("treats a user-invocable-only override like disable-model-invocation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "ask-matt", + ["---", "name: ask-matt", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "ask-matt": "user-invocable-only" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled, skill.userInvocationOnly === true]), + [["ask-matt", true, true]], + ); + }), + ); + + it.effect("drops every override in a file when one value is invalid, as Claude Code does", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + for (const name of ["unknown-mode", "boolean-false", "sibling-off"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + // Verified against the CLI: with an unknown string or a boolean in the + // map, the valid "off" sibling is ignored too and every skill runs. + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["boolean-false", true], + ["sibling-off", true], + ["unknown-mode", true], + ], + ); + }), + ); + + it.effect("reads repository root settings from a nested workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const repo = path.join(tempDir, "repo"); + const workspace = path.join(repo, "packages", "app"); + + for (const name of ["root-off", "root-off-cwd-on", "cwd-off-root-on"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + yield* fs.makeDirectory(path.join(repo, ".git"), { recursive: true }); + yield* fs.makeDirectory(path.join(repo, ".claude"), { recursive: true }); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + // The CLI ignores the root's plain settings.json from a nested cwd. + yield* fs.writeFileString( + path.join(repo, ".claude", "settings.json"), + '{ "skillOverrides": { "cwd-off-root-on": "off" } }', + ); + // The root local file outranks the workspace local file, as in the CLI. + yield* fs.writeFileString( + path.join(repo, ".claude", "settings.local.json"), + '{ "skillOverrides": { "root-off": "off", "root-off-cwd-on": "off", "cwd-off-root-on": "on" } }', + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{ "skillOverrides": { "root-off-cwd-on": "on", "cwd-off-root-on": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["cwd-off-root-on", true], + ["root-off", false], + ["root-off-cwd-on", false], + ], + ); + }), + ); + + it.effect("ignores ancestor settings outside a repository", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const parent = path.join(tempDir, "not-a-repo"); + const workspace = path.join(parent, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "kept", + ["---", "name: kept", "---", "", "# Body"].join("\n"), + ); + yield* fs.makeDirectory(path.join(parent, ".claude"), { recursive: true }); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + path.join(parent, ".claude", "settings.local.json"), + '{ "skillOverrides": { "kept": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["kept", true]], + ); + }), + ); + + it.effect("lets the administrator's managed policy outrank every other settings file", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + for (const [platform, expected] of [ + ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"], + ["linux", "/etc/claude-code/managed-settings.json"], + ] as const) { + const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {}); + assert.deepEqual(paths, [ + "/home/.claude/settings.json", + "/workspace/.claude/settings.json", + "/workspace/.claude/settings.local.json", + expected, + ]); + } + + assert.deepEqual( + skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", { + PROGRAMDATA: "C:/ProgramData", + }).at(-1), + "C:/ProgramData/ClaudeCode/managed-settings.json", + ); + assert.deepEqual(skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", {}), [ + "/home/.claude/settings.json", + ]); + + // Only the repository root's local file joins in, after the + // workspace's own local file so it wins. + assert.deepEqual( + skillOverrideSettingsPaths( + path, + "/home/.claude", + "/repo/packages/app", + "linux", + {}, + "/repo", + ), + [ + "/home/.claude/settings.json", + "/repo/packages/app/.claude/settings.json", + "/repo/packages/app/.claude/settings.local.json", + "/repo/.claude/settings.local.json", + "/etc/claude-code/managed-settings.json", + ], + ); + // A workspace that is the root itself is not read twice. + assert.deepEqual( + skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"), + [ + "/home/.claude/settings.json", + "/repo/.claude/settings.json", + "/repo/.claude/settings.local.json", + "/etc/claude-code/managed-settings.json", + ], + ); + }), + ); + + it.effect("records a skill Claude Code keeps out of its own slash commands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "agent-only", + ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.userInvocable]), + [["agent-only", false]], + ); + }), + ); + + it.effect("identifies a skill by its directory, as Claude Code does", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "probe-alias", + ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "probe-alias-frontmatter": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + // The frontmatter name is not the command, so an override naming it is + // not the override Claude Code would apply either. + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["probe-alias", true]], + ); + }), + ); + + it.effect("switches a skill off by its directory name", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "probe-alias", + ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "probe-alias": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["probe-alias", false]], + ); + }), + ); + + it.effect("accepts the YAML 1.1 boolean spellings Claude Code allows", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const skillsDir = path.join(configDir, "skills"); + + yield* writeSkill( + skillsDir, + "user-only-yes", + ["---", "disable-model-invocation: yes", "---", "", "# Body"].join("\n"), + ); + yield* writeSkill( + skillsDir, + "agent-only-no", + ["---", "user-invocable: no", "---", "", "# Body"].join("\n"), + ); + yield* writeSkill( + skillsDir, + "plain-off", + ["---", "disable-model-invocation: off", "---", "", "# Body"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [ + skill.name, + skill.userInvocationOnly === true, + skill.userInvocable === false, + ]), + [ + ["agent-only-no", false, true], + ["plain-off", false, false], + ["user-only-yes", true, false], + ], + ); + }), + ); + it.effect("returns an empty list when no skill roots exist", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 09eb3d80e..d6d737bd9 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -3,10 +3,13 @@ * * Claude Code loads skills from `/skills` (user scope) and * `/.claude/skills` (project scope), one directory per skill with a - * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces - * skills only as slash commands without their filesystem paths, so the - * provider snapshot scans the same locations directly, mirroring how the - * Codex app-server reports its skills. + * `SKILL.md` carrying YAML frontmatter. The user root wins on name collisions, + * matching the CLI. `.agents/skills` is a Codex location: verified against the + * CLI, a skill that lives only there is answered with `Unknown command`, so it + * is not scanned here. + * The Agent SDK init handshake surfaces skills only as slash commands without + * their filesystem paths, so the provider snapshot scans the same locations + * directly, mirroring how the Codex app-server reports its skills. * * @module provider/Drivers/ClaudeSkills */ @@ -16,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@spiritdevs/contracts" import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@spiritdevs/shared/hostProcess"; +import { fromLenientJson } from "@spiritdevs/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "../../pathExpansion.ts"; @@ -27,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; type SkillFrontmatter = | { readonly kind: "missing" } | { readonly kind: "malformed" } - | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + | { + readonly kind: "parsed"; + readonly description?: string; + readonly userInvocationOnly?: boolean; + readonly userInvocable?: boolean; + }; + +/** + * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`, + * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and + * numbers. Verified against the CLI: a skill carrying `user-invocable: no` is + * absent from its published slash commands, so a strict `=== false` here would + * offer a command the CLI rejects. + */ +function parseFrontmatterBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + return value === 1 ? true : value === 0 ? false : undefined; + } + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase()) { + case "true": + case "yes": + case "on": + case "y": + return true; + case "false": + case "no": + case "off": + case "n": + return false; + default: + return undefined; + } +} function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); @@ -46,15 +86,186 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { } const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; const description = typeof record.description === "string" ? record.description.trim() : ""; return { kind: "parsed", - ...(name ? { name } : {}), ...(description ? { description } : {}), + ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true + ? { userInvocationOnly: true } + : {}), + ...(parseFrontmatterBoolean(record["user-invocable"]) === false + ? { userInvocable: false } + : {}), }; } +/** + * Where an administrator installs the policy file whose settings outrank every + * user and project one. Absent on almost every machine, which is why a missing + * file is the normal case rather than an error. + */ +export function claudeManagedSettingsPath( + path: Path.Path, + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): string | undefined { + if (platform === "darwin") { + return "/Library/Application Support/ClaudeCode/managed-settings.json"; + } + if (platform === "win32") { + const programData = environment.PROGRAMDATA?.trim(); + return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined; + } + return "/etc/claude-code/managed-settings.json"; +} + +/** + * Settings files Claude Code merges for `skillOverrides`, in increasing + * precedence: user, project, project-local, then the administrator's managed + * policy, which wins outright. When the workspace sits inside a git + * repository, the repository root's `settings.local.json` is read too and + * outranks the workspace's own local file. Verified against the CLI from a + * nested cwd: a root local file switching a skill off wins over a cwd one + * switching it on, the root's plain `settings.json` is not consulted, and + * without a `.git` above the cwd no root file is read. A skill the user + * switched off is reported disabled rather than dropped, so the picker can + * grey it out instead of silently losing it. + */ +export function skillOverrideSettingsPaths( + path: Path.Path, + configDirPath: string, + cwd: string | undefined, + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, + repositoryRoot?: string, +): ReadonlyArray { + const managedPath = claudeManagedSettingsPath(path, platform, environment); + const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined; + return [ + path.join(configDirPath, "settings.json"), + ...(cwd + ? [ + path.join(cwd, ".claude", "settings.json"), + path.join(cwd, ".claude", "settings.local.json"), + ] + : []), + ...(root ? [path.join(root, ".claude", "settings.local.json")] : []), + ...(managedPath ? [managedPath] : []), + ]; +} + +/** + * Nearest ancestor of `cwd` (inclusive) holding a `.git` entry, which is the + * boundary Claude Code walks up to for project settings. `undefined` outside + * a repository. + */ +const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( + cwd: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(cwd); + while (true) { + const isRoot = yield* fileSystem + .exists(path.join(current, ".git")) + .pipe(Effect.orElseSucceed(() => false)); + if (isRoot) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +}); + +/** + * The four states Claude Code accepts. The CLI validates the whole map, not + * each entry: verified against it, one entry with an unknown value (or a + * boolean) makes it drop every override in that file, so this schema does the + * same rather than applying the valid siblings the CLI ignores. + */ +const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]); + +// Lenient because these settings files are hand-edited and Claude Code itself +// tolerates comments and trailing commas in them. +const SkillOverrideSettings = fromLenientJson( + Schema.Struct({ + skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)), + }), +); +const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings); + +/** + * What a `skillOverrides` entry says about one skill. `"user-invocable-only"` + * hides it from the agent exactly as `disable-model-invocation` does, so it is + * kept apart from a plain on/off decision rather than collapsed into one. + */ +type SkillOverride = { + readonly enabled: boolean; + readonly userInvocationOnly: boolean; +}; + +function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride { + switch (value) { + case "off": + return { enabled: false, userInvocationOnly: false }; + case "user-invocable-only": + return { enabled: true, userInvocationOnly: true }; + case "on": + case "name-only": + return { enabled: true, userInvocationOnly: false }; + } +} + +const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( + configDirPath: string, + cwd: string | undefined, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const overridesByName = new Map(); + const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); + + for (const settingsPath of skillOverrideSettingsPaths( + path, + configDirPath, + cwd, + platform, + environment, + repositoryRoot, + )) { + const contents = yield* fileSystem + .readFileString(settingsPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const parsed = yield* decodeSkillOverrideSettings(contents).pipe( + Effect.tapError(() => + Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", { + path: settingsPath, + }), + ), + Effect.orElseSucceed(() => undefined), + ); + const overrides = parsed?.skillOverrides; + if (!overrides) { + continue; + } + + for (const [name, value] of Object.entries(overrides)) { + overridesByName.set(name, parseSkillOverride(value)); + } + } + + return overridesByName; +}); + /** * Resolve the Claude config directory the CLI would use, matching the * precedence the spawned CLI sees: the instance's `homePath` (exported as @@ -84,11 +295,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir and the workspace. - * Discovery is best-effort: unreadable roots and malformed skill entries are - * skipped so a broken skill never degrades the provider snapshot. On name - * collisions the project-scoped skill wins, matching Claude Code's - * most-specific-wins resolution. + * Enumerate Claude Code skills from the user config dir and the workspace + * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed + * skill entries are skipped so a broken skill never degrades the provider + * snapshot. Roots are listed highest precedence first and the first hit for a + * name wins, matching Claude Code: verified against the CLI with the same + * skill name in both scopes, the user copy is the one that runs. Reporting the + * project copy instead would attach its invocation metadata to a command + * Claude Code resolves elsewhere. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, @@ -98,6 +312,7 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); + const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env); const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, @@ -127,19 +342,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* continue; } - const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + // Claude Code identifies a skill by its directory, not by the + // frontmatter `name`: verified against the CLI, a skill in `probe-alias/` + // declaring `name: probe-alias-frontmatter` is published as + // `probe-alias`, and only `skillOverrides["probe-alias"]` switches it + // off. Keying off the frontmatter name would report a command that does + // not exist and miss the override that disables it. + const name = entry.trim(); if (!name) { continue; } + // First root wins, so a later root never displaces a higher-precedence + // skill of the same name. + if (skillsByName.has(name)) { + continue; + } + + const override = skillOverrides.get(name); + const userInvocationOnly = + (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) || + override?.userInvocationOnly === true; skillsByName.set(name, { name, path: skillPath, - enabled: true, + enabled: override?.enabled ?? true, scope: root.scope, ...(frontmatter.kind === "parsed" && frontmatter.description ? { description: frontmatter.description } : {}), + ...(userInvocationOnly ? { userInvocationOnly: true } : {}), + ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false + ? { userInvocable: false } + : {}), }); } } diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9042bc487..3cf070ca2 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1067,7 +1067,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }, ...(capabilities?.slashCommands ?? []), ]; - const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + const unavailableSkillNames = new Set( + skills + .filter((skill) => !skill.enabled || skill.userInvocable === false) + .map((skill) => skill.name), + ); + const dedupedSlashCommands = dedupeSlashCommands(slashCommands).filter( + (command) => !unavailableSkillNames.has(command.name), + ); const authProbe = capabilities?.apiProvider === "bedrock" diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6b450e6d8..1c55bc4dc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -4,6 +4,8 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; @@ -2313,6 +2315,53 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("omits disabled and agent-only skills from probed slash commands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-menu-" }); + for (const name of ["disabled", "agent-only", "user-only"]) { + yield* fs.makeDirectory(path.join(homePath, "skills", name), { recursive: true }); + yield* fs.writeFileString( + path.join(homePath, "skills", name, "SKILL.md"), + name === "agent-only" + ? "---\nuser-invocable: false\n---\nContext" + : "---\ndisable-model-invocation: true\n---\nRelease", + ); + } + yield* fs.writeFileString( + path.join(homePath, "settings.json"), + '{"skillOverrides":{"disabled":"off"}}', + ); + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, homePath }, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [{ name: "disabled" }, { name: "agent-only" }, { name: "user-only" }], + }), + ); + + assert.deepStrictEqual( + status.slashCommands.map((command) => command.name), + ["compact", "user-only"], + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("deduplicates probed claude slash commands by name", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 29c11b551..993819704 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1190,10 +1190,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const query = composerTrigger.query.trim().toLowerCase(); const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; - if (!query) { - return slashCommandItems; - } - return searchSlashCommandItems(slashCommandItems, query); + return searchSlashCommandItems(slashCommandItems, query, composerTrigger.rangeStart === 0); } if (composerTrigger.kind === "skill") { return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts index e336f62bf..07fff8072 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -42,6 +42,34 @@ describe("searchSlashCommandItems", () => { ]); }); + it("only offers provider commands at the start of the message", () => { + const items = [ + { + id: "model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "compact", + type: "provider-slash-command", + provider: claudeDriver, + command: { name: "compact" }, + label: "/compact", + description: "Compact", + }, + ] satisfies Array< + Extract + >; + expect(searchSlashCommandItems(items, "", false).map((item) => item.id)).toEqual(["model"]); + expect(searchSlashCommandItems(items, "compact", false)).toEqual([]); + expect(searchSlashCommandItems(items, "", true).map((item) => item.id)).toEqual([ + "model", + "compact", + ]); + }); + it("supports fuzzy provider command matches", () => { const items = [ { diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 059438119..0eae57e3d 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -47,10 +47,14 @@ export function searchSlashCommandItems( Extract >, query: string, + isAtPromptStart = true, ): Array> { + const availableItems = isAtPromptStart + ? items + : items.filter((item) => item.type !== "provider-slash-command"); const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); if (!normalizedQuery) { - return [...items]; + return [...availableItems]; } const ranked: Array<{ @@ -59,7 +63,7 @@ export function searchSlashCommandItems( tieBreaker: string; }> = []; - for (const item of items) { + for (const item of availableItems) { const score = scoreSlashCommandItem(item, normalizedQuery); if (score === null) { continue; diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index e804dd9fa..4dd107297 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -48,6 +48,15 @@ describe("searchProviderSkills", () => { expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]); }); + it("keeps user-only skills and hides agent-only skills", () => { + const skills = [ + makeSkill({ name: "deploy", userInvocationOnly: true }), + makeSkill({ name: "context", userInvocable: false }), + ]; + expect(searchProviderSkills(skills, "").map((skill) => skill.name)).toEqual(["deploy"]); + expect(searchProviderSkills(skills, "context")).toEqual([]); + }); + it("omits disabled skills from results", () => { const skills = [ makeSkill({ name: "ui", displayName: "Ui", enabled: false }), diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index 1682ed1fb..4aaec2e5f 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -71,7 +71,7 @@ export function searchProviderSkills( query: string, limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { - const enabledSkills = skills.filter((skill) => skill.enabled); + const enabledSkills = skills.filter((skill) => skill.enabled && skill.userInvocable !== false); const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); if (!normalizedQuery) { diff --git a/docs/user/composer.md b/docs/user/composer.md index d5a94b8c1..6ae40ff4e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -28,3 +28,18 @@ When you send the first message with **New worktree** selected, a workspace prep appears beside your message. It shows preparation, checkout progress, and the setup action as they happen. Once the workspace is ready for the agent, the card disappears and the Working timer starts. Setup failures stay visible so you can inspect them. + +Type `$` to pick a skill. With Claude, Pathway translates the selected `$name` into +a direct skill invocation, including when it appears mid-message. Skills reserved +for direct user invocation remain available. Skills switched off in Claude's +settings or reserved for the agent are omitted from the composer menus. + +Claude runs one skill directly per message. If you mention several, the last +one runs directly and earlier mentions become requests for Claude's Skill tool. +Earlier user-only skills cannot run through that tool, so send each in its own message. +Claude skill names come from their directory names, and a user skill takes precedence +over a project skill with the same name. + +Provider slash commands appear only when `/` starts the whole message, where the +provider can expand them. Pathway's `/model`, `/plan`, and `/default` commands +remain available at the beginning of any line. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index acfdf7944..65dc75d48 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -94,6 +94,10 @@ export const ServerProviderSkill = Schema.Struct({ enabled: Schema.Boolean, displayName: Schema.optional(TrimmedNonEmptyString), shortDescription: Schema.optional(TrimmedNonEmptyString), + /** The provider allows only the user to start this skill. */ + userInvocationOnly: Schema.optional(Schema.Boolean), + /** False when the provider reserves this skill for the agent. */ + userInvocable: Schema.optional(Schema.Boolean), }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; From d5084f619c90d08123a7ade65a23a62219baecff Mon Sep 17 00:00:00 2001 From: Corey Baines Date: Wed, 9 Sep 2026 12:25:58 +1000 Subject: [PATCH 2/2] fix(claude): scope composer skills to the selected workspace Keep native compact available when skill names collide and skip discovery for dollar signs outside the skill-token grammar. Address the three review findings on PR #118. --- .../AgentThreadComposerSuggestions.swift | 47 +++- .../views/NewAgentThreadSuggestions.swift | 54 +++- apps/server/src/auth/RpcAuthorization.ts | 1 + .../Adapters/ClaudeAdapterV2.test.ts | 248 +++++++++--------- .../Adapters/ClaudeAdapterV2.ts | 7 +- .../Drivers/ClaudeComposerCatalog.test.ts | 109 ++++++++ .../provider/Drivers/ClaudeComposerCatalog.ts | 26 ++ .../src/provider/Drivers/ClaudeDriver.ts | 9 + .../Drivers/ClaudeSkillDispatch.test.ts | 18 +- .../provider/Drivers/ClaudeSkillDispatch.ts | 4 + .../src/provider/Layers/ClaudeProvider.ts | 12 +- .../provider/Layers/ProviderRegistry.test.ts | 4 +- apps/server/src/provider/ProviderDriver.ts | 5 + apps/server/src/ws.ts | 12 + apps/web/src/components/chat/ChatComposer.tsx | 48 ++-- docs/user/composer.md | 3 +- packages/client-runtime/src/state/server.ts | 6 + .../src/state/serverComposerCatalog.test.ts | 50 ++++ packages/contracts/src/rpc.ts | 9 + packages/contracts/src/server.ts | 6 + 20 files changed, 525 insertions(+), 153 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeComposerCatalog.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeComposerCatalog.ts create mode 100644 packages/client-runtime/src/state/serverComposerCatalog.test.ts diff --git a/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift b/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift index b0f0379ef..31b40e5dd 100644 --- a/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift +++ b/apps/pathway-ios/Pathway/shared/views/AgentThreadComposerSuggestions.swift @@ -68,6 +68,44 @@ struct AgentThreadComposerSuggestions: View { @State private var loadedTrigger: AgentThreadComposerTrigger? @State private var isSearching = false @State private var errorMessage: String? + @State private var scopedCatalog: [String: JSONValue] = [:] + @State private var loadedCatalogTarget: [String]? + @State private var catalogError: String? + + private var catalogCwd: String? { model.thread.shell.worktreePath ?? workspaceRoot } + private var catalogTarget: [String] { + [model.currentModelSelection.instanceId, catalogCwd ?? "", provider["provider"]?.stringValue ?? "", + trigger.kind == .skill || trigger.kind == .slash ? "open" : "closed"] + } + private var needsScopedCatalog: Bool { + provider["provider"]?.stringValue == "claudeAgent" && (trigger.kind == .skill || trigger.kind == .slash) + } + private var composerCatalog: [String: JSONValue] { + guard needsScopedCatalog else { return provider } + return loadedCatalogTarget == catalogTarget ? scopedCatalog : [:] + } + private var catalogIsLoading: Bool { needsScopedCatalog && loadedCatalogTarget != catalogTarget } + + private func loadComposerCatalog() async { + guard needsScopedCatalog else { catalogError = nil; loadedCatalogTarget = nil; return } + let target = catalogTarget + catalogError = nil + do { + let result = try await model.request("server.getComposerCatalog", payload: .object([ + "instanceId": .string(model.currentModelSelection.instanceId), "cwd": catalogCwd.map(JSONValue.string) ?? .null + ]), reportsErrors: false) + try Task.checkCancellation() + guard target == catalogTarget else { return } + scopedCatalog = result.objectValue ?? [:] + loadedCatalogTarget = target + } catch is CancellationError { return } + catch { + guard !Task.isCancelled, target == catalogTarget else { return } + scopedCatalog = [:] + loadedCatalogTarget = target + catalogError = "Could not load this project's commands. Close and reopen the menu to retry." + } + } private var provider: [String: JSONValue] { model.serverConfig["providers"]?.arrayValue?.compactMap(\.objectValue) @@ -86,7 +124,7 @@ struct AgentThreadComposerSuggestions: View { } } case .skill: - candidates = (provider["skills"]?.arrayValue ?? []).compactMap { value in + candidates = (composerCatalog["skills"]?.arrayValue ?? []).compactMap { value in guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, fields["userInvocable"]?.boolValue != false, let name = fields["name"]?.stringValue else { return nil } return .init(id: "skill:\(name)", title: fields["displayName"]?.stringValue ?? name, @@ -104,7 +142,7 @@ struct AgentThreadComposerSuggestions: View { ] } } - commands += (trigger.range.location == 0 ? provider["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in + commands += (trigger.range.location == 0 ? composerCatalog["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in guard let fields = value.objectValue, let name = fields["name"]?.stringValue else { return nil } return .init(id: "command:\(name)", title: "/\(name)", detail: fields["description"]?.stringValue ?? fields["input"]?.objectValue?["hint"]?.stringValue ?? "Provider command", @@ -122,8 +160,10 @@ struct AgentThreadComposerSuggestions: View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if isSearching { ProgressView("Searching files…").font(.caption).padding(10) } + if catalogIsLoading { ProgressView("Loading commands…").font(.caption).padding(10) } + if needsScopedCatalog, loadedCatalogTarget == catalogTarget, let catalogError { Text(catalogError).font(.caption).padding(10) } if let errorMessage { Text(errorMessage).font(.caption).foregroundStyle(.secondary).padding(10) } - if items.isEmpty && !isSearching && errorMessage == nil { + if items.isEmpty && !isSearching && !catalogIsLoading && catalogError == nil && errorMessage == nil { Text(model.isConfigurationLocked && trigger.kind == .model ? model.configurationLockReason ?? "Model is managed by the parent thread" : "No matching suggestions") .font(.caption).foregroundStyle(.secondary).padding(10) } @@ -148,6 +188,7 @@ struct AgentThreadComposerSuggestions: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("agent-thread-composer-suggestions") .task(id: trigger) { await searchPaths() } + .task(id: catalogTarget) { await loadComposerCatalog() } .task(id: model.currentModelSelection.instanceId) { if trigger.kind != .path && provider.isEmpty { await model.refreshServerConfig() } } diff --git a/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift b/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift index 3adbeb786..6318ae806 100644 --- a/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift +++ b/apps/pathway-ios/Pathway/shared/views/NewAgentThreadSuggestions.swift @@ -8,13 +8,53 @@ struct NewAgentThreadSuggestions: View { @State private var loadedTrigger: AgentThreadComposerTrigger? @State private var isLoading = false @State private var errorMessage: String? + @State private var scopedCatalog: [String: JSONValue] = [:] + @State private var loadedCatalogTarget: [String]? + @State private var catalogError: String? + + private var catalogCwd: String? { model.workspaceRoot } + private var catalogTarget: [String] { + [model.selectedProviderID, catalogCwd ?? "", provider["provider"]?.stringValue ?? "", + trigger.kind == .skill || trigger.kind == .slash ? "open" : "closed"] + } + private var needsScopedCatalog: Bool { + provider["provider"]?.stringValue == "claudeAgent" && (trigger.kind == .skill || trigger.kind == .slash) + } + private var composerCatalog: [String: JSONValue] { + guard needsScopedCatalog else { return provider } + return loadedCatalogTarget == catalogTarget ? scopedCatalog : [:] + } + private var catalogIsLoading: Bool { needsScopedCatalog && loadedCatalogTarget != catalogTarget } + + private func loadComposerCatalog() async { + guard needsScopedCatalog else { catalogError = nil; loadedCatalogTarget = nil; return } + let target = catalogTarget + catalogError = nil + do { + let result = try await model.request("server.getComposerCatalog", payload: .object([ + "instanceId": .string(model.selectedProviderID), "cwd": catalogCwd.map(JSONValue.string) ?? .null + ])) + try Task.checkCancellation() + guard target == catalogTarget else { return } + scopedCatalog = result.objectValue ?? [:] + loadedCatalogTarget = target + } catch is CancellationError { return } + catch { + guard !Task.isCancelled, target == catalogTarget else { return } + scopedCatalog = [:] + loadedCatalogTarget = target + catalogError = "Could not load this project's commands. Close and reopen the menu to retry." + } + } var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if isLoading { ProgressView("Searching files…").padding(10) } + if catalogIsLoading { ProgressView("Loading commands…").font(.caption).padding(10) } + if needsScopedCatalog, loadedCatalogTarget == catalogTarget, let catalogError { Text(catalogError).font(.caption).padding(10) } if let errorMessage { Text(errorMessage).font(.caption).padding(10) } - if suggestions.isEmpty && !isLoading && errorMessage == nil { Text("No matching suggestions").font(.caption).padding(10) } + if suggestions.isEmpty && !isLoading && !catalogIsLoading && catalogError == nil && errorMessage == nil { Text("No matching suggestions").font(.caption).padding(10) } ForEach(suggestions) { suggestion in Button { select(suggestion, trigger) } label: { HStack(spacing: 9) { @@ -30,12 +70,16 @@ struct NewAgentThreadSuggestions: View { } }.frame(maxHeight: 170).background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 12)) .task(id: trigger) { await searchPaths() } + .task(id: catalogTarget) { await loadComposerCatalog() } + } + + private var provider: [String: JSONValue] { + model.serverConfig["providers"]?.arrayValue?.compactMap(\.objectValue) + .first { $0["instanceId"]?.stringValue == model.selectedProviderID } ?? [:] } private var suggestions: [AgentThreadComposerSuggestion] { if trigger.kind == .path { return loadedTrigger == trigger ? paths : [] } - let provider = model.serverConfig["providers"]?.arrayValue?.compactMap(\.objectValue) - .first { $0["instanceId"]?.stringValue == model.selectedProviderID } ?? [:] var candidates: [AgentThreadComposerSuggestion] = [] switch trigger.kind { case .model: @@ -44,7 +88,7 @@ struct NewAgentThreadSuggestions: View { action: .model(.init(instanceId: provider.id, model: value.id, options: nil))) } } case .skill: - candidates = (provider["skills"]?.arrayValue ?? []).compactMap { value in + candidates = (composerCatalog["skills"]?.arrayValue ?? []).compactMap { value in guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, fields["userInvocable"]?.boolValue != false, let name = fields["name"]?.stringValue else { return nil } return .init(id: "skill:\(name)", title: fields["displayName"]?.stringValue ?? name, detail: fields["shortDescription"]?.stringValue ?? fields["description"]?.stringValue ?? "$\(name)", symbol: "sparkles", action: .insert("$\(name) ")) @@ -55,7 +99,7 @@ struct NewAgentThreadSuggestions: View { candidates += [.init(id: "builtin:plan", title: "/plan", detail: "Plan the work", symbol: "list.bullet.clipboard", action: .mode("plan")), .init(id: "builtin:default", title: "/default", detail: "Work mode", symbol: "text.bubble", action: .mode("default"))] } - candidates += (trigger.range.location == 0 ? provider["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in + candidates += (trigger.range.location == 0 ? composerCatalog["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in guard let fields = value.objectValue, let name = fields["name"]?.stringValue else { return nil } return .init(id: "command:\(name)", title: "/\(name)", detail: fields["description"]?.stringValue ?? "Provider command", symbol: "terminal", action: .insert("/\(name) ")) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index c108377f5..ae3f55795 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -42,6 +42,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetComposerCatalog]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverStartProviderAuthentication]: AuthOrchestrationOperateScope, [WS_METHODS.serverCompleteProviderAuthentication]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 29c802503..363787d5d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -715,129 +715,139 @@ describe("ClaudeAdapterV2 native protocol logging", () => { }); describe("ClaudeAdapterV2 attachments", () => { - it.effect("forwards persisted images on initial turns and live steering", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocatorV2; - const path = yield* Path.Path; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "pathway-claude-v2-attachments-", - }); - const offeredMessages: Array = []; - const adapter = makeClaudeAdapterV2({ - instanceId: CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-attachments"), - open: () => - Effect.succeed({ - messages: Stream.never, - offer: (message) => - Effect.sync(() => { - offeredMessages.push(message); - }), - setModel: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }), - forkSession: () => Effect.die("unused forkSession"), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-attachments"); - const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const attachment = ChatImageAttachment.make({ - type: "image", - id: ChatAttachmentId.make( - "thread-claude-attachments-12345678-1234-1234-1234-123456789abc", - ), - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 4, - }); - yield* fileSystem.writeFile( - path.join(attachmentsDir, attachmentRelativePath(attachment)), - Uint8Array.from([1, 2, 3, 4]), - ); - const attemptId = RunAttemptId.make("attempt-claude-attachments"); - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ + for (const initialText of ["What's in this image?", "The budget is $100"]) { + it.effect(`forwards images without skill discovery for ${initialText}`, () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const path = yield* Path.Path; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "pathway-claude-v2-attachments-", + }); + let skillDirectoryReads = 0; + const offeredMessages: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem: { + ...fileSystem, + readDirectory: (...args) => { + skillDirectoryReads += 1; + return fileSystem.readDirectory(...args); + }, + }, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-attachments"), + open: () => + Effect.succeed({ + messages: Stream.never, + offer: (message) => + Effect.sync(() => { + offeredMessages.push(message); + }), + setModel: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-attachments"); + const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments"); + const runtime = yield* adapter.openSession({ threadId, - providerThread, - now, - attemptId, - text: "What's in this image?", - attachments: [attachment], - }), - ); + providerSessionId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const attachment = ChatImageAttachment.make({ + type: "image", + id: ChatAttachmentId.make( + "thread-claude-attachments-12345678-1234-1234-1234-123456789abc", + ), + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + }); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(attachment)), + Uint8Array.from([1, 2, 3, 4]), + ); + const attemptId = RunAttemptId.make("attempt-claude-attachments"); + const now = yield* DateTime.now; - const expectedImageBlock = { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "AQIDBA==", - }, - } as const; - const expectedAttachmentPath = path.join( - attachmentsDir, - attachmentRelativePath(attachment), - ); - assert.deepEqual(offeredMessages[0]?.message.content, [ - { - type: "text", - text: `Ultrathink:\nWhat's in this image?\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]`, - }, - expectedImageBlock, - ]); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId, + text: initialText, + attachments: [attachment], + }), + ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }); - yield* runtime.steerTurn({ - threadId, - runId: RunId.make("run-claude-attachments"), - providerThread, - providerTurnId, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-claude-attachments-steer"), - text: "Focus on the diagram labels.", - attachments: [attachment], - }, - }); + const expectedImageBlock = { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "AQIDBA==", + }, + } as const; + const expectedAttachmentPath = path.join( + attachmentsDir, + attachmentRelativePath(attachment), + ); + assert.deepEqual(offeredMessages[0]?.message.content, [ + { + type: "text", + text: `Ultrathink:\n${initialText}\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]`, + }, + expectedImageBlock, + ]); - assert.equal(offeredMessages[1]?.priority, "now"); - assert.deepEqual(offeredMessages[1]?.message.content, [ - { - type: "text", - text: `Ultrathink:\nFocus on the diagram labels.\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]`, - }, - expectedImageBlock, - ]); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), - ); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run-claude-attachments"), + providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-claude-attachments-steer"), + text: "Focus on the diagram labels.", + attachments: [attachment], + }, + }); + + assert.equal(offeredMessages[1]?.priority, "now"); + assert.deepEqual(offeredMessages[1]?.message.content, [ + { + type: "text", + text: `Ultrathink:\nFocus on the diagram labels.\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]`, + }, + expectedImageBlock, + ]); + assert.equal(skillDirectoryReads, 0); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + } for (const scenario of [ { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d83619bc2..3a7f188ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -79,7 +79,10 @@ import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; import { ServerConfig } from "../../config.ts"; import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; -import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; +import { + hasClaudeSkillMention, + planClaudeSkillDispatch, +} from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; @@ -1174,7 +1177,7 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: readonly environment: NodeJS.ProcessEnv; readonly cwd: string | null; }) { - const skills = input.text.includes("$") + const skills = hasClaudeSkillMention(input.text) ? yield* discoverClaudeSkills(input.settings, input.cwd ?? undefined, input.environment).pipe( Effect.provideService(FileSystem.FileSystem, input.fileSystem), Effect.provide(NodePath.layer), diff --git a/apps/server/src/provider/Drivers/ClaudeComposerCatalog.test.ts b/apps/server/src/provider/Drivers/ClaudeComposerCatalog.test.ts new file mode 100644 index 000000000..36c8f0756 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeComposerCatalog.test.ts @@ -0,0 +1,109 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { loadClaudeComposerCatalog } from "./ClaudeComposerCatalog.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; + +it.layer(NodeServices.layer)("Claude composer catalog", (it) => { + it.effect("uses the target project's skill metadata and drops startup-only slash entries", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-catalog-" }); + const config = { homePath: path.join(root, "home") }; + const projectA = path.join(root, "a"); + const projectB = path.join(root, "b"); + for (const [cwd, name, description] of [ + [projectA, "deploy", "Deploy project A"], + [projectA, "only-a", "Only in A"], + [projectB, "deploy", "Deploy project B"], + ]) { + const directory = path.join(cwd!, ".claude", "skills", name!); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "SKILL.md"), + `---\ndescription: ${description}\n---\nInstructions`, + ); + } + const startup = { + skills: yield* discoverClaudeSkills(config, projectA, {}), + slashCommands: [ + { name: "compact" }, + { name: "deploy", description: "Deploy project A" }, + { name: "only-a" }, + ], + }; + const catalog = yield* loadClaudeComposerCatalog(config, projectB, {}, startup); + assert.deepEqual( + catalog.skills.map((skill) => [skill.name, skill.description, skill.path]), + [ + [ + "deploy", + "Deploy project B", + path.join(projectB, ".claude", "skills", "deploy", "SKILL.md"), + ], + ], + ); + assert.deepEqual(catalog.slashCommands, [ + { name: "compact" }, + { name: "deploy", description: "Deploy project B" }, + ]); + const dispatchedSkills = yield* discoverClaudeSkills(config, projectB, {}); + assert.deepEqual(catalog.skills, dispatchedSkills); + assert.equal( + planClaudeSkillDispatch( + "please $deploy", + new Set(dispatchedSkills.map((skill) => skill.name)), + )?.skillName, + "deploy", + ); + const emptyProject = yield* loadClaudeComposerCatalog( + config, + path.join(root, "empty"), + {}, + startup, + ); + assert.deepEqual(emptyProject, { skills: [], slashCommands: [{ name: "compact" }] }); + }), + ); + + it.effect("applies target-project overrides while preserving native compact", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-catalog-" }); + const config = { homePath: path.join(root, "home") }; + const cwd = path.join(root, "project"); + for (const name of ["deploy", "compact"]) { + const directory = path.join(config.homePath, "skills", name); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "SKILL.md"), + "---\ndescription: User skill\n---\nInstructions", + ); + } + yield* fs.makeDirectory(path.join(cwd, ".claude"), { recursive: true }); + yield* fs.writeFileString( + path.join(cwd, ".claude", "settings.json"), + '{"skillOverrides":{"deploy":"off","compact":"off"}}', + ); + const catalog = yield* loadClaudeComposerCatalog( + config, + cwd, + {}, + { + skills: [], + slashCommands: [{ name: "compact", description: "Native compact" }, { name: "deploy" }], + }, + ); + assert.deepEqual( + catalog.skills.map((skill) => skill.enabled), + [false, false], + ); + assert.deepEqual(catalog.slashCommands, [{ name: "compact", description: "Native compact" }]); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeComposerCatalog.ts b/apps/server/src/provider/Drivers/ClaudeComposerCatalog.ts new file mode 100644 index 000000000..2fc12f333 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeComposerCatalog.ts @@ -0,0 +1,26 @@ +import type { ClaudeSettings, ServerProviderComposerCatalog } from "@spiritdevs/contracts"; +import * as Effect from "effect/Effect"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; + +/** Replace the startup workspace's skills with those the selected workspace can run. */ +export const loadClaudeComposerCatalog = Effect.fn("loadClaudeComposerCatalog")(function* ( + config: Pick, + cwd: string | null, + environment: NodeJS.ProcessEnv, + snapshot: ServerProviderComposerCatalog, +) { + const skills = yield* discoverClaudeSkills(config, cwd ?? undefined, environment); + const skillNames = new Set([...snapshot.skills, ...skills].map((skill) => skill.name)); + const slashCommands = snapshot.slashCommands.filter( + (command) => command.name === "compact" || !skillNames.has(command.name), + ); + const commandNames = new Set(slashCommands.map((command) => command.name)); + for (const skill of skills) { + if (!skill.enabled || skill.userInvocable === false || commandNames.has(skill.name)) continue; + slashCommands.push({ + name: skill.name, + ...(skill.description ? { description: skill.description } : {}), + }); + } + return { skills, slashCommands }; +}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 73b8fcf29..6ee2d5bd6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -24,6 +24,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { loadClaudeComposerCatalog } from "./ClaudeComposerCatalog.ts"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; @@ -335,6 +336,14 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + getComposerCatalog: (workspaceCwd) => + snapshot.getSnapshot.pipe( + Effect.flatMap((current) => + loadClaudeComposerCatalog(effectiveConfig, workspaceCwd, processEnv, current), + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), orchestrationAdapter, textGeneration, authentication: authenticationWithCacheRefresh, diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index 99074c8b0..da19edffe 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; +import { hasClaudeSkillMention, planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; const SKILLS = new Set(["implement", "review", "re-release-version"]); @@ -39,3 +39,19 @@ describe("planClaudeSkillDispatch", () => { expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); }); }); + +describe("hasClaudeSkillMention", () => { + it.each(["The budget is $100", "cost $1,000", "a lone $", "cost is 5$implement", "echo ${HOME}"])( + "skips discovery for %s", + (prompt) => { + expect(hasClaudeSkillMention(prompt)).toBe(false); + }, + ); + it.each(["$implement", "please $review the diff", "$plugin:review\nnow", "$review $implement"])( + "discovers possible skills in %s", + (prompt) => { + expect(hasClaudeSkillMention(prompt)).toBe(true); + expect(hasClaudeSkillMention(prompt)).toBe(true); + }, + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index a008e0f9e..af97b950b 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -31,6 +31,10 @@ */ const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +export function hasClaudeSkillMention(prompt: string): boolean { + return !prompt.matchAll(SKILL_MENTION_PATTERN).next().done; +} + export interface ClaudeSkillDispatch { /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ readonly leadingText: string | undefined; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 3cf070ca2..23e7e3355 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1060,21 +1060,23 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = [ + const nativeSlashCommands = [ { name: "compact", description: "Summarize the conversation and reduce context usage", }, - ...(capabilities?.slashCommands ?? []), ]; const unavailableSkillNames = new Set( skills .filter((skill) => !skill.enabled || skill.userInvocable === false) .map((skill) => skill.name), ); - const dedupedSlashCommands = dedupeSlashCommands(slashCommands).filter( - (command) => !unavailableSkillNames.has(command.name), - ); + const dedupedSlashCommands = dedupeSlashCommands([ + ...nativeSlashCommands, + ...(capabilities?.slashCommands ?? []).filter( + (command) => !unavailableSkillNames.has(command.name), + ), + ]); const authProbe = capabilities?.apiProvider === "bedrock" diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1c55bc4dc..e499f0f06 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2320,7 +2320,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "pathway-claude-menu-" }); - for (const name of ["disabled", "agent-only", "user-only"]) { + for (const name of ["disabled", "agent-only", "user-only", "compact"]) { yield* fs.makeDirectory(path.join(homePath, "skills", name), { recursive: true }); yield* fs.writeFileString( path.join(homePath, "skills", name, "SKILL.md"), @@ -2331,7 +2331,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te } yield* fs.writeFileString( path.join(homePath, "settings.json"), - '{"skillOverrides":{"disabled":"off"}}', + '{"skillOverrides":{"disabled":"off","compact":"off"}}', ); const status = yield* checkClaudeProviderStatus( { ...defaultClaudeSettings, homePath }, diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 8861a17ff..7f8cc4625 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -23,6 +23,7 @@ */ import type { ProviderDriverKind, + ServerProviderComposerCatalog, ProviderInstanceEnvironment, ProviderInstanceId, } from "@spiritdevs/contracts"; @@ -70,6 +71,10 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; + /** Workspace-specific composer entries, separate from environment-wide status. */ + readonly getComposerCatalog?: ( + cwd: string | null, + ) => Effect.Effect; readonly orchestrationAdapter: ProviderAdapterV2Shape; readonly textGeneration: TextGenerationShape; readonly authentication?: ProviderAuthenticationShape | undefined; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 48bb31506..237a14899 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1570,6 +1570,18 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetComposerCatalog]: (input) => + observeRpcEffect( + WS_METHODS.serverGetComposerCatalog, + Effect.gen(function* () { + const instance = yield* providerInstanceRegistry.getInstance(input.instanceId); + if (!instance || !instance.enabled) return { skills: [], slashCommands: [] }; + if (instance.getComposerCatalog) return yield* instance.getComposerCatalog(input.cwd); + const snapshot = yield* instance.snapshot.getSnapshot; + return { skills: snapshot.skills, slashCommands: snapshot.slashCommands }; + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 993819704..aaec8b76b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,5 @@ +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; import type { EnvironmentId, ModelSelection, @@ -1142,6 +1144,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) query: isPathTrigger ? pathTriggerQuery : null, }); + const needsClaudeCatalog = + selectedProvider === "claudeAgent" && + (composerTriggerKind === "skill" || composerTriggerKind === "slash-command"); + const scopedCatalog = useEnvironmentQuery( + needsClaudeCatalog && environmentId !== null + ? serverEnvironment.composerCatalog({ + environmentId, + input: { instanceId: selectedInstanceId, cwd: gitCwd }, + }) + : null, + ); + // Never fall back to the startup project's skills while a scoped request loads or fails. + const composerCatalog = + selectedProvider === "claudeAgent" ? scopedCatalog.data : selectedProviderStatus; + const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1178,22 +1195,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( - (command) => ({ - id: `provider-slash-command:${selectedProvider}:${command.name}`, - type: "provider-slash-command" as const, - provider: selectedProvider, - command, - label: `/${command.name}`, - description: command.description ?? command.input?.hint ?? "Run provider command", - }), - ); + const providerSlashCommandItems = (composerCatalog?.slashCommands ?? []).map((command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + })); const query = composerTrigger.query.trim().toLowerCase(); const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; return searchSlashCommandItems(slashCommandItems, query, composerTrigger.rangeStart === 0); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( + return searchProviderSkills(composerCatalog?.skills ?? [], composerTrigger.query).map( (skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, type: "skill" as const, @@ -1208,7 +1223,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [composerTrigger, selectedProvider, composerCatalog, workspaceEntries.entries]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1265,15 +1280,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (needsClaudeCatalog && scopedCatalog.isPending); const composerMenuEmptyState = useMemo(() => { + if (needsClaudeCatalog && scopedCatalog.error) + return "Could not load this project's commands. Close and reopen the menu to retry."; if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; } return composerTriggerKind === "path" ? "No matching files or folders." : "No matching command."; - }, [composerTriggerKind]); + }, [composerTriggerKind, needsClaudeCatalog, scopedCatalog.error]); // ------------------------------------------------------------------ // Provider traits UI @@ -3338,7 +3356,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={composerCatalog?.skills ?? []} className="min-h-[1lh] max-h-[min(6lh,22dvh)] overscroll-contain" onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/docs/user/composer.md b/docs/user/composer.md index 6ae40ff4e..0c89b13d1 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -29,7 +29,8 @@ appears beside your message. It shows preparation, checkout progress, and the se they happen. Once the workspace is ready for the agent, the card disappears and the Working timer starts. Setup failures stay visible so you can inspect them. -Type `$` to pick a skill. With Claude, Pathway translates the selected `$name` into +Type `$` to pick a skill. Claude skill suggestions come from the selected project or +worktree and provider account. They refresh when you open the menu. With Claude, Pathway translates the selected `$name` into a direct skill invocation, including when it appears mid-message. Skills reserved for direct user invocation remain available. Skills switched off in Claude's settings or reserved for the agent are omitted from the composer menus. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 76a842ae7..c05675e3d 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1010,6 +1010,12 @@ export function createServerEnvironmentAtoms( Stream.mapAccum(Option.none, projectServerWelcome), ), }), + composerCatalog: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:composer-catalog", + tag: WS_METHODS.serverGetComposerCatalog, + staleTimeMs: 0, + idleTtlMs: 0, + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, diff --git a/packages/client-runtime/src/state/serverComposerCatalog.test.ts b/packages/client-runtime/src/state/serverComposerCatalog.test.ts new file mode 100644 index 000000000..b8f755391 --- /dev/null +++ b/packages/client-runtime/src/state/serverComposerCatalog.test.ts @@ -0,0 +1,50 @@ +import { EnvironmentId, ProviderInstanceId } from "@spiritdevs/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { createServerEnvironmentAtoms } from "./server.ts"; + +describe("composer catalog query identity", () => { + it("never reuses another environment, provider instance, or workspace's catalog", () => { + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.effect(EnvironmentRegistry, Effect.die("No query should run in this identity test")), + Layer.effect( + EnvironmentCacheStore, + Effect.die("No cache should load in this identity test"), + ), + ), + ); + const { composerCatalog } = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => Atom.make(null), + }); + const target = { + environmentId: EnvironmentId.make("environment-a"), + input: { + instanceId: ProviderInstanceId.make("claude-a"), + cwd: "/project-a", + }, + }; + expect(composerCatalog(target)).toBe( + composerCatalog({ ...target, input: { ...target.input } }), + ); + expect(composerCatalog(target)).not.toBe( + composerCatalog({ ...target, environmentId: EnvironmentId.make("environment-b") }), + ); + expect(composerCatalog(target)).not.toBe( + composerCatalog({ + ...target, + input: { ...target.input, instanceId: ProviderInstanceId.make("claude-b") }, + }), + ); + expect(composerCatalog(target)).not.toBe( + composerCatalog({ ...target, input: { ...target.input, cwd: "/project-b" } }), + ); + expect(composerCatalog(target)).not.toBe( + composerCatalog({ ...target, input: { ...target.input, cwd: null } }), + ); + }); +}); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a4b7ad7b..0719eda35 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -304,6 +304,7 @@ import { } from "./previewAutomation.ts"; import { ServerConfigStreamEvent, + ServerProviderComposerCatalog, DesktopUpdateCommitInput, ServerConfig, ServerProviderAuthenticationCancelInput, @@ -439,6 +440,7 @@ export const WS_METHODS = { // Server meta serverProbe: "server.probe", serverGetConfig: "server.getConfig", + serverGetComposerCatalog: "server.getComposerCatalog", serverRefreshProviders: "server.refreshProviders", serverStartProviderAuthentication: "server.startProviderAuthentication", serverCompleteProviderAuthentication: "server.completeProviderAuthentication", @@ -538,6 +540,12 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsServerGetComposerCatalogRpc = Rpc.make(WS_METHODS.serverGetComposerCatalog, { + payload: Schema.Struct({ instanceId: ProviderInstanceId, cwd: Schema.NullOr(Schema.String) }), + success: ServerProviderComposerCatalog, + error: EnvironmentAuthorizationError, +}); + export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -1931,6 +1939,7 @@ export const EmailRpcs = RpcGroup.make( export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, + WsServerGetComposerCatalogRpc, WsServerRefreshProvidersRpc, WsServerStartProviderAuthenticationRpc, WsServerCompleteProviderAuthenticationRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 65dc75d48..60d6dfa70 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -101,6 +101,12 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderComposerCatalog = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), + slashCommands: Schema.Array(ServerProviderSlashCommand), +}); +export type ServerProviderComposerCatalog = typeof ServerProviderComposerCatalog.Type; + /** * Availability of a configured provider instance from the runtime's POV. *