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 {...@@ -12,6 +12,7 @@ import {
12import {12import {
13 dedupeProviderSkillsByName,13 dedupeProviderSkillsByName,
14 getProviderSkillsForSlashMenu,14 getProviderSkillsForSlashMenu,
15 isProviderSkillUserInvocable,
15} from "@t3tools/client-runtime/providerSkills";16} from "@t3tools/client-runtime/providerSkills";
16import { useCallback, useEffect, useMemo, useRef, useState } from "react";17import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1718
...@@ -114,8 +115,14 @@ export function useComposerCommandMenu({...@@ -114,8 +115,14 @@ export function useComposerCommandMenu({
114 (item.command === "model" || onUpdateInteractionMode !== undefined),115 (item.command === "model" || onUpdateInteractionMode !== undefined),
115 );116 );
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.
117 const providerCommands: ComposerCommandItem[] = [];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 if (!command.name.toLowerCase().includes(q)) continue;126 if (!command.name.toLowerCase().includes(q)) continue;
120 // Codex feedback uploads an existing thread's session and logs.127 // Codex feedback uploads an existing thread's session and logs.
121 if (128 if (
...@@ -149,7 +156,7 @@ export function useComposerCommandMenu({...@@ -149,7 +156,7 @@ export function useComposerCommandMenu({
149156
150 if (trigger.kind === "skill") {157 if (trigger.kind === "skill") {
151 const enabledSkills = dedupeProviderSkillsByName(158 const enabledSkills = dedupeProviderSkillsByName(
152 (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled),159 (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable),
153 );160 );
154 const normalizedQuery = normalizeSearchQuery(trigger.query, {161 const normalizedQuery = normalizeSearchQuery(trigger.query, {
155 trimLeadingPattern: /^\$+/,162 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";...@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
66
7import { discoverClaudeSkills } from "./ClaudeSkills.ts";7import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts";
88
9const writeSkill = Effect.fn(function* (9const writeSkill = Effect.fn(function* (
10 skillsDir: string,10 skillsDir: string,
...@@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {...@@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
66 }),66 }),
67 );67 );
6868
69 it.effect("discovers project skills from the workspace .agents directory", () =>69 it.effect("ignores .agents/skills, which Claude Code does not load", () =>
70 Effect.gen(function* () {70 Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;72 const path = yield* Path.Path;
...@@ -74,6 +74,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {...@@ -74,6 +74,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
74 const configDir = path.join(tempDir, "claude-home");74 const configDir = path.join(tempDir, "claude-home");
75 const workspace = path.join(tempDir, "workspace");75 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.
77 yield* writeSkill(79 yield* writeSkill(
78 path.join(workspace, ".agents", "skills"),80 path.join(workspace, ".agents", "skills"),
79 "review",81 "review",
...@@ -82,19 +84,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {...@@ -82,19 +84,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
8284
83 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);85 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
8486
85 assert.deepEqual(skills, [87 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 ]);
94 }),88 }),
95 );89 );
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", () =>
98 Effect.gen(function* () {92 Effect.gen(function* () {
99 const fs = yield* FileSystem.FileSystem;93 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;94 const path = yield* Path.Path;
...@@ -123,49 +117,16 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {...@@ -123,49 +117,16 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
123 assert.deepEqual(skills, [117 assert.deepEqual(skills, [
124 {118 {
125 name: "deploy",119 name: "deploy",
126 path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"),120 path: path.join(configDir, "skills", "deploy", "SKILL.md"),
127 enabled: true,121 enabled: true,
128 scope: "project",122 scope: "user",
129 description: "Claude deploy.",123 description: "User 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.",
163 },124 },
164 ]);125 ]);
165 }),126 }),
166 );127 );
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", () =>
169 Effect.gen(function* () {130 Effect.gen(function* () {
170 const fs = yield* FileSystem.FileSystem;131 const fs = yield* FileSystem.FileSystem;
171 const path = yield* Path.Path;132 const path = yield* Path.Path;
...@@ -187,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {...@@ -187,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
187 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);148 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
188149
189 assert.equal(skills.length, 1);150 assert.equal(skills.length, 1);
190 assert.equal(skills[0]?.scope, "project");151 assert.equal(skills[0]?.scope, "user");
191 assert.equal(skills[0]?.description, "Project deploy.");152 assert.equal(skills[0]?.description, "User deploy.");
192 }),153 }),
193 );154 );
194155
...@@ -287,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {...@@ -287,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
287 }),248 }),
288 );249 );
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
290 it.effect("returns an empty list when no skill roots exist", () =>669 it.effect("returns an empty list when no skill roots exist", () =>
291 Effect.gen(function* () {670 Effect.gen(function* () {
292 const fs = yield* FileSystem.FileSystem;671 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/Drivers/ClaudeSkills.ts+250-21
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1/**1/**
2 * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.2 * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.
3 *3 *
4 * Claude Code loads skills from `<config dir>/skills` (user scope), then4 * Claude Code loads skills from `<config dir>/skills` (user scope) and
5 * `<cwd>/.agents/skills` and `<cwd>/.claude/skills` (project scope), one5 * `<cwd>/.claude/skills` (project scope), one directory per skill with a
6 * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots6 * `SKILL.md` carrying YAML frontmatter. The user root wins on name collisions,
7 * win on name collisions, so precedence is user, `.agents`, then `.claude`.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 * The Agent SDK init handshake surfaces skills only as slash commands without10 * The Agent SDK init handshake surfaces skills only as slash commands without
9 * their filesystem paths, so the provider snapshot scans the same locations11 * their filesystem paths, so the provider snapshot scans the same locations
10 * directly, mirroring how the Codex app-server reports its skills.12 * directly, mirroring how the Codex app-server reports its skills.
...@@ -17,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";...@@ -17,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
17import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";20import * as FileSystem from "effect/FileSystem";
19import * as Path from "effect/Path";21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
24import { fromLenientJson } from "@t3tools/shared/schemaJson";
20import { parse as parseYamlDocument } from "yaml";25import { parse as parseYamlDocument } from "yaml";
2126
22import { expandHomePath } from "../../pathExpansion.ts";27import { expandHomePath } from "../../pathExpansion.ts";
...@@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;...@@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
28type SkillFrontmatter =33type SkillFrontmatter =
29 | { readonly kind: "missing" }34 | { readonly kind: "missing" }
30 | { readonly kind: "malformed" }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 */
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
33function parseSkillFrontmatter(contents: string): SkillFrontmatter {72function parseSkillFrontmatter(contents: string): SkillFrontmatter {
34 const match = FRONTMATTER_PATTERN.exec(contents);73 const match = FRONTMATTER_PATTERN.exec(contents);
...@@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter {...@@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter {
47 }86 }
4887
49 const record = parsed as Record<string, unknown>;88 const record = parsed as Record<string, unknown>;
50 const name = typeof record.name === "string" ? record.name.trim() : "";
51 const description = typeof record.description === "string" ? record.description.trim() : "";89 const description = typeof record.description === "string" ? record.description.trim() : "";
52 return {90 return {
53 kind: "parsed",91 kind: "parsed",
54 ...(name ? { name } : {}),
55 ...(description ? { description } : {}),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}
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
59/**270/**
60 * Resolve the Claude config directory the CLI would use, matching the271 * Resolve the Claude config directory the CLI would use, matching the
61 * precedence the spawned CLI sees: the instance's `homePath` (exported as272 * precedence the spawned CLI sees: the instance's `homePath` (exported as
...@@ -85,12 +296,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct...@@ -85,12 +296,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct
85});296});
86297
87/**298/**
88 * Enumerate Claude Code skills from the user config dir, workspace299 * Enumerate Claude Code skills from the user config dir and the workspace
89 * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery300 * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed
90 * is best-effort: unreadable roots and malformed skill entries are skipped so301 * skill entries are skipped so a broken skill never degrades the provider
91 * a broken skill never degrades the provider snapshot. On name collisions,302 * snapshot. Roots are listed highest precedence first and the first hit for a
92 * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching303 * name wins, matching Claude Code: verified against the CLI with the same
93 * Claude Code's resolution.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 */
95export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (308export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
96 config: Pick<ClaudeSettings, "homePath">,309 config: Pick<ClaudeSettings, "homePath">,
...@@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*...@@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
100 const fileSystem = yield* FileSystem.FileSystem;313 const fileSystem = yield* FileSystem.FileSystem;
101 const path = yield* Path.Path;314 const path = yield* Path.Path;
102 const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);315 const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
316 const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env);
103317
104 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [318 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
105 { directory: path.join(configDirPath, "skills"), scope: "user" },319 { directory: path.join(configDirPath, "skills"), scope: "user" },
106 ...(cwd320 ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
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 : []),
112 ];321 ];
113322
114 const skillsByName = new Map<string, ServerProviderSkill>();323 const skillsByName = new Map<string, ServerProviderSkill>();
...@@ -134,19 +343,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*...@@ -134,19 +343,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
134 continue;343 continue;
135 }344 }
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();
138 if (!name) {353 if (!name) {
139 continue;354 continue;
140 }355 }
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;
142 skillsByName.set(name, {367 skillsByName.set(name, {
143 name,368 name,
144 path: skillPath,369 path: skillPath,
145 enabled: true,370 enabled: override?.enabled ?? true,
146 scope: root.scope,371 scope: root.scope,
147 ...(frontmatter.kind === "parsed" && frontmatter.description372 ...(frontmatter.kind === "parsed" && frontmatter.description
148 ? { description: frontmatter.description }373 ? { description: frontmatter.description }
149 : {}),374 : {}),
375 ...(userInvocationOnly ? { userInvocationOnly: true } : {}),
376 ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false
377 ? { userInvocable: false }
378 : {}),
150 });379 });
151 }380 }
152 }381 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+132
...@@ -810,6 +810,138 @@ describe("ClaudeAdapterLive", () => {...@@ -810,6 +810,138 @@ describe("ClaudeAdapterLive", () => {
810 );810 );
811 });811 });
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
813 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {945 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
814 const harness = makeHarness();946 const harness = makeHarness();
815 return Effect.gen(function* () {947 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+38-1
...@@ -79,6 +79,8 @@ import { ServerConfig } from "../../config.ts";...@@ -79,6 +79,8 @@ import { ServerConfig } from "../../config.ts";
79import * as McpProviderSession from "../../mcp/McpProviderSession.ts";79import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
80import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";80import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
81import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";81import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
82import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
83import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
82import {84import {
83 BUNDLED_CLAUDE_MODEL_CATALOG,85 BUNDLED_CLAUDE_MODEL_CATALOG,
84 type ClaudeModelCatalog,86 type ClaudeModelCatalog,
...@@ -1276,12 +1278,23 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (...@@ -1276,12 +1278,23 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1276 readonly attachmentsDir: string;1278 readonly attachmentsDir: string;
1277 readonly boundInstanceId: ProviderInstanceId;1279 readonly boundInstanceId: ProviderInstanceId;
1278 readonly modelCatalog: ClaudeModelCatalog;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 const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog);1285 const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog);
1282 const sdkContent: Array<Record<string, unknown>> = [];1286 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) {
1285 sdkContent.push({ type: "text", text });1298 sdkContent.push({ type: "text", text });
1286 }1299 }
12871300
...@@ -1332,6 +1345,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (...@@ -1332,6 +1345,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1332 );1345 );
1333 }1346 }
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
1335 return buildUserMessage({ sdkContent });1354 return buildUserMessage({ sdkContent });
1336});1355});
13371356
...@@ -4602,11 +4621,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4602,11 +4621,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4602 });4621 });
4603 }4622 }
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 );
4605 const message = yield* buildUserMessageEffect(input, {4637 const message = yield* buildUserMessageEffect(input, {
4606 fileSystem,4638 fileSystem,
4607 attachmentsDir: serverConfig.attachmentsDir,4639 attachmentsDir: serverConfig.attachmentsDir,
4608 boundInstanceId,4640 boundInstanceId,
4609 modelCatalog,4641 modelCatalog,
4642 skillNames: new Set(
4643 skills
4644 .filter((skill) => skill.enabled && skill.userInvocable !== false)
4645 .map((skill) => skill.name),
4646 ),
4610 });4647 });
46114648
4612 yield* Queue.offer(context.promptQueue, {4649 yield* Queue.offer(context.promptQueue, {
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+11-1
...@@ -177,7 +177,7 @@ describe("searchSlashCommandItems", () => {...@@ -177,7 +177,7 @@ describe("searchSlashCommandItems", () => {
177 ]);177 ]);
178 });178 });
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", () => {
181 const items = [181 const items = [
182 {182 {
183 id: "slash:model",183 id: "slash:model",
...@@ -186,6 +186,14 @@ describe("searchSlashCommandItems", () => {...@@ -186,6 +186,14 @@ describe("searchSlashCommandItems", () => {
186 label: "/model",186 label: "/model",
187 description: "Switch model",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 id: "skill:claudeAgent:unslop",198 id: "skill:claudeAgent:unslop",
191 type: "skill",199 type: "skill",
...@@ -204,9 +212,11 @@ describe("searchSlashCommandItems", () => {...@@ -204,9 +212,11 @@ describe("searchSlashCommandItems", () => {
204212
205 expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([213 expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([
206 "slash:model",214 "slash:model",
215 "skill:claudeAgent:unslop",
207 ]);216 ]);
208 expect(slashCommandItemsForPromptPosition(items, true).map((item) => item.id)).toEqual([217 expect(slashCommandItemsForPromptPosition(items, true).map((item) => item.id)).toEqual([
209 "slash:model",218 "slash:model",
219 "provider-slash-command:claudeAgent:compact",
210 "skill:claudeAgent:unslop",220 "skill:claudeAgent:unslop",
211 ]);221 ]);
212 });222 });
apps/web/src/components/chat/composerSlashCommandSearch.ts+7-1
...@@ -12,6 +12,12 @@ type SlashSearchItem = Extract<...@@ -12,6 +12,12 @@ type SlashSearchItem = Extract<
12 { type: "slash-command" | "provider-slash-command" | "skill" }12 { type: "slash-command" | "provider-slash-command" | "skill" }
13>;13>;
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 */
15export function slashCommandItemsForPromptPosition(21export function slashCommandItemsForPromptPosition(
16 items: ReadonlyArray<SlashSearchItem>,22 items: ReadonlyArray<SlashSearchItem>,
17 isAtPromptStart: boolean,23 isAtPromptStart: boolean,
...@@ -19,7 +25,7 @@ export function slashCommandItemsForPromptPosition(...@@ -19,7 +25,7 @@ export function slashCommandItemsForPromptPosition(
19 if (isAtPromptStart) {25 if (isAtPromptStart) {
20 return [...items];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}
2430
25function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {31function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {
apps/web/src/providerSkillSearch.test.ts+13
...@@ -48,6 +48,19 @@ describe("searchProviderSkills", () => {...@@ -48,6 +48,19 @@ describe("searchProviderSkills", () => {
48 expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]);48 expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]);
49 });49 });
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
51 it("omits disabled skills from results", () => {64 it("omits disabled skills from results", () => {
52 const skills = [65 const skills = [
53 makeSkill({ name: "ui", displayName: "Ui", enabled: false }),66 makeSkill({ name: "ui", displayName: "Ui", enabled: false }),
apps/web/src/providerSkillSearch.ts+2-1
...@@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts";...@@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts";
2import {2import {
3 dedupeProviderSkillsByName,3 dedupeProviderSkillsByName,
4 formatProviderSkillDisplayName,4 formatProviderSkillDisplayName,
5 isProviderSkillUserInvocable,
5} from "@t3tools/client-runtime/providerSkills";6} from "@t3tools/client-runtime/providerSkills";
6import {7import {
7 insertRankedSearchResult,8 insertRankedSearchResult,
...@@ -73,7 +74,7 @@ export function searchProviderSkills(...@@ -73,7 +74,7 @@ export function searchProviderSkills(
73 query: string,74 query: string,
74 limit = Number.POSITIVE_INFINITY,75 limit = Number.POSITIVE_INFINITY,
75): ServerProviderSkill[] {76): ServerProviderSkill[] {
76 const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled));77 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
77 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });78 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });
7879
79 if (!normalizedQuery) {80 if (!normalizedQuery) {
docs/user/composer.md+9
...@@ -150,6 +150,15 @@ same `$name` skill token to your message. The original skill name remains search...@@ -150,6 +150,15 @@ same `$name` skill token to your message. The original skill name remains search
150also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps150also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps
151the `/skill:Skill Name` label.151the `/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
153On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to162On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to
154start it in the background. T3 Code opens another new thread and shows an **Open** action for the163start it in the background. T3 Code opens another new thread and shows an **Open** action for the
155thread that started. The new thread keeps the selected workspace mode and base branch. If **New164thread that started. The new thread keeps the selected workspace mode and base branch. If **New
docs/user/providers-claude.md+10-3
...@@ -48,10 +48,17 @@ Claude can show its own resume prompt when you continue an old session....@@ -48,10 +48,17 @@ Claude can show its own resume prompt when you continue an old session.
4848
49## Where Claude Skills Are Loaded49## Where Claude Skills Are Loaded
5050
51T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then51T3 Code looks for Claude skills in the Claude config directory's `skills` folder and
52`<workspace>/.agents/skills`, then `<workspace>/.claude/skills`.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
56## I Want Work And Personal Claude Accounts63## I Want Work And Personal Claude Accounts
5764
packages/client-runtime/src/providerSkills.test.ts+24
...@@ -108,6 +108,30 @@ describe("getProviderSkillsForSlashMenu", () => {...@@ -108,6 +108,30 @@ describe("getProviderSkillsForSlashMenu", () => {
108 });108 });
109});109});
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
111describe("getProviderSlashCommandsForSlashMenu", () => {135describe("getProviderSlashCommandsForSlashMenu", () => {
112 const commands = [136 const commands = [
113 { name: "ask-matt", description: "Ask which skill fits your situation." },137 { name: "ask-matt", description: "Ask which skill fits your situation." },
packages/client-runtime/src/providerSkills.ts+14-1
...@@ -39,12 +39,25 @@ export function dedupeProviderSkillsByName(...@@ -39,12 +39,25 @@ export function dedupeProviderSkillsByName(
39 });39 });
40}40}
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
42export function getProviderSkillsForSlashMenu(55export function getProviderSkillsForSlashMenu(
43 skills: ReadonlyArray<ServerProviderSkill>,56 skills: ReadonlyArray<ServerProviderSkill>,
44 showSkillsInSlashMenu: boolean,57 showSkillsInSlashMenu: boolean,
45): ServerProviderSkill[] {58): ServerProviderSkill[] {
46 return showSkillsInSlashMenu59 return showSkillsInSlashMenu
47 ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled))60 ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable))
48 : [];61 : [];
49}62}
5063
packages/contracts/src/server.ts+12
...@@ -95,6 +95,18 @@ export const ServerProviderSkill = Schema.Struct({...@@ -95,6 +95,18 @@ export const ServerProviderSkill = Schema.Struct({
95 enabled: Schema.Boolean,95 enabled: Schema.Boolean,
96 displayName: Schema.optional(TrimmedNonEmptyString),96 displayName: Schema.optional(TrimmedNonEmptyString),
97 shortDescription: Schema.optional(TrimmedNonEmptyString),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});
99export type ServerProviderSkill = typeof ServerProviderSkill.Type;111export type ServerProviderSkill = typeof ServerProviderSkill.Type;
100112