History

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

Theo Browne committed 2 months ago

Commit ea71a191 parent 9a7b1e216 files · +1080 −82Browse files

Unified · Split

16 files
  1. apps/mobile/src/features/threads/use-composer-command-menu.ts +9 −2
  2. apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +41 −0
  3. apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +78 −0
  4. apps/server/src/provider/Drivers/ClaudeSkills.test.ts +430 −51
  5. apps/server/src/provider/Drivers/ClaudeSkills.ts +250 −21
  6. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +132 −0
  7. apps/server/src/provider/Layers/ClaudeAdapter.ts +38 −1
  8. apps/web/src/components/chat/composerSlashCommandSearch.test.ts +11 −1
  9. apps/web/src/components/chat/composerSlashCommandSearch.ts +7 −1
  10. apps/web/src/providerSkillSearch.test.ts +13 −0
  11. apps/web/src/providerSkillSearch.ts +2 −1
  12. docs/user/composer.md +9 −0
  13. docs/user/providers-claude.md +10 −3
  14. packages/client-runtime/src/providerSkills.test.ts +24 −0
  15. packages/client-runtime/src/providerSkills.ts +14 −1
  16. packages/contracts/src/server.ts +12 −0
apps/mobile/src/features/threads/use-composer-command-menu.ts+9−2
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 import {
1313 dedupeProviderSkillsByName,
1414 getProviderSkillsForSlashMenu,
15+ isProviderSkillUserInvocable,
1516 } from "@t3tools/client-runtime/providerSkills";
1617 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1718
@@ -114,8 +115,14 @@ export function useComposerCommandMenu({ · 96 unmodified lines
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({ · 27 unmodified lines
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+41−0
@@ -0,0 +1,41 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
4+
5+const SKILLS = new Set(["implement", "review", "re-release-version"]);
6+
7+describe("planClaudeSkillDispatch", () => {
8+ it("leaves a prompt without a known skill untouched", () => {
9+ expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined();
10+ // Not a discovered skill, so it stays prose rather than becoming a command.
11+ expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined();
12+ });
13+
14+ it("moves a mid-prompt mention into a trailing slash command", () => {
15+ expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({
16+ leadingText: "ok, now",
17+ commandText: "/implement all the tickets",
18+ skillName: "implement",
19+ });
20+ });
21+
22+ it("keeps a mention that opens the prompt as a single command block", () => {
23+ expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({
24+ leadingText: undefined,
25+ commandText: "/review\nfocus on auth",
26+ skillName: "review",
27+ });
28+ });
29+
30+ it("dispatches the last mention and rewrites earlier ones inline", () => {
31+ expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({
32+ leadingText: "/review the diff, then",
33+ commandText: "/implement the fixes",
34+ skillName: "implement",
35+ });
36+ });
37+
38+ it("ignores a dollar token glued to other text", () => {
39+ expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
40+ });
41+});
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+78−0
@@ -0,0 +1,78 @@
1+/**
2+ * ClaudeSkillDispatch — turns `$skill` mentions in a composer prompt into the
3+ * slash invocation Claude Code actually runs.
4+ *
5+ * The composer inserts `$name` for every provider. Codex parses that natively;
6+ * Claude Code does not, and treats it as prose. Claude Code's only user-side
7+ * invocation is a text block whose first character is `/`: the harness
8+ * expands `/name args` into the SKILL.md body, and every character after the
9+ * name (newlines included) arrives as `ARGUMENTS`. Verified against the CLI in
10+ * stream-json mode, which is what the Agent SDK uses:
11+ *
12+ * - The check runs on the LAST text block of the message. Earlier text
13+ * blocks are preserved verbatim, and image blocks may sit before it.
14+ * - Leading whitespace, or a `/name` that starts a later line of the same
15+ * block, is literal text.
16+ * - Only one skill expands per message; a second `/x` becomes argument text
17+ * (anthropics/claude-code#87113). The model still starts the rest through
18+ * its Skill tool when it reads `/name` in the prompt, so earlier mentions
19+ * are rewritten to `/name` inline.
20+ *
21+ * So one mention anywhere in the prompt becomes a guaranteed invocation, and
22+ * the user's text on either side is kept in order.
23+ *
24+ * @module provider/Drivers/ClaudeSkillDispatch
25+ */
26+
27+/**
28+ * Same token shape the composer and timeline chips recognise
29+ * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a
30+ * dispatched skill are always the same set.
31+ */
32+const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
33+
34+export interface ClaudeSkillDispatch {
35+ /** Text before the dispatched mention, or `undefined` when it opens the prompt. */
36+ readonly leadingText: string | undefined;
37+ /** `/name` plus the trailing text, ready to be the message's last text block. */
38+ readonly commandText: string;
39+ readonly skillName: string;
40+}
41+
42+/**
43+ * Split `prompt` around the last `$skill` mention that names a known skill.
44+ * Returns `undefined` when there is nothing to dispatch, in which case the
45+ * prompt should go out unchanged. Mentions that do not match a discovered
46+ * skill stay literal: a `$HOME` in prose must not become a command.
47+ */
48+export function planClaudeSkillDispatch(
49+ prompt: string,
50+ skillNames: ReadonlySet<string>,
51+): ClaudeSkillDispatch | undefined {
52+ const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => {
53+ const name = match[2] ?? "";
54+ if (!skillNames.has(name)) return [];
55+ const start = (match.index ?? 0) + (match[1]?.length ?? 0);
56+ return [{ name, start, end: start + name.length + 1 }];
57+ });
58+ const last = mentions.at(-1);
59+ if (!last) {
60+ return undefined;
61+ }
62+
63+ const leading = prompt.slice(0, last.start);
64+ const trailing = prompt.slice(last.end);
65+ const leadingWithInlineSlashes = mentions
66+ .slice(0, -1)
67+ .reduceRight(
68+ (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`,
69+ leading,
70+ )
71+ .trimEnd();
72+
73+ return {
74+ leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined,
75+ commandText: `/${last.name}${trailing}`.trimEnd(),
76+ skillName: last.name,
77+ };
78+}
apps/server/src/provider/Drivers/ClaudeSkills.test.ts+430−51
@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
44 import * as FileSystem from "effect/FileSystem";
55 import * as Path from "effect/Path";
66
7−import { discoverClaudeSkills } from "./ClaudeSkills.ts";
7+import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts";
88
99 const writeSkill = Effect.fn(function* (
1010 skillsDir: string,
@@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { · 55 unmodified lines
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) => { · 1 unmodified line
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) => { · 2 unmodified lines
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) => { · 22 unmodified lines
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) => { · 15 unmodified lines
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) => { · 92 unmodified lines
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"; · 6 unmodified lines
1719 import * as Effect from "effect/Effect";
1820 import * as FileSystem from "effect/FileSystem";
1921 import * as Path from "effect/Path";
22+import * as Schema from "effect/Schema";
23+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
24+import { fromLenientJson } from "@t3tools/shared/schemaJson";
2025 import { parse as parseYamlDocument } from "yaml";
2126
2227 import { expandHomePath } from "../../pathExpansion.ts";
@@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; · 5 unmodified lines
2833 type 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+ */
50+function parseFrontmatterBoolean(value: unknown): boolean | undefined {
51+ if (typeof value === "boolean") return value;
52+ if (typeof value === "number") {
53+ return value === 1 ? true : value === 0 ? false : undefined;
54+ }
55+ if (typeof value !== "string") return undefined;
56+ switch (value.trim().toLowerCase()) {
57+ case "true":
58+ case "yes":
59+ case "on":
60+ case "y":
61+ return true;
62+ case "false":
63+ case "no":
64+ case "off":
65+ case "n":
66+ return false;
67+ default:
68+ return undefined;
69+ }
70+}
3271
3372 function parseSkillFrontmatter(contents: string): SkillFrontmatter {
3473 const match = FRONTMATTER_PATTERN.exec(contents);
@@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { · 12 unmodified lines
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+ */
107+export function claudeManagedSettingsPath(
108+ path: Path.Path,
109+ platform: NodeJS.Platform,
110+ environment: NodeJS.ProcessEnv,
111+): string | undefined {
112+ if (platform === "darwin") {
113+ return "/Library/Application Support/ClaudeCode/managed-settings.json";
114+ }
115+ if (platform === "win32") {
116+ const programData = environment.PROGRAMDATA?.trim();
117+ return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined;
118+ }
119+ return "/etc/claude-code/managed-settings.json";
120+}
121+
122+/**
123+ * Settings files Claude Code merges for `skillOverrides`, in increasing
124+ * precedence: user, project, project-local, then the administrator's managed
125+ * policy, which wins outright. When the workspace sits inside a git
126+ * repository, the repository root's `settings.local.json` is read too and
127+ * outranks the workspace's own local file. Verified against the CLI from a
128+ * nested cwd: a root local file switching a skill off wins over a cwd one
129+ * switching it on, the root's plain `settings.json` is not consulted, and
130+ * without a `.git` above the cwd no root file is read. A skill the user
131+ * switched off is reported disabled rather than dropped, so the picker can
132+ * grey it out instead of silently losing it.
133+ */
134+export function skillOverrideSettingsPaths(
135+ path: Path.Path,
136+ configDirPath: string,
137+ cwd: string | undefined,
138+ platform: NodeJS.Platform,
139+ environment: NodeJS.ProcessEnv,
140+ repositoryRoot?: string,
141+): ReadonlyArray<string> {
142+ const managedPath = claudeManagedSettingsPath(path, platform, environment);
143+ const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined;
144+ return [
145+ path.join(configDirPath, "settings.json"),
146+ ...(cwd
147+ ? [
148+ path.join(cwd, ".claude", "settings.json"),
149+ path.join(cwd, ".claude", "settings.local.json"),
150+ ]
151+ : []),
152+ ...(root ? [path.join(root, ".claude", "settings.local.json")] : []),
153+ ...(managedPath ? [managedPath] : []),
154+ ];
155+}
156+
157+/**
158+ * Nearest ancestor of `cwd` (inclusive) holding a `.git` entry, which is the
159+ * boundary Claude Code walks up to for project settings. `undefined` outside
160+ * a repository.
161+ */
162+const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* (
163+ cwd: string,
164+): Effect.fn.Return<string | undefined, never, FileSystem.FileSystem | Path.Path> {
165+ const fileSystem = yield* FileSystem.FileSystem;
166+ const path = yield* Path.Path;
167+ let current = path.resolve(cwd);
168+ while (true) {
169+ const isRoot = yield* fileSystem
170+ .exists(path.join(current, ".git"))
171+ .pipe(Effect.orElseSucceed(() => false));
172+ if (isRoot) {
173+ return current;
174+ }
175+ const parent = path.dirname(current);
176+ if (parent === current) {
177+ return undefined;
178+ }
179+ current = parent;
180+ }
181+});
182+
183+/**
184+ * The four states Claude Code accepts. The CLI validates the whole map, not
185+ * each entry: verified against it, one entry with an unknown value (or a
186+ * boolean) makes it drop every override in that file, so this schema does the
187+ * same rather than applying the valid siblings the CLI ignores.
188+ */
189+const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]);
190+
191+// Lenient because these settings files are hand-edited and Claude Code itself
192+// tolerates comments and trailing commas in them.
193+const SkillOverrideSettings = fromLenientJson(
194+ Schema.Struct({
195+ skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)),
196+ }),
197+);
198+const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings);
199+
200+/**
201+ * What a `skillOverrides` entry says about one skill. `"user-invocable-only"`
202+ * hides it from the agent exactly as `disable-model-invocation` does, so it is
203+ * kept apart from a plain on/off decision rather than collapsed into one.
204+ */
205+type SkillOverride = {
206+ readonly enabled: boolean;
207+ readonly userInvocationOnly: boolean;
208+};
209+
210+function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride {
211+ switch (value) {
212+ case "off":
213+ return { enabled: false, userInvocationOnly: false };
214+ case "user-invocable-only":
215+ return { enabled: true, userInvocationOnly: true };
216+ case "on":
217+ case "name-only":
218+ return { enabled: true, userInvocationOnly: false };
219+ }
220+}
221+
222+const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
223+ configDirPath: string,
224+ cwd: string | undefined,
225+ environment: NodeJS.ProcessEnv,
226+): Effect.fn.Return<ReadonlyMap<string, SkillOverride>, never, FileSystem.FileSystem | Path.Path> {
227+ const fileSystem = yield* FileSystem.FileSystem;
228+ const path = yield* Path.Path;
229+ const platform = yield* HostProcessPlatform;
230+ const overridesByName = new Map<string, SkillOverride>();
231+ const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd);
232+
233+ for (const settingsPath of skillOverrideSettingsPaths(
234+ path,
235+ configDirPath,
236+ cwd,
237+ platform,
238+ environment,
239+ repositoryRoot,
240+ )) {
241+ const contents = yield* fileSystem
242+ .readFileString(settingsPath)
243+ .pipe(Effect.orElseSucceed(() => undefined));
244+ if (contents === undefined) {
245+ continue;
246+ }
247+
248+ const parsed = yield* decodeSkillOverrideSettings(contents).pipe(
249+ Effect.tapError((cause) =>
250+ Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", {
251+ path: settingsPath,
252+ cause,
253+ }),
254+ ),
255+ Effect.orElseSucceed(() => undefined),
256+ );
257+ const overrides = parsed?.skillOverrides;
258+ if (!overrides) {
259+ continue;
260+ }
261+
262+ for (const [name, value] of Object.entries(overrides)) {
263+ overridesByName.set(name, parseSkillOverride(value));
264+ }
265+ }
266+
267+ return overridesByName;
268+});
269+
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 · 23 unmodified lines
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 */
95308 export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
96309 config: Pick<ClaudeSettings, "homePath">,
@@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* · 3 unmodified lines
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* · 19 unmodified lines
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−0
@@ -810,6 +810,138 @@ describe("ClaudeAdapterLive", () => { · 809 unmodified lines
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"; · 78 unmodified lines
7979 import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
8080 import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
8181 import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
82+import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
83+import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
8284 import {
8385 BUNDLED_CLAUDE_MODEL_CATALOG,
8486 type ClaudeModelCatalog,
@@ -1276,12 +1278,23 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( · 1191 unmodified lines
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* ( · 44 unmodified lines
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* ( · 3264 unmodified lines
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", () => { · 176 unmodified lines
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", () => { · 2 unmodified lines
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", () => { · 12 unmodified lines
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< · 11 unmodified lines
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+ */
1521 export function slashCommandItemsForPromptPosition(
1622 items: ReadonlyArray<SlashSearchItem>,
1723 isAtPromptStart: boolean,
@@ -19,7 +25,7 @@ export function slashCommandItemsForPromptPosition( · 1 unmodified line
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
2531 function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {
apps/web/src/providerSkillSearch.test.ts+13−0
@@ -48,6 +48,19 @@ describe("searchProviderSkills", () => { · 47 unmodified lines
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"; · 1 unmodified line
22 import {
33 dedupeProviderSkillsByName,
44 formatProviderSkillDisplayName,
5+ isProviderSkillUserInvocable,
56 } from "@t3tools/client-runtime/providerSkills";
67 import {
78 insertRankedSearchResult,
@@ -73,7 +74,7 @@ export function searchProviderSkills( · 65 unmodified lines
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−0
@@ -150,6 +150,15 @@ same `$name` skill token to your message. The original skill name remains search · 149 unmodified lines
150150 also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps
151151 the `/skill:Skill Name` label.
152152
153+A skill token runs the skill wherever it sits in your message. T3 Code sends it to each provider in
154+the form that provider runs, so the text before and after the token is kept. Skills that only you may
155+start, and never the agent on its own, work the same way. A skill you switched off in the provider's
156+settings does not appear in either menu.
157+
158+Provider commands such as `/compact` only run when they open the message, so the `/` menu offers
159+them only there. T3 Code's own commands, such as `/model` and `/plan`, and skills stay available on
160+any line.
161+
153162 On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to
154163 start it in the background. T3 Code opens another new thread and shows an **Open** action for the
155164 thread that started. The new thread keeps the selected workspace mode and base branch. If **New
docs/user/providers-claude.md+10−3
@@ -48,10 +48,17 @@ Claude can show its own resume prompt when you continue an old session. · 47 unmodified lines
4848
4949 ## Where Claude Skills Are Loaded
5050
51−T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then
52−`<workspace>/.agents/skills`, then `<workspace>/.claude/skills`.
51+T3 Code looks for Claude skills in the Claude config directory's `skills` folder and
52+`<workspace>/.claude/skills`, the two places Claude Code loads them from.
5353
54−If the same skill name exists in more than one folder, the later folder wins.
54+If the same skill name exists in more than one folder, the one in the Claude config directory
55+wins, the same way Claude Code resolves it.
56+
57+A skill set to `off` in Claude Code's `skillOverrides` is left out of both composer menus. A skill
58+marked `disable-model-invocation` still appears, because you start it yourself when you pick it.
59+Claude Code runs one skill per message; when a message names several, the last one runs directly and
60+Claude starts the others through its Skill tool, which refuses skills marked
61+`disable-model-invocation`.
5562
5663 ## I Want Work And Personal Claude Accounts
5764
packages/client-runtime/src/providerSkills.test.ts+24−0
@@ -108,6 +108,30 @@ describe("getProviderSkillsForSlashMenu", () => { · 107 unmodified lines
108108 });
109109 });
110110
111+describe("getProviderSkillsForSlashMenu", () => {
112+ it("drops a skill the provider reserves for the agent", () => {
113+ const skills = [
114+ {
115+ name: "legacy-system-context",
116+ path: "/Users/matt/.claude/skills/legacy-system-context/SKILL.md",
117+ enabled: true,
118+ userInvocable: false,
119+ },
120+ {
121+ name: "deploy",
122+ path: "/Users/matt/.claude/skills/deploy/SKILL.md",
123+ enabled: true,
124+ // Reserved for the user, not the agent: still a valid pick.
125+ userInvocationOnly: true,
126+ },
127+ ];
128+
129+ expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([
130+ "deploy",
131+ ]);
132+ });
133+});
134+
111135 describe("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( · 38 unmodified lines
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+ */
49+export function isProviderSkillUserInvocable(
50+ skill: Pick<ServerProviderSkill, "enabled" | "userInvocable">,
51+): boolean {
52+ return skill.enabled && skill.userInvocable !== false;
53+}
54+
4255 export 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−0
@@ -95,6 +95,18 @@ export const ServerProviderSkill = Schema.Struct({ · 94 unmodified lines
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 });
99111 export type ServerProviderSkill = typeof ServerProviderSkill.Type;
100112