| author | |
| committer | |
| log | ea71a19d4181ce13b0e8f374f45ba9237ee4a9ce |
| tree | c3869594ffe41097fdf07b0dc5fe658be94578a8 |
| parent | 9a7b1e21e51609266adf657bcab0b43b6bcd445c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Rodrigo Brechard <rodrigo@clubtidy.fr>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>16 files changed, 1080 insertions(+), 82 deletions(-)
apps/mobile/src/features/threads/use-composer-command-menu.ts+9-2| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | import { |
| 13 | 13 | dedupeProviderSkillsByName, |
| 14 | 14 | getProviderSkillsForSlashMenu, |
| 15 | isProviderSkillUserInvocable, | |
| 15 | 16 | } from "@t3tools/client-runtime/providerSkills"; |
| 16 | 17 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 17 | 18 | |
| ... | ... | @@ -114,8 +115,14 @@ export function useComposerCommandMenu({ |
| 114 | 115 | (item.command === "model" || onUpdateInteractionMode !== undefined), |
| 115 | 116 | ); |
| 116 | 117 | |
| 118 | // A provider expands a slash command only when it opens the whole | |
| 119 | // message; elsewhere it arrives as literal text. Built-ins apply | |
| 120 | // locally and skills insert a `$` mention the server dispatches from | |
| 121 | // any position, so only provider commands are position-gated. | |
| 117 | 122 | const providerCommands: ComposerCommandItem[] = []; |
| 118 | for (const command of selectedProviderStatus?.slashCommands ?? []) { | |
| 123 | const expandableCommands = | |
| 124 | trigger.rangeStart === 0 ? (selectedProviderStatus?.slashCommands ?? []) : []; | |
| 125 | for (const command of expandableCommands) { | |
| 119 | 126 | if (!command.name.toLowerCase().includes(q)) continue; |
| 120 | 127 | // Codex feedback uploads an existing thread's session and logs. |
| 121 | 128 | if ( |
| ... | ... | @@ -149,7 +156,7 @@ export function useComposerCommandMenu({ |
| 149 | 156 | |
| 150 | 157 | if (trigger.kind === "skill") { |
| 151 | 158 | const enabledSkills = dedupeProviderSkillsByName( |
| 152 | (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled), | |
| 159 | (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable), | |
| 153 | 160 | ); |
| 154 | 161 | const normalizedQuery = normalizeSearchQuery(trigger.query, { |
| 155 | 162 | trimLeadingPattern: /^\$+/, |
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; | |
| 4 | ||
| 5 | const SKILLS = new Set(["implement", "review", "re-release-version"]); | |
| 6 | ||
| 7 | describe("planClaudeSkillDispatch", () => { | |
| 8 | it("leaves a prompt without a known skill untouched", () => { | |
| 9 | expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined(); | |
| 10 | // Not a discovered skill, so it stays prose rather than becoming a command. | |
| 11 | expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); | |
| 12 | }); | |
| 13 | ||
| 14 | it("moves a mid-prompt mention into a trailing slash command", () => { | |
| 15 | expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ | |
| 16 | leadingText: "ok, now", | |
| 17 | commandText: "/implement all the tickets", | |
| 18 | skillName: "implement", | |
| 19 | }); | |
| 20 | }); | |
| 21 | ||
| 22 | it("keeps a mention that opens the prompt as a single command block", () => { | |
| 23 | expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({ | |
| 24 | leadingText: undefined, | |
| 25 | commandText: "/review\nfocus on auth", | |
| 26 | skillName: "review", | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | it("dispatches the last mention and rewrites earlier ones inline", () => { | |
| 31 | expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ | |
| 32 | leadingText: "/review the diff, then", | |
| 33 | commandText: "/implement the fixes", | |
| 34 | skillName: "implement", | |
| 35 | }); | |
| 36 | }); | |
| 37 | ||
| 38 | it("ignores a dollar token glued to other text", () => { | |
| 39 | expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); | |
| 40 | }); | |
| 41 | }); |
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts created+78| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | /** | |
| 2 | * ClaudeSkillDispatch — turns `$skill` mentions in a composer prompt into the | |
| 3 | * slash invocation Claude Code actually runs. | |
| 4 | * | |
| 5 | * The composer inserts `$name` for every provider. Codex parses that natively; | |
| 6 | * Claude Code does not, and treats it as prose. Claude Code's only user-side | |
| 7 | * invocation is a text block whose first character is `/`: the harness | |
| 8 | * expands `/name args` into the SKILL.md body, and every character after the | |
| 9 | * name (newlines included) arrives as `ARGUMENTS`. Verified against the CLI in | |
| 10 | * stream-json mode, which is what the Agent SDK uses: | |
| 11 | * | |
| 12 | * - The check runs on the LAST text block of the message. Earlier text | |
| 13 | * blocks are preserved verbatim, and image blocks may sit before it. | |
| 14 | * - Leading whitespace, or a `/name` that starts a later line of the same | |
| 15 | * block, is literal text. | |
| 16 | * - Only one skill expands per message; a second `/x` becomes argument text | |
| 17 | * (anthropics/claude-code#87113). The model still starts the rest through | |
| 18 | * its Skill tool when it reads `/name` in the prompt, so earlier mentions | |
| 19 | * are rewritten to `/name` inline. | |
| 20 | * | |
| 21 | * So one mention anywhere in the prompt becomes a guaranteed invocation, and | |
| 22 | * the user's text on either side is kept in order. | |
| 23 | * | |
| 24 | * @module provider/Drivers/ClaudeSkillDispatch | |
| 25 | */ | |
| 26 | ||
| 27 | /** | |
| 28 | * Same token shape the composer and timeline chips recognise | |
| 29 | * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a | |
| 30 | * dispatched skill are always the same set. | |
| 31 | */ | |
| 32 | const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; | |
| 33 | ||
| 34 | export interface ClaudeSkillDispatch { | |
| 35 | /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ | |
| 36 | readonly leadingText: string | undefined; | |
| 37 | /** `/name` plus the trailing text, ready to be the message's last text block. */ | |
| 38 | readonly commandText: string; | |
| 39 | readonly skillName: string; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Split `prompt` around the last `$skill` mention that names a known skill. | |
| 44 | * Returns `undefined` when there is nothing to dispatch, in which case the | |
| 45 | * prompt should go out unchanged. Mentions that do not match a discovered | |
| 46 | * skill stay literal: a `$HOME` in prose must not become a command. | |
| 47 | */ | |
| 48 | export function planClaudeSkillDispatch( | |
| 49 | prompt: string, | |
| 50 | skillNames: ReadonlySet<string>, | |
| 51 | ): ClaudeSkillDispatch | undefined { | |
| 52 | const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { | |
| 53 | const name = match[2] ?? ""; | |
| 54 | if (!skillNames.has(name)) return []; | |
| 55 | const start = (match.index ?? 0) + (match[1]?.length ?? 0); | |
| 56 | return [{ name, start, end: start + name.length + 1 }]; | |
| 57 | }); | |
| 58 | const last = mentions.at(-1); | |
| 59 | if (!last) { | |
| 60 | return undefined; | |
| 61 | } | |
| 62 | ||
| 63 | const leading = prompt.slice(0, last.start); | |
| 64 | const trailing = prompt.slice(last.end); | |
| 65 | const leadingWithInlineSlashes = mentions | |
| 66 | .slice(0, -1) | |
| 67 | .reduceRight( | |
| 68 | (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`, | |
| 69 | leading, | |
| 70 | ) | |
| 71 | .trimEnd(); | |
| 72 | ||
| 73 | return { | |
| 74 | leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, | |
| 75 | commandText: `/${last.name}${trailing}`.trimEnd(), | |
| 76 | skillName: last.name, | |
| 77 | }; | |
| 78 | } |
apps/server/src/provider/Drivers/ClaudeSkills.test.ts+430-51| ... | ... | @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 5 | import * as Path from "effect/Path"; |
| 6 | 6 | |
| 7 | import { discoverClaudeSkills } from "./ClaudeSkills.ts"; | |
| 7 | import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; | |
| 8 | 8 | |
| 9 | 9 | const writeSkill = Effect.fn(function* ( |
| 10 | 10 | skillsDir: string, |
| ... | ... | @@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { |
| 66 | 66 | }), |
| 67 | 67 | ); |
| 68 | 68 | |
| 69 | it.effect("discovers project skills from the workspace .agents directory", () => | |
| 69 | it.effect("ignores .agents/skills, which Claude Code does not load", () => | |
| 70 | 70 | Effect.gen(function* () { |
| 71 | 71 | const fs = yield* FileSystem.FileSystem; |
| 72 | 72 | const path = yield* Path.Path; |
| ... | ... | @@ -74,6 +74,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { |
| 74 | 74 | const configDir = path.join(tempDir, "claude-home"); |
| 75 | 75 | const workspace = path.join(tempDir, "workspace"); |
| 76 | 76 | |
| 77 | // Verified against the CLI: `/review` here is answered with | |
| 78 | // `Unknown command`, so offering it would dispatch a dead command. | |
| 77 | 79 | yield* writeSkill( |
| 78 | 80 | path.join(workspace, ".agents", "skills"), |
| 79 | 81 | "review", |
| ... | ... | @@ -82,19 +84,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { |
| 82 | 84 | |
| 83 | 85 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); |
| 84 | 86 | |
| 85 | assert.deepEqual(skills, [ | |
| 86 | { | |
| 87 | name: "review", | |
| 88 | path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), | |
| 89 | enabled: true, | |
| 90 | scope: "project", | |
| 91 | description: "Review the changes.", | |
| 92 | }, | |
| 93 | ]); | |
| 87 | assert.deepEqual(skills, []); | |
| 94 | 88 | }), |
| 95 | 89 | ); |
| 96 | 90 | |
| 97 | it.effect("prefers workspace .claude skills on three-way name collisions", () => | |
| 91 | it.effect("prefers user skills on name collisions even with a stray .agents copy", () => | |
| 98 | 92 | Effect.gen(function* () { |
| 99 | 93 | const fs = yield* FileSystem.FileSystem; |
| 100 | 94 | const path = yield* Path.Path; |
| ... | ... | @@ -123,49 +117,16 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { |
| 123 | 117 | assert.deepEqual(skills, [ |
| 124 | 118 | { |
| 125 | 119 | name: "deploy", |
| 126 | path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), | |
| 120 | path: path.join(configDir, "skills", "deploy", "SKILL.md"), | |
| 127 | 121 | enabled: true, |
| 128 | scope: "project", | |
| 129 | description: "Claude deploy.", | |
| 130 | }, | |
| 131 | ]); | |
| 132 | }), | |
| 133 | ); | |
| 134 | ||
| 135 | it.effect("prefers workspace .agents skills over user skills on name collisions", () => | |
| 136 | Effect.gen(function* () { | |
| 137 | const fs = yield* FileSystem.FileSystem; | |
| 138 | const path = yield* Path.Path; | |
| 139 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 140 | const configDir = path.join(tempDir, "claude-home"); | |
| 141 | const workspace = path.join(tempDir, "workspace"); | |
| 142 | ||
| 143 | yield* writeSkill( | |
| 144 | path.join(configDir, "skills"), | |
| 145 | "deploy", | |
| 146 | ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), | |
| 147 | ); | |
| 148 | yield* writeSkill( | |
| 149 | path.join(workspace, ".agents", "skills"), | |
| 150 | "deploy", | |
| 151 | ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), | |
| 152 | ); | |
| 153 | ||
| 154 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); | |
| 155 | ||
| 156 | assert.deepEqual(skills, [ | |
| 157 | { | |
| 158 | name: "deploy", | |
| 159 | path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), | |
| 160 | enabled: true, | |
| 161 | scope: "project", | |
| 162 | description: "Agents deploy.", | |
| 122 | scope: "user", | |
| 123 | description: "User deploy.", | |
| 163 | 124 | }, |
| 164 | 125 | ]); |
| 165 | 126 | }), |
| 166 | 127 | ); |
| 167 | 128 | |
| 168 | it.effect("prefers project skills over user skills on name collisions", () => | |
| 129 | it.effect("prefers user skills over project skills on name collisions", () => | |
| 169 | 130 | Effect.gen(function* () { |
| 170 | 131 | const fs = yield* FileSystem.FileSystem; |
| 171 | 132 | const path = yield* Path.Path; |
| ... | ... | @@ -187,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { |
| 187 | 148 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); |
| 188 | 149 | |
| 189 | 150 | assert.equal(skills.length, 1); |
| 190 | assert.equal(skills[0]?.scope, "project"); | |
| 191 | assert.equal(skills[0]?.description, "Project deploy."); | |
| 151 | assert.equal(skills[0]?.scope, "user"); | |
| 152 | assert.equal(skills[0]?.description, "User deploy."); | |
| 192 | 153 | }), |
| 193 | 154 | ); |
| 194 | 155 | |
| ... | ... | @@ -287,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { |
| 287 | 248 | }), |
| 288 | 249 | ); |
| 289 | 250 | |
| 251 | it.effect("marks skills that only the user can invoke", () => | |
| 252 | Effect.gen(function* () { | |
| 253 | const fs = yield* FileSystem.FileSystem; | |
| 254 | const path = yield* Path.Path; | |
| 255 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 256 | const configDir = path.join(tempDir, "claude-home"); | |
| 257 | const workspace = path.join(tempDir, "workspace"); | |
| 258 | ||
| 259 | yield* writeSkill( | |
| 260 | path.join(workspace, ".claude", "skills"), | |
| 261 | "re-release-version", | |
| 262 | [ | |
| 263 | "---", | |
| 264 | "name: re-release-version", | |
| 265 | "description: Move the current tag forward.", | |
| 266 | "disable-model-invocation: true", | |
| 267 | "---", | |
| 268 | "", | |
| 269 | "# Body", | |
| 270 | ].join("\n"), | |
| 271 | ); | |
| 272 | yield* writeSkill( | |
| 273 | path.join(workspace, ".claude", "skills"), | |
| 274 | "release-version", | |
| 275 | ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join( | |
| 276 | "\n", | |
| 277 | ), | |
| 278 | ); | |
| 279 | ||
| 280 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); | |
| 281 | ||
| 282 | assert.equal( | |
| 283 | skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly, | |
| 284 | true, | |
| 285 | ); | |
| 286 | assert.equal( | |
| 287 | skills.find((skill) => skill.name === "release-version")?.userInvocationOnly, | |
| 288 | undefined, | |
| 289 | ); | |
| 290 | }), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect("disables skills switched off by skillOverrides", () => | |
| 294 | Effect.gen(function* () { | |
| 295 | const fs = yield* FileSystem.FileSystem; | |
| 296 | const path = yield* Path.Path; | |
| 297 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 298 | const configDir = path.join(tempDir, "claude-home"); | |
| 299 | const workspace = path.join(tempDir, "workspace"); | |
| 300 | ||
| 301 | for (const name of ["kept", "off-by-user", "off-by-project"]) { | |
| 302 | yield* writeSkill( | |
| 303 | path.join(configDir, "skills"), | |
| 304 | name, | |
| 305 | ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), | |
| 306 | ); | |
| 307 | } | |
| 308 | ||
| 309 | yield* fs.makeDirectory(configDir, { recursive: true }); | |
| 310 | yield* fs.writeFileString( | |
| 311 | path.join(configDir, "settings.json"), | |
| 312 | '{ "skillOverrides": { "off-by-user": "off", "kept": "on" } }', | |
| 313 | ); | |
| 314 | yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); | |
| 315 | yield* fs.writeFileString( | |
| 316 | path.join(workspace, ".claude", "settings.json"), | |
| 317 | '{ "skillOverrides": { "off-by-project": "off" } }', | |
| 318 | ); | |
| 319 | ||
| 320 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); | |
| 321 | ||
| 322 | assert.deepEqual( | |
| 323 | skills.map((skill) => [skill.name, skill.enabled]), | |
| 324 | [ | |
| 325 | ["kept", true], | |
| 326 | ["off-by-project", false], | |
| 327 | ["off-by-user", false], | |
| 328 | ], | |
| 329 | ); | |
| 330 | }), | |
| 331 | ); | |
| 332 | ||
| 333 | it.effect("ignores unreadable settings when resolving skillOverrides", () => | |
| 334 | Effect.gen(function* () { | |
| 335 | const fs = yield* FileSystem.FileSystem; | |
| 336 | const path = yield* Path.Path; | |
| 337 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 338 | const configDir = path.join(tempDir, "claude-home"); | |
| 339 | ||
| 340 | yield* writeSkill( | |
| 341 | path.join(configDir, "skills"), | |
| 342 | "kept", | |
| 343 | ["---", "name: kept", "---", "", "# Body"].join("\n"), | |
| 344 | ); | |
| 345 | yield* fs.writeFileString(path.join(configDir, "settings.json"), "{ not json"); | |
| 346 | ||
| 347 | const skills = yield* discoverClaudeSkills({ homePath: configDir }); | |
| 348 | ||
| 349 | assert.deepEqual( | |
| 350 | skills.map((skill) => [skill.name, skill.enabled]), | |
| 351 | [["kept", true]], | |
| 352 | ); | |
| 353 | }), | |
| 354 | ); | |
| 355 | ||
| 356 | it.effect("treats a user-invocable-only override like disable-model-invocation", () => | |
| 357 | Effect.gen(function* () { | |
| 358 | const fs = yield* FileSystem.FileSystem; | |
| 359 | const path = yield* Path.Path; | |
| 360 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 361 | const configDir = path.join(tempDir, "claude-home"); | |
| 362 | ||
| 363 | yield* writeSkill( | |
| 364 | path.join(configDir, "skills"), | |
| 365 | "ask-matt", | |
| 366 | ["---", "name: ask-matt", "---", "", "# Body"].join("\n"), | |
| 367 | ); | |
| 368 | yield* fs.writeFileString( | |
| 369 | path.join(configDir, "settings.json"), | |
| 370 | '{ "skillOverrides": { "ask-matt": "user-invocable-only" } }', | |
| 371 | ); | |
| 372 | ||
| 373 | const skills = yield* discoverClaudeSkills({ homePath: configDir }); | |
| 374 | ||
| 375 | assert.deepEqual( | |
| 376 | skills.map((skill) => [skill.name, skill.enabled, skill.userInvocationOnly === true]), | |
| 377 | [["ask-matt", true, true]], | |
| 378 | ); | |
| 379 | }), | |
| 380 | ); | |
| 381 | ||
| 382 | it.effect("drops every override in a file when one value is invalid, as Claude Code does", () => | |
| 383 | Effect.gen(function* () { | |
| 384 | const fs = yield* FileSystem.FileSystem; | |
| 385 | const path = yield* Path.Path; | |
| 386 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 387 | const configDir = path.join(tempDir, "claude-home"); | |
| 388 | ||
| 389 | for (const name of ["unknown-mode", "boolean-false", "sibling-off"]) { | |
| 390 | yield* writeSkill( | |
| 391 | path.join(configDir, "skills"), | |
| 392 | name, | |
| 393 | ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), | |
| 394 | ); | |
| 395 | } | |
| 396 | // Verified against the CLI: with an unknown string or a boolean in the | |
| 397 | // map, the valid "off" sibling is ignored too and every skill runs. | |
| 398 | yield* fs.writeFileString( | |
| 399 | path.join(configDir, "settings.json"), | |
| 400 | '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }', | |
| 401 | ); | |
| 402 | ||
| 403 | const skills = yield* discoverClaudeSkills({ homePath: configDir }); | |
| 404 | ||
| 405 | assert.deepEqual( | |
| 406 | skills.map((skill) => [skill.name, skill.enabled]), | |
| 407 | [ | |
| 408 | ["boolean-false", true], | |
| 409 | ["sibling-off", true], | |
| 410 | ["unknown-mode", true], | |
| 411 | ], | |
| 412 | ); | |
| 413 | }), | |
| 414 | ); | |
| 415 | ||
| 416 | it.effect("reads repository root settings from a nested workspace", () => | |
| 417 | Effect.gen(function* () { | |
| 418 | const fs = yield* FileSystem.FileSystem; | |
| 419 | const path = yield* Path.Path; | |
| 420 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 421 | const configDir = path.join(tempDir, "claude-home"); | |
| 422 | const repo = path.join(tempDir, "repo"); | |
| 423 | const workspace = path.join(repo, "packages", "app"); | |
| 424 | ||
| 425 | for (const name of ["root-off", "root-off-cwd-on", "cwd-off-root-on"]) { | |
| 426 | yield* writeSkill( | |
| 427 | path.join(configDir, "skills"), | |
| 428 | name, | |
| 429 | ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), | |
| 430 | ); | |
| 431 | } | |
| 432 | yield* fs.makeDirectory(path.join(repo, ".git"), { recursive: true }); | |
| 433 | yield* fs.makeDirectory(path.join(repo, ".claude"), { recursive: true }); | |
| 434 | yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); | |
| 435 | // The CLI ignores the root's plain settings.json from a nested cwd. | |
| 436 | yield* fs.writeFileString( | |
| 437 | path.join(repo, ".claude", "settings.json"), | |
| 438 | '{ "skillOverrides": { "cwd-off-root-on": "off" } }', | |
| 439 | ); | |
| 440 | // The root local file outranks the workspace local file, as in the CLI. | |
| 441 | yield* fs.writeFileString( | |
| 442 | path.join(repo, ".claude", "settings.local.json"), | |
| 443 | '{ "skillOverrides": { "root-off": "off", "root-off-cwd-on": "off", "cwd-off-root-on": "on" } }', | |
| 444 | ); | |
| 445 | yield* fs.writeFileString( | |
| 446 | path.join(workspace, ".claude", "settings.local.json"), | |
| 447 | '{ "skillOverrides": { "root-off-cwd-on": "on", "cwd-off-root-on": "off" } }', | |
| 448 | ); | |
| 449 | ||
| 450 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); | |
| 451 | ||
| 452 | assert.deepEqual( | |
| 453 | skills.map((skill) => [skill.name, skill.enabled]), | |
| 454 | [ | |
| 455 | ["cwd-off-root-on", true], | |
| 456 | ["root-off", false], | |
| 457 | ["root-off-cwd-on", false], | |
| 458 | ], | |
| 459 | ); | |
| 460 | }), | |
| 461 | ); | |
| 462 | ||
| 463 | it.effect("ignores ancestor settings outside a repository", () => | |
| 464 | Effect.gen(function* () { | |
| 465 | const fs = yield* FileSystem.FileSystem; | |
| 466 | const path = yield* Path.Path; | |
| 467 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 468 | const configDir = path.join(tempDir, "claude-home"); | |
| 469 | const parent = path.join(tempDir, "not-a-repo"); | |
| 470 | const workspace = path.join(parent, "workspace"); | |
| 471 | ||
| 472 | yield* writeSkill( | |
| 473 | path.join(configDir, "skills"), | |
| 474 | "kept", | |
| 475 | ["---", "name: kept", "---", "", "# Body"].join("\n"), | |
| 476 | ); | |
| 477 | yield* fs.makeDirectory(path.join(parent, ".claude"), { recursive: true }); | |
| 478 | yield* fs.makeDirectory(workspace, { recursive: true }); | |
| 479 | yield* fs.writeFileString( | |
| 480 | path.join(parent, ".claude", "settings.local.json"), | |
| 481 | '{ "skillOverrides": { "kept": "off" } }', | |
| 482 | ); | |
| 483 | ||
| 484 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); | |
| 485 | ||
| 486 | assert.deepEqual( | |
| 487 | skills.map((skill) => [skill.name, skill.enabled]), | |
| 488 | [["kept", true]], | |
| 489 | ); | |
| 490 | }), | |
| 491 | ); | |
| 492 | ||
| 493 | it.effect("lets the administrator's managed policy outrank every other settings file", () => | |
| 494 | Effect.gen(function* () { | |
| 495 | const path = yield* Path.Path; | |
| 496 | ||
| 497 | for (const [platform, expected] of [ | |
| 498 | ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"], | |
| 499 | ["linux", "/etc/claude-code/managed-settings.json"], | |
| 500 | ] as const) { | |
| 501 | const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {}); | |
| 502 | assert.deepEqual(paths, [ | |
| 503 | "/home/.claude/settings.json", | |
| 504 | "/workspace/.claude/settings.json", | |
| 505 | "/workspace/.claude/settings.local.json", | |
| 506 | expected, | |
| 507 | ]); | |
| 508 | } | |
| 509 | ||
| 510 | assert.deepEqual( | |
| 511 | skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", { | |
| 512 | PROGRAMDATA: "C:/ProgramData", | |
| 513 | }).at(-1), | |
| 514 | "C:/ProgramData/ClaudeCode/managed-settings.json", | |
| 515 | ); | |
| 516 | assert.deepEqual(skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", {}), [ | |
| 517 | "/home/.claude/settings.json", | |
| 518 | ]); | |
| 519 | ||
| 520 | // Only the repository root's local file joins in, after the | |
| 521 | // workspace's own local file so it wins. | |
| 522 | assert.deepEqual( | |
| 523 | skillOverrideSettingsPaths( | |
| 524 | path, | |
| 525 | "/home/.claude", | |
| 526 | "/repo/packages/app", | |
| 527 | "linux", | |
| 528 | {}, | |
| 529 | "/repo", | |
| 530 | ), | |
| 531 | [ | |
| 532 | "/home/.claude/settings.json", | |
| 533 | "/repo/packages/app/.claude/settings.json", | |
| 534 | "/repo/packages/app/.claude/settings.local.json", | |
| 535 | "/repo/.claude/settings.local.json", | |
| 536 | "/etc/claude-code/managed-settings.json", | |
| 537 | ], | |
| 538 | ); | |
| 539 | // A workspace that is the root itself is not read twice. | |
| 540 | assert.deepEqual( | |
| 541 | skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"), | |
| 542 | [ | |
| 543 | "/home/.claude/settings.json", | |
| 544 | "/repo/.claude/settings.json", | |
| 545 | "/repo/.claude/settings.local.json", | |
| 546 | "/etc/claude-code/managed-settings.json", | |
| 547 | ], | |
| 548 | ); | |
| 549 | }), | |
| 550 | ); | |
| 551 | ||
| 552 | it.effect("records a skill Claude Code keeps out of its own slash commands", () => | |
| 553 | Effect.gen(function* () { | |
| 554 | const fs = yield* FileSystem.FileSystem; | |
| 555 | const path = yield* Path.Path; | |
| 556 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 557 | const configDir = path.join(tempDir, "claude-home"); | |
| 558 | ||
| 559 | yield* writeSkill( | |
| 560 | path.join(configDir, "skills"), | |
| 561 | "agent-only", | |
| 562 | ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"), | |
| 563 | ); | |
| 564 | ||
| 565 | const skills = yield* discoverClaudeSkills({ homePath: configDir }); | |
| 566 | ||
| 567 | assert.deepEqual( | |
| 568 | skills.map((skill) => [skill.name, skill.userInvocable]), | |
| 569 | [["agent-only", false]], | |
| 570 | ); | |
| 571 | }), | |
| 572 | ); | |
| 573 | ||
| 574 | it.effect("identifies a skill by its directory, as Claude Code does", () => | |
| 575 | Effect.gen(function* () { | |
| 576 | const fs = yield* FileSystem.FileSystem; | |
| 577 | const path = yield* Path.Path; | |
| 578 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 579 | const configDir = path.join(tempDir, "claude-home"); | |
| 580 | ||
| 581 | yield* writeSkill( | |
| 582 | path.join(configDir, "skills"), | |
| 583 | "probe-alias", | |
| 584 | ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), | |
| 585 | ); | |
| 586 | yield* fs.writeFileString( | |
| 587 | path.join(configDir, "settings.json"), | |
| 588 | '{ "skillOverrides": { "probe-alias-frontmatter": "off" } }', | |
| 589 | ); | |
| 590 | ||
| 591 | const skills = yield* discoverClaudeSkills({ homePath: configDir }); | |
| 592 | ||
| 593 | // The frontmatter name is not the command, so an override naming it is | |
| 594 | // not the override Claude Code would apply either. | |
| 595 | assert.deepEqual( | |
| 596 | skills.map((skill) => [skill.name, skill.enabled]), | |
| 597 | [["probe-alias", true]], | |
| 598 | ); | |
| 599 | }), | |
| 600 | ); | |
| 601 | ||
| 602 | it.effect("switches a skill off by its directory name", () => | |
| 603 | Effect.gen(function* () { | |
| 604 | const fs = yield* FileSystem.FileSystem; | |
| 605 | const path = yield* Path.Path; | |
| 606 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 607 | const configDir = path.join(tempDir, "claude-home"); | |
| 608 | ||
| 609 | yield* writeSkill( | |
| 610 | path.join(configDir, "skills"), | |
| 611 | "probe-alias", | |
| 612 | ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), | |
| 613 | ); | |
| 614 | yield* fs.writeFileString( | |
| 615 | path.join(configDir, "settings.json"), | |
| 616 | '{ "skillOverrides": { "probe-alias": "off" } }', | |
| 617 | ); | |
| 618 | ||
| 619 | const skills = yield* discoverClaudeSkills({ homePath: configDir }); | |
| 620 | ||
| 621 | assert.deepEqual( | |
| 622 | skills.map((skill) => [skill.name, skill.enabled]), | |
| 623 | [["probe-alias", false]], | |
| 624 | ); | |
| 625 | }), | |
| 626 | ); | |
| 627 | ||
| 628 | it.effect("accepts the YAML 1.1 boolean spellings Claude Code allows", () => | |
| 629 | Effect.gen(function* () { | |
| 630 | const fs = yield* FileSystem.FileSystem; | |
| 631 | const path = yield* Path.Path; | |
| 632 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | |
| 633 | const configDir = path.join(tempDir, "claude-home"); | |
| 634 | const skillsDir = path.join(configDir, "skills"); | |
| 635 | ||
| 636 | yield* writeSkill( | |
| 637 | skillsDir, | |
| 638 | "user-only-yes", | |
| 639 | ["---", "disable-model-invocation: yes", "---", "", "# Body"].join("\n"), | |
| 640 | ); | |
| 641 | yield* writeSkill( | |
| 642 | skillsDir, | |
| 643 | "agent-only-no", | |
| 644 | ["---", "user-invocable: no", "---", "", "# Body"].join("\n"), | |
| 645 | ); | |
| 646 | yield* writeSkill( | |
| 647 | skillsDir, | |
| 648 | "plain-off", | |
| 649 | ["---", "disable-model-invocation: off", "---", "", "# Body"].join("\n"), | |
| 650 | ); | |
| 651 | ||
| 652 | const skills = yield* discoverClaudeSkills({ homePath: configDir }); | |
| 653 | ||
| 654 | assert.deepEqual( | |
| 655 | skills.map((skill) => [ | |
| 656 | skill.name, | |
| 657 | skill.userInvocationOnly === true, | |
| 658 | skill.userInvocable === false, | |
| 659 | ]), | |
| 660 | [ | |
| 661 | ["agent-only-no", false, true], | |
| 662 | ["plain-off", false, false], | |
| 663 | ["user-only-yes", true, false], | |
| 664 | ], | |
| 665 | ); | |
| 666 | }), | |
| 667 | ); | |
| 668 | ||
| 290 | 669 | it.effect("returns an empty list when no skill roots exist", () => |
| 291 | 670 | Effect.gen(function* () { |
| 292 | 671 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/provider/Drivers/ClaudeSkills.ts+250-21| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | /** |
| 2 | 2 | * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. |
| 3 | 3 | * |
| 4 | * Claude Code loads skills from `<config dir>/skills` (user scope), then | |
| 5 | * `<cwd>/.agents/skills` and `<cwd>/.claude/skills` (project scope), one | |
| 6 | * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots | |
| 7 | * win on name collisions, so precedence is user, `.agents`, then `.claude`. | |
| 4 | * Claude Code loads skills from `<config dir>/skills` (user scope) and | |
| 5 | * `<cwd>/.claude/skills` (project scope), one directory per skill with a | |
| 6 | * `SKILL.md` carrying YAML frontmatter. The user root wins on name collisions, | |
| 7 | * matching the CLI. `.agents/skills` is a Codex location: verified against the | |
| 8 | * CLI, a skill that lives only there is answered with `Unknown command`, so it | |
| 9 | * is not scanned here. | |
| 8 | 10 | * The Agent SDK init handshake surfaces skills only as slash commands without |
| 9 | 11 | * their filesystem paths, so the provider snapshot scans the same locations |
| 10 | 12 | * directly, mirroring how the Codex app-server reports its skills. |
| ... | ... | @@ -17,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; |
| 17 | 19 | import * as Effect from "effect/Effect"; |
| 18 | 20 | import * as FileSystem from "effect/FileSystem"; |
| 19 | 21 | import * as Path from "effect/Path"; |
| 22 | import * as Schema from "effect/Schema"; | |
| 23 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 24 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 20 | 25 | import { parse as parseYamlDocument } from "yaml"; |
| 21 | 26 | |
| 22 | 27 | import { expandHomePath } from "../../pathExpansion.ts"; |
| ... | ... | @@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; |
| 28 | 33 | type SkillFrontmatter = |
| 29 | 34 | | { readonly kind: "missing" } |
| 30 | 35 | | { readonly kind: "malformed" } |
| 31 | | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; | |
| 36 | | { | |
| 37 | readonly kind: "parsed"; | |
| 38 | readonly description?: string; | |
| 39 | readonly userInvocationOnly?: boolean; | |
| 40 | readonly userInvocable?: boolean; | |
| 41 | }; | |
| 42 | ||
| 43 | /** | |
| 44 | * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`, | |
| 45 | * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and | |
| 46 | * numbers. Verified against the CLI: a skill carrying `user-invocable: no` is | |
| 47 | * absent from its published slash commands, so a strict `=== false` here would | |
| 48 | * offer a command the CLI rejects. | |
| 49 | */ | |
| 50 | function parseFrontmatterBoolean(value: unknown): boolean | undefined { | |
| 51 | if (typeof value === "boolean") return value; | |
| 52 | if (typeof value === "number") { | |
| 53 | return value === 1 ? true : value === 0 ? false : undefined; | |
| 54 | } | |
| 55 | if (typeof value !== "string") return undefined; | |
| 56 | switch (value.trim().toLowerCase()) { | |
| 57 | case "true": | |
| 58 | case "yes": | |
| 59 | case "on": | |
| 60 | case "y": | |
| 61 | return true; | |
| 62 | case "false": | |
| 63 | case "no": | |
| 64 | case "off": | |
| 65 | case "n": | |
| 66 | return false; | |
| 67 | default: | |
| 68 | return undefined; | |
| 69 | } | |
| 70 | } | |
| 32 | 71 | |
| 33 | 72 | function parseSkillFrontmatter(contents: string): SkillFrontmatter { |
| 34 | 73 | const match = FRONTMATTER_PATTERN.exec(contents); |
| ... | ... | @@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { |
| 47 | 86 | } |
| 48 | 87 | |
| 49 | 88 | const record = parsed as Record<string, unknown>; |
| 50 | const name = typeof record.name === "string" ? record.name.trim() : ""; | |
| 51 | 89 | const description = typeof record.description === "string" ? record.description.trim() : ""; |
| 52 | 90 | return { |
| 53 | 91 | kind: "parsed", |
| 54 | ...(name ? { name } : {}), | |
| 55 | 92 | ...(description ? { description } : {}), |
| 93 | ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true | |
| 94 | ? { userInvocationOnly: true } | |
| 95 | : {}), | |
| 96 | ...(parseFrontmatterBoolean(record["user-invocable"]) === false | |
| 97 | ? { userInvocable: false } | |
| 98 | : {}), | |
| 56 | 99 | }; |
| 57 | 100 | } |
| 58 | 101 | |
| 102 | /** | |
| 103 | * Where an administrator installs the policy file whose settings outrank every | |
| 104 | * user and project one. Absent on almost every machine, which is why a missing | |
| 105 | * file is the normal case rather than an error. | |
| 106 | */ | |
| 107 | export function claudeManagedSettingsPath( | |
| 108 | path: Path.Path, | |
| 109 | platform: NodeJS.Platform, | |
| 110 | environment: NodeJS.ProcessEnv, | |
| 111 | ): string | undefined { | |
| 112 | if (platform === "darwin") { | |
| 113 | return "/Library/Application Support/ClaudeCode/managed-settings.json"; | |
| 114 | } | |
| 115 | if (platform === "win32") { | |
| 116 | const programData = environment.PROGRAMDATA?.trim(); | |
| 117 | return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined; | |
| 118 | } | |
| 119 | return "/etc/claude-code/managed-settings.json"; | |
| 120 | } | |
| 121 | ||
| 122 | /** | |
| 123 | * Settings files Claude Code merges for `skillOverrides`, in increasing | |
| 124 | * precedence: user, project, project-local, then the administrator's managed | |
| 125 | * policy, which wins outright. When the workspace sits inside a git | |
| 126 | * repository, the repository root's `settings.local.json` is read too and | |
| 127 | * outranks the workspace's own local file. Verified against the CLI from a | |
| 128 | * nested cwd: a root local file switching a skill off wins over a cwd one | |
| 129 | * switching it on, the root's plain `settings.json` is not consulted, and | |
| 130 | * without a `.git` above the cwd no root file is read. A skill the user | |
| 131 | * switched off is reported disabled rather than dropped, so the picker can | |
| 132 | * grey it out instead of silently losing it. | |
| 133 | */ | |
| 134 | export function skillOverrideSettingsPaths( | |
| 135 | path: Path.Path, | |
| 136 | configDirPath: string, | |
| 137 | cwd: string | undefined, | |
| 138 | platform: NodeJS.Platform, | |
| 139 | environment: NodeJS.ProcessEnv, | |
| 140 | repositoryRoot?: string, | |
| 141 | ): ReadonlyArray<string> { | |
| 142 | const managedPath = claudeManagedSettingsPath(path, platform, environment); | |
| 143 | const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined; | |
| 144 | return [ | |
| 145 | path.join(configDirPath, "settings.json"), | |
| 146 | ...(cwd | |
| 147 | ? [ | |
| 148 | path.join(cwd, ".claude", "settings.json"), | |
| 149 | path.join(cwd, ".claude", "settings.local.json"), | |
| 150 | ] | |
| 151 | : []), | |
| 152 | ...(root ? [path.join(root, ".claude", "settings.local.json")] : []), | |
| 153 | ...(managedPath ? [managedPath] : []), | |
| 154 | ]; | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Nearest ancestor of `cwd` (inclusive) holding a `.git` entry, which is the | |
| 159 | * boundary Claude Code walks up to for project settings. `undefined` outside | |
| 160 | * a repository. | |
| 161 | */ | |
| 162 | const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( | |
| 163 | cwd: string, | |
| 164 | ): Effect.fn.Return<string | undefined, never, FileSystem.FileSystem | Path.Path> { | |
| 165 | const fileSystem = yield* FileSystem.FileSystem; | |
| 166 | const path = yield* Path.Path; | |
| 167 | let current = path.resolve(cwd); | |
| 168 | while (true) { | |
| 169 | const isRoot = yield* fileSystem | |
| 170 | .exists(path.join(current, ".git")) | |
| 171 | .pipe(Effect.orElseSucceed(() => false)); | |
| 172 | if (isRoot) { | |
| 173 | return current; | |
| 174 | } | |
| 175 | const parent = path.dirname(current); | |
| 176 | if (parent === current) { | |
| 177 | return undefined; | |
| 178 | } | |
| 179 | current = parent; | |
| 180 | } | |
| 181 | }); | |
| 182 | ||
| 183 | /** | |
| 184 | * The four states Claude Code accepts. The CLI validates the whole map, not | |
| 185 | * each entry: verified against it, one entry with an unknown value (or a | |
| 186 | * boolean) makes it drop every override in that file, so this schema does the | |
| 187 | * same rather than applying the valid siblings the CLI ignores. | |
| 188 | */ | |
| 189 | const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]); | |
| 190 | ||
| 191 | // Lenient because these settings files are hand-edited and Claude Code itself | |
| 192 | // tolerates comments and trailing commas in them. | |
| 193 | const SkillOverrideSettings = fromLenientJson( | |
| 194 | Schema.Struct({ | |
| 195 | skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)), | |
| 196 | }), | |
| 197 | ); | |
| 198 | const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings); | |
| 199 | ||
| 200 | /** | |
| 201 | * What a `skillOverrides` entry says about one skill. `"user-invocable-only"` | |
| 202 | * hides it from the agent exactly as `disable-model-invocation` does, so it is | |
| 203 | * kept apart from a plain on/off decision rather than collapsed into one. | |
| 204 | */ | |
| 205 | type SkillOverride = { | |
| 206 | readonly enabled: boolean; | |
| 207 | readonly userInvocationOnly: boolean; | |
| 208 | }; | |
| 209 | ||
| 210 | function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride { | |
| 211 | switch (value) { | |
| 212 | case "off": | |
| 213 | return { enabled: false, userInvocationOnly: false }; | |
| 214 | case "user-invocable-only": | |
| 215 | return { enabled: true, userInvocationOnly: true }; | |
| 216 | case "on": | |
| 217 | case "name-only": | |
| 218 | return { enabled: true, userInvocationOnly: false }; | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( | |
| 223 | configDirPath: string, | |
| 224 | cwd: string | undefined, | |
| 225 | environment: NodeJS.ProcessEnv, | |
| 226 | ): Effect.fn.Return<ReadonlyMap<string, SkillOverride>, never, FileSystem.FileSystem | Path.Path> { | |
| 227 | const fileSystem = yield* FileSystem.FileSystem; | |
| 228 | const path = yield* Path.Path; | |
| 229 | const platform = yield* HostProcessPlatform; | |
| 230 | const overridesByName = new Map<string, SkillOverride>(); | |
| 231 | const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); | |
| 232 | ||
| 233 | for (const settingsPath of skillOverrideSettingsPaths( | |
| 234 | path, | |
| 235 | configDirPath, | |
| 236 | cwd, | |
| 237 | platform, | |
| 238 | environment, | |
| 239 | repositoryRoot, | |
| 240 | )) { | |
| 241 | const contents = yield* fileSystem | |
| 242 | .readFileString(settingsPath) | |
| 243 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 244 | if (contents === undefined) { | |
| 245 | continue; | |
| 246 | } | |
| 247 | ||
| 248 | const parsed = yield* decodeSkillOverrideSettings(contents).pipe( | |
| 249 | Effect.tapError((cause) => | |
| 250 | Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", { | |
| 251 | path: settingsPath, | |
| 252 | cause, | |
| 253 | }), | |
| 254 | ), | |
| 255 | Effect.orElseSucceed(() => undefined), | |
| 256 | ); | |
| 257 | const overrides = parsed?.skillOverrides; | |
| 258 | if (!overrides) { | |
| 259 | continue; | |
| 260 | } | |
| 261 | ||
| 262 | for (const [name, value] of Object.entries(overrides)) { | |
| 263 | overridesByName.set(name, parseSkillOverride(value)); | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | return overridesByName; | |
| 268 | }); | |
| 269 | ||
| 59 | 270 | /** |
| 60 | 271 | * Resolve the Claude config directory the CLI would use, matching the |
| 61 | 272 | * precedence the spawned CLI sees: the instance's `homePath` (exported as |
| ... | ... | @@ -85,12 +296,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct |
| 85 | 296 | }); |
| 86 | 297 | |
| 87 | 298 | /** |
| 88 | * Enumerate Claude Code skills from the user config dir, workspace | |
| 89 | * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery | |
| 90 | * is best-effort: unreadable roots and malformed skill entries are skipped so | |
| 91 | * a broken skill never degrades the provider snapshot. On name collisions, | |
| 92 | * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching | |
| 93 | * Claude Code's resolution. | |
| 299 | * Enumerate Claude Code skills from the user config dir and the workspace | |
| 300 | * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed | |
| 301 | * skill entries are skipped so a broken skill never degrades the provider | |
| 302 | * snapshot. Roots are listed highest precedence first and the first hit for a | |
| 303 | * name wins, matching Claude Code: verified against the CLI with the same | |
| 304 | * skill name in both scopes, the user copy is the one that runs. Reporting the | |
| 305 | * project copy instead would attach its invocation metadata to a command | |
| 306 | * Claude Code resolves elsewhere. | |
| 94 | 307 | */ |
| 95 | 308 | export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( |
| 96 | 309 | config: Pick<ClaudeSettings, "homePath">, |
| ... | ... | @@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* |
| 100 | 313 | const fileSystem = yield* FileSystem.FileSystem; |
| 101 | 314 | const path = yield* Path.Path; |
| 102 | 315 | const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); |
| 316 | const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env); | |
| 103 | 317 | |
| 104 | 318 | const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ |
| 105 | 319 | { directory: path.join(configDirPath, "skills"), scope: "user" }, |
| 106 | ...(cwd | |
| 107 | ? [ | |
| 108 | { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, | |
| 109 | { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, | |
| 110 | ] | |
| 111 | : []), | |
| 320 | ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), | |
| 112 | 321 | ]; |
| 113 | 322 | |
| 114 | 323 | const skillsByName = new Map<string, ServerProviderSkill>(); |
| ... | ... | @@ -134,19 +343,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* |
| 134 | 343 | continue; |
| 135 | 344 | } |
| 136 | 345 | |
| 137 | const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); | |
| 346 | // Claude Code identifies a skill by its directory, not by the | |
| 347 | // frontmatter `name`: verified against the CLI, a skill in `probe-alias/` | |
| 348 | // declaring `name: probe-alias-frontmatter` is published as | |
| 349 | // `probe-alias`, and only `skillOverrides["probe-alias"]` switches it | |
| 350 | // off. Keying off the frontmatter name would report a command that does | |
| 351 | // not exist and miss the override that disables it. | |
| 352 | const name = entry.trim(); | |
| 138 | 353 | if (!name) { |
| 139 | 354 | continue; |
| 140 | 355 | } |
| 141 | 356 | |
| 357 | // First root wins, so a later root never displaces a higher-precedence | |
| 358 | // skill of the same name. | |
| 359 | if (skillsByName.has(name)) { | |
| 360 | continue; | |
| 361 | } | |
| 362 | ||
| 363 | const override = skillOverrides.get(name); | |
| 364 | const userInvocationOnly = | |
| 365 | (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) || | |
| 366 | override?.userInvocationOnly === true; | |
| 142 | 367 | skillsByName.set(name, { |
| 143 | 368 | name, |
| 144 | 369 | path: skillPath, |
| 145 | enabled: true, | |
| 370 | enabled: override?.enabled ?? true, | |
| 146 | 371 | scope: root.scope, |
| 147 | 372 | ...(frontmatter.kind === "parsed" && frontmatter.description |
| 148 | 373 | ? { description: frontmatter.description } |
| 149 | 374 | : {}), |
| 375 | ...(userInvocationOnly ? { userInvocationOnly: true } : {}), | |
| 376 | ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false | |
| 377 | ? { userInvocable: false } | |
| 378 | : {}), | |
| 150 | 379 | }); |
| 151 | 380 | } |
| 152 | 381 | } |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+132| ... | ... | @@ -810,6 +810,138 @@ describe("ClaudeAdapterLive", () => { |
| 810 | 810 | ); |
| 811 | 811 | }); |
| 812 | 812 | |
| 813 | it.effect("dispatches a $skill mention as a trailing slash command block", () => { | |
| 814 | // Claude Code only runs `/name` from the message's last text block, so a | |
| 815 | // chip picked mid-prompt is moved there and the surrounding prose kept. | |
| 816 | const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-home-")); | |
| 817 | NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "implement"), { recursive: true }); | |
| 818 | NodeFS.writeFileSync( | |
| 819 | NodePath.join(homeDir, "skills", "implement", "SKILL.md"), | |
| 820 | "---\ndescription: Implement the tickets.\n---\n# Body\n", | |
| 821 | ); | |
| 822 | const harness = makeHarness({ claudeConfig: { homePath: homeDir } }); | |
| 823 | return Effect.gen(function* () { | |
| 824 | yield* Effect.addFinalizer(() => | |
| 825 | Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })), | |
| 826 | ); | |
| 827 | const adapter = yield* ClaudeAdapter; | |
| 828 | const session = yield* adapter.startSession({ | |
| 829 | threadId: THREAD_ID, | |
| 830 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 831 | runtimeMode: "full-access", | |
| 832 | }); | |
| 833 | ||
| 834 | yield* adapter.sendTurn({ | |
| 835 | threadId: session.threadId, | |
| 836 | input: "ok, now $implement all the tickets\nstart with auth", | |
| 837 | attachments: [], | |
| 838 | }); | |
| 839 | ||
| 840 | const promptMessage = yield* Effect.promise(() => | |
| 841 | readFirstPromptMessage(harness.getLastCreateQueryInput()), | |
| 842 | ); | |
| 843 | assert.deepEqual(promptMessage?.message.content, [ | |
| 844 | { type: "text", text: "ok, now" }, | |
| 845 | { type: "text", text: "/implement all the tickets\nstart with auth" }, | |
| 846 | ]); | |
| 847 | }).pipe( | |
| 848 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 849 | Effect.provide(harness.layer), | |
| 850 | ); | |
| 851 | }); | |
| 852 | ||
| 853 | it.effect("keeps the skill command block after image attachments", () => { | |
| 854 | // A command block followed by an image is not expanded by the CLI; the | |
| 855 | // image must come first. | |
| 856 | const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skill-image-")); | |
| 857 | const homeDir = NodePath.join(baseDir, "claude-home"); | |
| 858 | NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "review"), { recursive: true }); | |
| 859 | NodeFS.writeFileSync( | |
| 860 | NodePath.join(homeDir, "skills", "review", "SKILL.md"), | |
| 861 | "---\ndescription: Review.\n---\n# Body\n", | |
| 862 | ); | |
| 863 | const harness = makeHarness({ baseDir, claudeConfig: { homePath: homeDir } }); | |
| 864 | return Effect.gen(function* () { | |
| 865 | yield* Effect.addFinalizer(() => | |
| 866 | Effect.sync(() => NodeFS.rmSync(baseDir, { recursive: true, force: true })), | |
| 867 | ); | |
| 868 | const adapter = yield* ClaudeAdapter; | |
| 869 | const { attachmentsDir } = yield* ServerConfig; | |
| 870 | const attachment = { | |
| 871 | type: "image" as const, | |
| 872 | id: "thread-claude-attachment-12345678-1234-1234-1234-123456789abc", | |
| 873 | name: "diagram.png", | |
| 874 | mimeType: "image/png", | |
| 875 | sizeBytes: 4, | |
| 876 | }; | |
| 877 | const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); | |
| 878 | NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); | |
| 879 | NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); | |
| 880 | ||
| 881 | const session = yield* adapter.startSession({ | |
| 882 | threadId: THREAD_ID, | |
| 883 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 884 | runtimeMode: "full-access", | |
| 885 | }); | |
| 886 | yield* adapter.sendTurn({ | |
| 887 | threadId: session.threadId, | |
| 888 | input: "$review this screenshot", | |
| 889 | attachments: [attachment], | |
| 890 | }); | |
| 891 | ||
| 892 | const promptMessage = yield* Effect.promise(() => | |
| 893 | readFirstPromptMessage(harness.getLastCreateQueryInput()), | |
| 894 | ); | |
| 895 | assert.isDefined(promptMessage); | |
| 896 | const blocks = promptMessage.message.content as Array<{ type: string; text?: string }>; | |
| 897 | assert.deepEqual( | |
| 898 | blocks.map((block) => (block.type === "text" ? block.text : block.type)), | |
| 899 | ["image", "/review this screenshot"], | |
| 900 | ); | |
| 901 | }).pipe( | |
| 902 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 903 | Effect.provide(harness.layer), | |
| 904 | ); | |
| 905 | }); | |
| 906 | ||
| 907 | it.effect("leaves a $ mention of an unknown or disabled skill as prose", () => { | |
| 908 | const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-off-")); | |
| 909 | NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "deploy"), { recursive: true }); | |
| 910 | NodeFS.writeFileSync( | |
| 911 | NodePath.join(homeDir, "skills", "deploy", "SKILL.md"), | |
| 912 | "---\ndescription: Deploy.\n---\n# Body\n", | |
| 913 | ); | |
| 914 | NodeFS.writeFileSync( | |
| 915 | NodePath.join(homeDir, "settings.json"), | |
| 916 | JSON.stringify({ skillOverrides: { deploy: "off" } }), | |
| 917 | ); | |
| 918 | const harness = makeHarness({ claudeConfig: { homePath: homeDir } }); | |
| 919 | return Effect.gen(function* () { | |
| 920 | yield* Effect.addFinalizer(() => | |
| 921 | Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })), | |
| 922 | ); | |
| 923 | const adapter = yield* ClaudeAdapter; | |
| 924 | const session = yield* adapter.startSession({ | |
| 925 | threadId: THREAD_ID, | |
| 926 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 927 | runtimeMode: "full-access", | |
| 928 | }); | |
| 929 | yield* adapter.sendTurn({ | |
| 930 | threadId: session.threadId, | |
| 931 | input: "run $deploy and echo $HOME", | |
| 932 | attachments: [], | |
| 933 | }); | |
| 934 | ||
| 935 | const promptText = yield* Effect.promise(() => | |
| 936 | readFirstPromptText(harness.getLastCreateQueryInput()), | |
| 937 | ); | |
| 938 | assert.equal(promptText, "run $deploy and echo $HOME"); | |
| 939 | }).pipe( | |
| 940 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 941 | Effect.provide(harness.layer), | |
| 942 | ); | |
| 943 | }); | |
| 944 | ||
| 813 | 945 | it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { |
| 814 | 946 | const harness = makeHarness(); |
| 815 | 947 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+38-1| ... | ... | @@ -79,6 +79,8 @@ import { ServerConfig } from "../../config.ts"; |
| 79 | 79 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 80 | 80 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; |
| 81 | 81 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 82 | import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; | |
| 83 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; | |
| 82 | 84 | import { |
| 83 | 85 | BUNDLED_CLAUDE_MODEL_CATALOG, |
| 84 | 86 | type ClaudeModelCatalog, |
| ... | ... | @@ -1276,12 +1278,23 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( |
| 1276 | 1278 | readonly attachmentsDir: string; |
| 1277 | 1279 | readonly boundInstanceId: ProviderInstanceId; |
| 1278 | 1280 | readonly modelCatalog: ClaudeModelCatalog; |
| 1281 | /** Names of the skills Claude Code can run for this session's cwd. */ | |
| 1282 | readonly skillNames: ReadonlySet<string>; | |
| 1279 | 1283 | }, |
| 1280 | 1284 | ) { |
| 1281 | 1285 | const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog); |
| 1282 | 1286 | const sdkContent: Array<Record<string, unknown>> = []; |
| 1283 | 1287 | |
| 1284 | if (text.length > 0) { | |
| 1288 | // Claude Code expands a skill only from the LAST text block, and only when | |
| 1289 | // `/name` is its first character. A `$skill` chip anywhere in the prompt is | |
| 1290 | // therefore split into [leading text, "/name trailing text"] so the CLI | |
| 1291 | // runs it natively and the prose around it survives. See ClaudeSkillDispatch. | |
| 1292 | const dispatch = planClaudeSkillDispatch(text, dependencies.skillNames); | |
| 1293 | if (dispatch) { | |
| 1294 | if (dispatch.leadingText !== undefined) { | |
| 1295 | sdkContent.push({ type: "text", text: dispatch.leadingText }); | |
| 1296 | } | |
| 1297 | } else if (text.length > 0) { | |
| 1285 | 1298 | sdkContent.push({ type: "text", text }); |
| 1286 | 1299 | } |
| 1287 | 1300 | |
| ... | ... | @@ -1332,6 +1345,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( |
| 1332 | 1345 | ); |
| 1333 | 1346 | } |
| 1334 | 1347 | |
| 1348 | // Images go before the command block: a text block after them still | |
| 1349 | // expands, a command block followed by an image does not. | |
| 1350 | if (dispatch) { | |
| 1351 | sdkContent.push({ type: "text", text: dispatch.commandText }); | |
| 1352 | } | |
| 1353 | ||
| 1335 | 1354 | return buildUserMessage({ sdkContent }); |
| 1336 | 1355 | }); |
| 1337 | 1356 | |
| ... | ... | @@ -4602,11 +4621,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4602 | 4621 | }); |
| 4603 | 4622 | } |
| 4604 | 4623 | |
| 4624 | // Re-scan on every send: skills are added and switched off mid-session, | |
| 4625 | // and the scan is a few directory reads. A skill switched off via | |
| 4626 | // skillOverrides, or reserved for the agent with `user-invocable: false`, | |
| 4627 | // is left as prose: the CLI would answer `/name` with a notice instead of | |
| 4628 | // running it. | |
| 4629 | const skills = yield* discoverClaudeSkills( | |
| 4630 | claudeSettings, | |
| 4631 | context.session.cwd, | |
| 4632 | claudeEnvironment, | |
| 4633 | ).pipe( | |
| 4634 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 4635 | Effect.provideService(Path.Path, path), | |
| 4636 | ); | |
| 4605 | 4637 | const message = yield* buildUserMessageEffect(input, { |
| 4606 | 4638 | fileSystem, |
| 4607 | 4639 | attachmentsDir: serverConfig.attachmentsDir, |
| 4608 | 4640 | boundInstanceId, |
| 4609 | 4641 | modelCatalog, |
| 4642 | skillNames: new Set( | |
| 4643 | skills | |
| 4644 | .filter((skill) => skill.enabled && skill.userInvocable !== false) | |
| 4645 | .map((skill) => skill.name), | |
| 4646 | ), | |
| 4610 | 4647 | }); |
| 4611 | 4648 | |
| 4612 | 4649 | yield* Queue.offer(context.promptQueue, { |
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+11-1| ... | ... | @@ -177,7 +177,7 @@ describe("searchSlashCommandItems", () => { |
| 177 | 177 | ]); |
| 178 | 178 | }); |
| 179 | 179 | |
| 180 | it("hides skills from slash completion after the first message line", () => { | |
| 180 | it("hides provider commands from slash completion after the first message line", () => { | |
| 181 | 181 | const items = [ |
| 182 | 182 | { |
| 183 | 183 | id: "slash:model", |
| ... | ... | @@ -186,6 +186,14 @@ describe("searchSlashCommandItems", () => { |
| 186 | 186 | label: "/model", |
| 187 | 187 | description: "Switch model", |
| 188 | 188 | }, |
| 189 | { | |
| 190 | id: "provider-slash-command:claudeAgent:compact", | |
| 191 | type: "provider-slash-command", | |
| 192 | provider: claudeDriver, | |
| 193 | command: { name: "compact" }, | |
| 194 | label: "/compact", | |
| 195 | description: "Compact the conversation", | |
| 196 | }, | |
| 189 | 197 | { |
| 190 | 198 | id: "skill:claudeAgent:unslop", |
| 191 | 199 | type: "skill", |
| ... | ... | @@ -204,9 +212,11 @@ describe("searchSlashCommandItems", () => { |
| 204 | 212 | |
| 205 | 213 | expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([ |
| 206 | 214 | "slash:model", |
| 215 | "skill:claudeAgent:unslop", | |
| 207 | 216 | ]); |
| 208 | 217 | expect(slashCommandItemsForPromptPosition(items, true).map((item) => item.id)).toEqual([ |
| 209 | 218 | "slash:model", |
| 219 | "provider-slash-command:claudeAgent:compact", | |
| 210 | 220 | "skill:claudeAgent:unslop", |
| 211 | 221 | ]); |
| 212 | 222 | }); |
apps/web/src/components/chat/composerSlashCommandSearch.ts+7-1| ... | ... | @@ -12,6 +12,12 @@ type SlashSearchItem = Extract< |
| 12 | 12 | { type: "slash-command" | "provider-slash-command" | "skill" } |
| 13 | 13 | >; |
| 14 | 14 | |
| 15 | /** | |
| 16 | * A provider expands a slash command only when it opens the whole message; | |
| 17 | * anywhere else it reaches the agent as literal text, so it is not offered | |
| 18 | * there. Built-ins apply locally on selection and skills insert a `$` mention | |
| 19 | * the server dispatches from any position, so both stay available. | |
| 20 | */ | |
| 15 | 21 | export function slashCommandItemsForPromptPosition( |
| 16 | 22 | items: ReadonlyArray<SlashSearchItem>, |
| 17 | 23 | isAtPromptStart: boolean, |
| ... | ... | @@ -19,7 +25,7 @@ export function slashCommandItemsForPromptPosition( |
| 19 | 25 | if (isAtPromptStart) { |
| 20 | 26 | return [...items]; |
| 21 | 27 | } |
| 22 | return items.filter((item) => item.type !== "skill"); | |
| 28 | return items.filter((item) => item.type !== "provider-slash-command"); | |
| 23 | 29 | } |
| 24 | 30 | |
| 25 | 31 | function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { |
apps/web/src/providerSkillSearch.test.ts+13| ... | ... | @@ -48,6 +48,19 @@ describe("searchProviderSkills", () => { |
| 48 | 48 | expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]); |
| 49 | 49 | }); |
| 50 | 50 | |
| 51 | it("keeps user-only skills and omits agent-only ones", () => { | |
| 52 | const skills = [ | |
| 53 | makeSkill({ name: "re-release-version", userInvocationOnly: true }), | |
| 54 | makeSkill({ name: "release-context", userInvocable: false }), | |
| 55 | makeSkill({ name: "release-version" }), | |
| 56 | ]; | |
| 57 | ||
| 58 | expect(searchProviderSkills(skills, "release").map((skill) => skill.name)).toEqual([ | |
| 59 | "release-version", | |
| 60 | "re-release-version", | |
| 61 | ]); | |
| 62 | }); | |
| 63 | ||
| 51 | 64 | it("omits disabled skills from results", () => { |
| 52 | 65 | const skills = [ |
| 53 | 66 | makeSkill({ name: "ui", displayName: "Ui", enabled: false }), |
apps/web/src/providerSkillSearch.ts+2-1| ... | ... | @@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; |
| 2 | 2 | import { |
| 3 | 3 | dedupeProviderSkillsByName, |
| 4 | 4 | formatProviderSkillDisplayName, |
| 5 | isProviderSkillUserInvocable, | |
| 5 | 6 | } from "@t3tools/client-runtime/providerSkills"; |
| 6 | 7 | import { |
| 7 | 8 | insertRankedSearchResult, |
| ... | ... | @@ -73,7 +74,7 @@ export function searchProviderSkills( |
| 73 | 74 | query: string, |
| 74 | 75 | limit = Number.POSITIVE_INFINITY, |
| 75 | 76 | ): ServerProviderSkill[] { |
| 76 | const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)); | |
| 77 | const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)); | |
| 77 | 78 | const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); |
| 78 | 79 | |
| 79 | 80 | if (!normalizedQuery) { |
docs/user/composer.md+9| ... | ... | @@ -150,6 +150,15 @@ same `$name` skill token to your message. The original skill name remains search |
| 150 | 150 | also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps |
| 151 | 151 | the `/skill:Skill Name` label. |
| 152 | 152 | |
| 153 | A skill token runs the skill wherever it sits in your message. T3 Code sends it to each provider in | |
| 154 | the form that provider runs, so the text before and after the token is kept. Skills that only you may | |
| 155 | start, and never the agent on its own, work the same way. A skill you switched off in the provider's | |
| 156 | settings does not appear in either menu. | |
| 157 | ||
| 158 | Provider commands such as `/compact` only run when they open the message, so the `/` menu offers | |
| 159 | them only there. T3 Code's own commands, such as `/model` and `/plan`, and skills stay available on | |
| 160 | any line. | |
| 161 | ||
| 153 | 162 | On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to |
| 154 | 163 | start it in the background. T3 Code opens another new thread and shows an **Open** action for the |
| 155 | 164 | thread that started. The new thread keeps the selected workspace mode and base branch. If **New |
docs/user/providers-claude.md+10-3| ... | ... | @@ -48,10 +48,17 @@ Claude can show its own resume prompt when you continue an old session. |
| 48 | 48 | |
| 49 | 49 | ## Where Claude Skills Are Loaded |
| 50 | 50 | |
| 51 | T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then | |
| 52 | `<workspace>/.agents/skills`, then `<workspace>/.claude/skills`. | |
| 51 | T3 Code looks for Claude skills in the Claude config directory's `skills` folder and | |
| 52 | `<workspace>/.claude/skills`, the two places Claude Code loads them from. | |
| 53 | 53 | |
| 54 | If the same skill name exists in more than one folder, the later folder wins. | |
| 54 | If the same skill name exists in more than one folder, the one in the Claude config directory | |
| 55 | wins, the same way Claude Code resolves it. | |
| 56 | ||
| 57 | A skill set to `off` in Claude Code's `skillOverrides` is left out of both composer menus. A skill | |
| 58 | marked `disable-model-invocation` still appears, because you start it yourself when you pick it. | |
| 59 | Claude Code runs one skill per message; when a message names several, the last one runs directly and | |
| 60 | Claude starts the others through its Skill tool, which refuses skills marked | |
| 61 | `disable-model-invocation`. | |
| 55 | 62 | |
| 56 | 63 | ## I Want Work And Personal Claude Accounts |
| 57 | 64 |
packages/client-runtime/src/providerSkills.test.ts+24| ... | ... | @@ -108,6 +108,30 @@ describe("getProviderSkillsForSlashMenu", () => { |
| 108 | 108 | }); |
| 109 | 109 | }); |
| 110 | 110 | |
| 111 | describe("getProviderSkillsForSlashMenu", () => { | |
| 112 | it("drops a skill the provider reserves for the agent", () => { | |
| 113 | const skills = [ | |
| 114 | { | |
| 115 | name: "legacy-system-context", | |
| 116 | path: "/Users/matt/.claude/skills/legacy-system-context/SKILL.md", | |
| 117 | enabled: true, | |
| 118 | userInvocable: false, | |
| 119 | }, | |
| 120 | { | |
| 121 | name: "deploy", | |
| 122 | path: "/Users/matt/.claude/skills/deploy/SKILL.md", | |
| 123 | enabled: true, | |
| 124 | // Reserved for the user, not the agent: still a valid pick. | |
| 125 | userInvocationOnly: true, | |
| 126 | }, | |
| 127 | ]; | |
| 128 | ||
| 129 | expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([ | |
| 130 | "deploy", | |
| 131 | ]); | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 111 | 135 | describe("getProviderSlashCommandsForSlashMenu", () => { |
| 112 | 136 | const commands = [ |
| 113 | 137 | { name: "ask-matt", description: "Ask which skill fits your situation." }, |
packages/client-runtime/src/providerSkills.ts+14-1| ... | ... | @@ -39,12 +39,25 @@ export function dedupeProviderSkillsByName( |
| 39 | 39 | }); |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | /** | |
| 43 | * Whether a composer pick can start this skill. A skill switched off in the | |
| 44 | * provider's settings will not run, and one the provider reserves for the | |
| 45 | * agent (Claude Code's `user-invocable: false`) rejects a user invocation. | |
| 46 | * Everything else, including skills the agent may not start on its own, is | |
| 47 | * fair game: the server dispatches the pick in the provider's native form. | |
| 48 | */ | |
| 49 | export function isProviderSkillUserInvocable( | |
| 50 | skill: Pick<ServerProviderSkill, "enabled" | "userInvocable">, | |
| 51 | ): boolean { | |
| 52 | return skill.enabled && skill.userInvocable !== false; | |
| 53 | } | |
| 54 | ||
| 42 | 55 | export function getProviderSkillsForSlashMenu( |
| 43 | 56 | skills: ReadonlyArray<ServerProviderSkill>, |
| 44 | 57 | showSkillsInSlashMenu: boolean, |
| 45 | 58 | ): ServerProviderSkill[] { |
| 46 | 59 | return showSkillsInSlashMenu |
| 47 | ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)) | |
| 60 | ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)) | |
| 48 | 61 | : []; |
| 49 | 62 | } |
| 50 | 63 |
packages/contracts/src/server.ts+12| ... | ... | @@ -95,6 +95,18 @@ export const ServerProviderSkill = Schema.Struct({ |
| 95 | 95 | enabled: Schema.Boolean, |
| 96 | 96 | displayName: Schema.optional(TrimmedNonEmptyString), |
| 97 | 97 | shortDescription: Schema.optional(TrimmedNonEmptyString), |
| 98 | /** | |
| 99 | * The skill is hidden from the agent's own skill tool, so only the user can | |
| 100 | * start it — Claude Code's `disable-model-invocation`. Composers must offer | |
| 101 | * it as a slash command; naming it in prose does nothing. | |
| 102 | */ | |
| 103 | userInvocationOnly: Schema.optional(Schema.Boolean), | |
| 104 | /** | |
| 105 | * The mirror of {@link ServerProviderSkill.userInvocationOnly}: Claude Code's | |
| 106 | * `user-invocable: false` keeps the skill out of its own slash commands, so | |
| 107 | * only the agent can start it. Composers must not offer it under `/`. | |
| 108 | */ | |
| 109 | userInvocable: Schema.optional(Schema.Boolean), | |
| 98 | 110 | }); |
| 99 | 111 | export type ServerProviderSkill = typeof ServerProviderSkill.Type; |
| 100 | 112 |