Unified · Split
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 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({ · 96 unmodified lines | ||
| 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({ · 27 unmodified lines | ||
| 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: /^\$+/, |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines | ||
| 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) => { · 55 unmodified lines | ||
| 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) => { · 1 unmodified line | ||
| 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) => { · 2 unmodified lines | ||
| 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) => { · 22 unmodified lines | ||
| 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) => { · 15 unmodified lines | ||
| 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) => { · 92 unmodified lines | ||
| 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; |
| @@ -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"; · 6 unmodified lines | ||
| 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|$)/; · 5 unmodified lines | ||
| 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 { · 12 unmodified lines | ||
| 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 · 23 unmodified lines | ||
| 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* · 3 unmodified lines | ||
| 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* · 19 unmodified lines | ||
| 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 | } |
| @@ -810,6 +810,138 @@ describe("ClaudeAdapterLive", () => { · 809 unmodified lines | ||
| 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* () { |
| @@ -79,6 +79,8 @@ import { ServerConfig } from "../../config.ts"; · 78 unmodified lines | ||
| 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* ( · 1191 unmodified lines | ||
| 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* ( · 44 unmodified lines | ||
| 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* ( · 3264 unmodified lines | ||
| 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, { |
| @@ -177,7 +177,7 @@ describe("searchSlashCommandItems", () => { · 176 unmodified lines | ||
| 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", () => { · 2 unmodified lines | ||
| 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", () => { · 12 unmodified lines | ||
| 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 | }); |
| @@ -12,6 +12,12 @@ type SlashSearchItem = Extract< · 11 unmodified lines | ||
| 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( · 1 unmodified line | ||
| 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 { |
| @@ -48,6 +48,19 @@ describe("searchProviderSkills", () => { · 47 unmodified lines | ||
| 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 }), |
| @@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; · 1 unmodified line | ||
| 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( · 65 unmodified lines | ||
| 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) { |
| @@ -150,6 +150,15 @@ same `$name` skill token to your message. The original skill name remains search · 149 unmodified lines | ||
| 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 |
| @@ -48,10 +48,17 @@ Claude can show its own resume prompt when you continue an old session. · 47 unmodified lines | ||
| 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 | |
| @@ -108,6 +108,30 @@ describe("getProviderSkillsForSlashMenu", () => { · 107 unmodified lines | ||
| 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." }, |
| @@ -39,12 +39,25 @@ export function dedupeProviderSkillsByName( · 38 unmodified lines | ||
| 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 | |
| @@ -95,6 +95,18 @@ export const ServerProviderSkill = Schema.Struct({ · 94 unmodified lines | ||
| 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 | |