authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 17:44:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 17:44:13-07:00
logea71a19d4181ce13b0e8f374f45ba9237ee4a9ce
treec3869594ffe41097fdf07b0dc5fe658be94578a8
parent9a7b1e21e51609266adf657bcab0b43b6bcd445c
signature Signed by PGP key B5690EEEBB952194

fix(claude): skills picked from the composer now run (#9128)

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 {
1212import {
1313 dedupeProviderSkillsByName,
1414 getProviderSkillsForSlashMenu,
15 isProviderSkillUserInvocable,
1516} from "@t3tools/client-runtime/providerSkills";
1617import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1718
......@@ -114,8 +115,14 @@ export function useComposerCommandMenu({
114115 (item.command === "model" || onUpdateInteractionMode !== undefined),
115116 );
116117
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.
117122 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) {
119126 if (!command.name.toLowerCase().includes(q)) continue;
120127 // Codex feedback uploads an existing thread's session and logs.
121128 if (
......@@ -149,7 +156,7 @@ export function useComposerCommandMenu({
149156
150157 if (trigger.kind === "skill") {
151158 const enabledSkills = dedupeProviderSkillsByName(
152 (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled),
159 (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable),
153160 );
154161 const normalizedQuery = normalizeSearchQuery(trigger.query, {
155162 trimLeadingPattern: /^\$+/,
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts created+41
......@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
4
5const SKILLS = new Set(["implement", "review", "re-release-version"]);
6
7describe("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 */
32const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
33
34export 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 */
48export 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";
44import * as FileSystem from "effect/FileSystem";
55import * as Path from "effect/Path";
66
7import { discoverClaudeSkills } from "./ClaudeSkills.ts";
7import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts";
88
99const writeSkill = Effect.fn(function* (
1010 skillsDir: string,
......@@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
6666 }),
6767 );
6868
69 it.effect("discovers project skills from the workspace .agents directory", () =>
69 it.effect("ignores .agents/skills, which Claude Code does not load", () =>
7070 Effect.gen(function* () {
7171 const fs = yield* FileSystem.FileSystem;
7272 const path = yield* Path.Path;
......@@ -74,6 +74,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
7474 const configDir = path.join(tempDir, "claude-home");
7575 const workspace = path.join(tempDir, "workspace");
7676
77 // Verified against the CLI: `/review` here is answered with
78 // `Unknown command`, so offering it would dispatch a dead command.
7779 yield* writeSkill(
7880 path.join(workspace, ".agents", "skills"),
7981 "review",
......@@ -82,19 +84,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
8284
8385 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
8486
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, []);
9488 }),
9589 );
9690
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", () =>
9892 Effect.gen(function* () {
9993 const fs = yield* FileSystem.FileSystem;
10094 const path = yield* Path.Path;
......@@ -123,49 +117,16 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
123117 assert.deepEqual(skills, [
124118 {
125119 name: "deploy",
126 path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"),
120 path: path.join(configDir, "skills", "deploy", "SKILL.md"),
127121 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.",
163124 },
164125 ]);
165126 }),
166127 );
167128
168 it.effect("prefers project skills over user skills on name collisions", () =>
129 it.effect("prefers user skills over project skills on name collisions", () =>
169130 Effect.gen(function* () {
170131 const fs = yield* FileSystem.FileSystem;
171132 const path = yield* Path.Path;
......@@ -187,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
187148 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
188149
189150 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.");
192153 }),
193154 );
194155
......@@ -287,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
287248 }),
288249 );
289250
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
290669 it.effect("returns an empty list when no skill roots exist", () =>
291670 Effect.gen(function* () {
292671 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/Drivers/ClaudeSkills.ts+250-21
......@@ -1,10 +1,12 @@
11/**
22 * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.
33 *
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.
810 * The Agent SDK init handshake surfaces skills only as slash commands without
911 * their filesystem paths, so the provider snapshot scans the same locations
1012 * directly, mirroring how the Codex app-server reports its skills.
......@@ -17,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
1719import * as Effect from "effect/Effect";
1820import * as FileSystem from "effect/FileSystem";
1921import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
24import { fromLenientJson } from "@t3tools/shared/schemaJson";
2025import { parse as parseYamlDocument } from "yaml";
2126
2227import { expandHomePath } from "../../pathExpansion.ts";
......@@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
2833type SkillFrontmatter =
2934 | { readonly kind: "missing" }
3035 | { 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 */
50function 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}
3271
3372function parseSkillFrontmatter(contents: string): SkillFrontmatter {
3473 const match = FRONTMATTER_PATTERN.exec(contents);
......@@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter {
4786 }
4887
4988 const record = parsed as Record<string, unknown>;
50 const name = typeof record.name === "string" ? record.name.trim() : "";
5189 const description = typeof record.description === "string" ? record.description.trim() : "";
5290 return {
5391 kind: "parsed",
54 ...(name ? { name } : {}),
5592 ...(description ? { description } : {}),
93 ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
94 ? { userInvocationOnly: true }
95 : {}),
96 ...(parseFrontmatterBoolean(record["user-invocable"]) === false
97 ? { userInvocable: false }
98 : {}),
5699 };
57100}
58101
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 */
107export 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 */
134export 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 */
162const 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 */
189const 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.
193const SkillOverrideSettings = fromLenientJson(
194 Schema.Struct({
195 skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)),
196 }),
197);
198const 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 */
205type SkillOverride = {
206 readonly enabled: boolean;
207 readonly userInvocationOnly: boolean;
208};
209
210function 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
222const 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
59270/**
60271 * Resolve the Claude config directory the CLI would use, matching the
61272 * precedence the spawned CLI sees: the instance's `homePath` (exported as
......@@ -85,12 +296,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct
85296});
86297
87298/**
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.
94307 */
95308export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
96309 config: Pick<ClaudeSettings, "homePath">,
......@@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
100313 const fileSystem = yield* FileSystem.FileSystem;
101314 const path = yield* Path.Path;
102315 const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
316 const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env);
103317
104318 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
105319 { 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 }] : []),
112321 ];
113322
114323 const skillsByName = new Map<string, ServerProviderSkill>();
......@@ -134,19 +343,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
134343 continue;
135344 }
136345
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();
138353 if (!name) {
139354 continue;
140355 }
141356
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;
142367 skillsByName.set(name, {
143368 name,
144369 path: skillPath,
145 enabled: true,
370 enabled: override?.enabled ?? true,
146371 scope: root.scope,
147372 ...(frontmatter.kind === "parsed" && frontmatter.description
148373 ? { description: frontmatter.description }
149374 : {}),
375 ...(userInvocationOnly ? { userInvocationOnly: true } : {}),
376 ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false
377 ? { userInvocable: false }
378 : {}),
150379 });
151380 }
152381 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+132
......@@ -810,6 +810,138 @@ describe("ClaudeAdapterLive", () => {
810810 );
811811 });
812812
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
813945 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
814946 const harness = makeHarness();
815947 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+38-1
......@@ -79,6 +79,8 @@ import { ServerConfig } from "../../config.ts";
7979import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
8080import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
8181import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
82import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
83import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
8284import {
8385 BUNDLED_CLAUDE_MODEL_CATALOG,
8486 type ClaudeModelCatalog,
......@@ -1276,12 +1278,23 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
12761278 readonly attachmentsDir: string;
12771279 readonly boundInstanceId: ProviderInstanceId;
12781280 readonly modelCatalog: ClaudeModelCatalog;
1281 /** Names of the skills Claude Code can run for this session's cwd. */
1282 readonly skillNames: ReadonlySet<string>;
12791283 },
12801284) {
12811285 const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog);
12821286 const sdkContent: Array<Record<string, unknown>> = [];
12831287
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) {
12851298 sdkContent.push({ type: "text", text });
12861299 }
12871300
......@@ -1332,6 +1345,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
13321345 );
13331346 }
13341347
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
13351354 return buildUserMessage({ sdkContent });
13361355});
13371356
......@@ -4602,11 +4621,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
46024621 });
46034622 }
46044623
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 );
46054637 const message = yield* buildUserMessageEffect(input, {
46064638 fileSystem,
46074639 attachmentsDir: serverConfig.attachmentsDir,
46084640 boundInstanceId,
46094641 modelCatalog,
4642 skillNames: new Set(
4643 skills
4644 .filter((skill) => skill.enabled && skill.userInvocable !== false)
4645 .map((skill) => skill.name),
4646 ),
46104647 });
46114648
46124649 yield* Queue.offer(context.promptQueue, {
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+11-1
......@@ -177,7 +177,7 @@ describe("searchSlashCommandItems", () => {
177177 ]);
178178 });
179179
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", () => {
181181 const items = [
182182 {
183183 id: "slash:model",
......@@ -186,6 +186,14 @@ describe("searchSlashCommandItems", () => {
186186 label: "/model",
187187 description: "Switch model",
188188 },
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 },
189197 {
190198 id: "skill:claudeAgent:unslop",
191199 type: "skill",
......@@ -204,9 +212,11 @@ describe("searchSlashCommandItems", () => {
204212
205213 expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([
206214 "slash:model",
215 "skill:claudeAgent:unslop",
207216 ]);
208217 expect(slashCommandItemsForPromptPosition(items, true).map((item) => item.id)).toEqual([
209218 "slash:model",
219 "provider-slash-command:claudeAgent:compact",
210220 "skill:claudeAgent:unslop",
211221 ]);
212222 });
apps/web/src/components/chat/composerSlashCommandSearch.ts+7-1
......@@ -12,6 +12,12 @@ type SlashSearchItem = Extract<
1212 { type: "slash-command" | "provider-slash-command" | "skill" }
1313>;
1414
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 */
1521export function slashCommandItemsForPromptPosition(
1622 items: ReadonlyArray<SlashSearchItem>,
1723 isAtPromptStart: boolean,
......@@ -19,7 +25,7 @@ export function slashCommandItemsForPromptPosition(
1925 if (isAtPromptStart) {
2026 return [...items];
2127 }
22 return items.filter((item) => item.type !== "skill");
28 return items.filter((item) => item.type !== "provider-slash-command");
2329}
2430
2531function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {
apps/web/src/providerSkillSearch.test.ts+13
......@@ -48,6 +48,19 @@ describe("searchProviderSkills", () => {
4848 expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]);
4949 });
5050
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
5164 it("omits disabled skills from results", () => {
5265 const skills = [
5366 makeSkill({ name: "ui", displayName: "Ui", enabled: false }),
apps/web/src/providerSkillSearch.ts+2-1
......@@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts";
22import {
33 dedupeProviderSkillsByName,
44 formatProviderSkillDisplayName,
5 isProviderSkillUserInvocable,
56} from "@t3tools/client-runtime/providerSkills";
67import {
78 insertRankedSearchResult,
......@@ -73,7 +74,7 @@ export function searchProviderSkills(
7374 query: string,
7475 limit = Number.POSITIVE_INFINITY,
7576): ServerProviderSkill[] {
76 const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled));
77 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
7778 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });
7879
7980 if (!normalizedQuery) {
docs/user/composer.md+9
......@@ -150,6 +150,15 @@ same `$name` skill token to your message. The original skill name remains search
150150also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps
151151the `/skill:Skill Name` label.
152152
153A skill token runs the skill wherever it sits in your message. T3 Code sends it to each provider in
154the form that provider runs, so the text before and after the token is kept. Skills that only you may
155start, and never the agent on its own, work the same way. A skill you switched off in the provider's
156settings does not appear in either menu.
157
158Provider commands such as `/compact` only run when they open the message, so the `/` menu offers
159them only there. T3 Code's own commands, such as `/model` and `/plan`, and skills stay available on
160any line.
161
153162On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to
154163start it in the background. T3 Code opens another new thread and shows an **Open** action for the
155164thread 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.
4848
4949## Where Claude Skills Are Loaded
5050
51T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then
52`<workspace>/.agents/skills`, then `<workspace>/.claude/skills`.
51T3 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.
5353
54If the same skill name exists in more than one folder, the later folder wins.
54If the same skill name exists in more than one folder, the one in the Claude config directory
55wins, the same way Claude Code resolves it.
56
57A skill set to `off` in Claude Code's `skillOverrides` is left out of both composer menus. A skill
58marked `disable-model-invocation` still appears, because you start it yourself when you pick it.
59Claude Code runs one skill per message; when a message names several, the last one runs directly and
60Claude starts the others through its Skill tool, which refuses skills marked
61`disable-model-invocation`.
5562
5663## I Want Work And Personal Claude Accounts
5764
packages/client-runtime/src/providerSkills.test.ts+24
......@@ -108,6 +108,30 @@ describe("getProviderSkillsForSlashMenu", () => {
108108 });
109109});
110110
111describe("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
111135describe("getProviderSlashCommandsForSlashMenu", () => {
112136 const commands = [
113137 { 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(
3939 });
4040}
4141
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 */
49export function isProviderSkillUserInvocable(
50 skill: Pick<ServerProviderSkill, "enabled" | "userInvocable">,
51): boolean {
52 return skill.enabled && skill.userInvocable !== false;
53}
54
4255export function getProviderSkillsForSlashMenu(
4356 skills: ReadonlyArray<ServerProviderSkill>,
4457 showSkillsInSlashMenu: boolean,
4558): ServerProviderSkill[] {
4659 return showSkillsInSlashMenu
47 ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled))
60 ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable))
4861 : [];
4962}
5063
packages/contracts/src/server.ts+12
......@@ -95,6 +95,18 @@ export const ServerProviderSkill = Schema.Struct({
9595 enabled: Schema.Boolean,
9696 displayName: Schema.optional(TrimmedNonEmptyString),
9797 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),
98110});
99111export type ServerProviderSkill = typeof ServerProviderSkill.Type;
100112