authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 18:39:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 18:39:42-07:00
log6b9a5987f686d0d904abe6f51b4be3bf873907e9
tree4153150c8b3020902e88da66506a0f27fd96b6b1
parentfc3f78f5d917fdeee6271d2f6fd42961369c7d9f
signature Signed by PGP key B5690EEEBB952194

fix: Claude Code skills discoverable for the composer $ picker (#4414)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 368 insertions(+), 2 deletions(-)

apps/server/package.json+2-1
......@@ -31,7 +31,8 @@
3131 "@opencode-ai/sdk": "^1.3.15",
3232 "@pierre/diffs": "catalog:",
3333 "effect": "catalog:",
34 "node-pty": "^1.1.0"
34 "node-pty": "^1.1.0",
35 "yaml": "catalog:"
3536 },
3637 "devDependencies": {
3738 "@effect/vitest": "catalog:",
apps/server/src/provider/Drivers/ClaudeDriver.ts+3
......@@ -118,6 +118,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
118118 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
119119 Effect.gen(function* () {
120120 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
121 const fileSystem = yield* FileSystem.FileSystem;
121122 const path = yield* Path.Path;
122123 const { cwd } = yield* ServerConfig;
123124 const httpClient = yield* HttpClient.HttpClient;
......@@ -165,9 +166,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
165166 effectiveConfig,
166167 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
167168 processEnv,
169 cwd,
168170 ).pipe(
169171 Effect.map(stampIdentity),
170172 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
173 Effect.provideService(FileSystem.FileSystem, fileSystem),
171174 Effect.provideService(Path.Path, path),
172175 );
173176
apps/server/src/provider/Drivers/ClaudeSkills.test.ts created+205
......@@ -0,0 +1,205 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6
7import { discoverClaudeSkills } from "./ClaudeSkills.ts";
8
9const writeSkill = Effect.fn(function* (
10 skillsDir: string,
11 directoryName: string,
12 contents: string,
13) {
14 const fs = yield* FileSystem.FileSystem;
15 const path = yield* Path.Path;
16 const skillDir = path.join(skillsDir, directoryName);
17 yield* fs.makeDirectory(skillDir, { recursive: true });
18 yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents);
19});
20
21it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
22 it.effect("discovers user and project skills with frontmatter metadata", () =>
23 Effect.gen(function* () {
24 const fs = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;
26 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
27 const configDir = path.join(tempDir, "claude-home");
28 const workspace = path.join(tempDir, "workspace");
29
30 yield* writeSkill(
31 path.join(configDir, "skills"),
32 "codex-review",
33 [
34 "---",
35 "name: codex-review",
36 "description: Ask Codex for a review.",
37 "---",
38 "",
39 "# Body",
40 ].join("\n"),
41 );
42 yield* writeSkill(
43 path.join(workspace, ".claude", "skills"),
44 "deploy",
45 ["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"),
46 );
47
48 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
49
50 assert.deepEqual(skills, [
51 {
52 name: "codex-review",
53 path: path.join(configDir, "skills", "codex-review", "SKILL.md"),
54 enabled: true,
55 scope: "user",
56 description: "Ask Codex for a review.",
57 },
58 {
59 name: "deploy",
60 path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"),
61 enabled: true,
62 scope: "project",
63 description: "Deploy the app.",
64 },
65 ]);
66 }),
67 );
68
69 it.effect("prefers project skills over user skills on name collisions", () =>
70 Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
74 const configDir = path.join(tempDir, "claude-home");
75 const workspace = path.join(tempDir, "workspace");
76
77 yield* writeSkill(
78 path.join(configDir, "skills"),
79 "deploy",
80 ["---", "name: deploy", "description: User deploy.", "---"].join("\n"),
81 );
82 yield* writeSkill(
83 path.join(workspace, ".claude", "skills"),
84 "deploy",
85 ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"),
86 );
87
88 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
89
90 assert.equal(skills.length, 1);
91 assert.equal(skills[0]?.scope, "project");
92 assert.equal(skills[0]?.description, "Project deploy.");
93 }),
94 );
95
96 it.effect("falls back to the directory name and skips malformed frontmatter", () =>
97 Effect.gen(function* () {
98 const fs = yield* FileSystem.FileSystem;
99 const path = yield* Path.Path;
100 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
101 const configDir = path.join(tempDir, "claude-home");
102 const skillsDir = path.join(configDir, "skills");
103
104 yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n");
105 yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n");
106 // A stray file (not a directory with SKILL.md) must be skipped.
107 yield* fs.makeDirectory(skillsDir, { recursive: true });
108 yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill");
109
110 const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined);
111
112 // A skill with no frontmatter falls back to its directory name; a skill
113 // whose frontmatter fails to parse is skipped entirely (Claude Code
114 // won't load it either).
115 assert.deepEqual(
116 skills.map((skill) => skill.name),
117 ["no-frontmatter"],
118 );
119 assert.equal(skills[0]?.description, undefined);
120 }),
121 );
122
123 it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () =>
124 Effect.gen(function* () {
125 const fs = yield* FileSystem.FileSystem;
126 const path = yield* Path.Path;
127 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
128 const environmentConfigDir = path.join(tempDir, "env-config");
129
130 yield* writeSkill(
131 path.join(environmentConfigDir, "skills"),
132 "env-skill",
133 ["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"),
134 );
135
136 const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, {
137 CLAUDE_CONFIG_DIR: environmentConfigDir,
138 });
139
140 assert.deepEqual(
141 skills.map((skill) => skill.name),
142 ["env-skill"],
143 );
144
145 // An explicit homePath wins over the environment variable, matching
146 // makeClaudeEnvironment which overwrites CLAUDE_CONFIG_DIR for the CLI.
147 const explicitHome = path.join(tempDir, "explicit-home");
148 yield* writeSkill(
149 path.join(explicitHome, "skills"),
150 "explicit-skill",
151 ["---", "name: explicit-skill", "---"].join("\n"),
152 );
153 const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, {
154 CLAUDE_CONFIG_DIR: environmentConfigDir,
155 });
156 assert.deepEqual(
157 explicitSkills.map((skill) => skill.name),
158 ["explicit-skill"],
159 );
160 }),
161 );
162
163 it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () =>
164 Effect.gen(function* () {
165 const fs = yield* FileSystem.FileSystem;
166 const path = yield* Path.Path;
167 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
168 const workspace = path.join(tempDir, "workspace");
169 yield* fs.makeDirectory(workspace, { recursive: true });
170
171 // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own
172 // cwd (the workspace), so discovery must do the same.
173 yield* writeSkill(
174 path.join(workspace, "relative-config", "skills"),
175 "relative-skill",
176 ["---", "name: relative-skill", "---"].join("\n"),
177 );
178
179 const skills = yield* discoverClaudeSkills({ homePath: "" }, workspace, {
180 CLAUDE_CONFIG_DIR: "relative-config",
181 });
182
183 assert.deepEqual(
184 skills.map((skill) => skill.name),
185 ["relative-skill"],
186 );
187 assert.equal(skills[0]?.scope, "user");
188 }),
189 );
190
191 it.effect("returns an empty list when no skill roots exist", () =>
192 Effect.gen(function* () {
193 const fs = yield* FileSystem.FileSystem;
194 const path = yield* Path.Path;
195 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
196
197 const skills = yield* discoverClaudeSkills(
198 { homePath: path.join(tempDir, "missing-home") },
199 path.join(tempDir, "missing-workspace"),
200 );
201
202 assert.deepEqual(skills, []);
203 }),
204 );
205});
apps/server/src/provider/Drivers/ClaudeSkills.ts created+148
......@@ -0,0 +1,148 @@
1/**
2 * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.
3 *
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 Agent SDK init handshake surfaces
7 * skills only as slash commands without their filesystem paths, so the
8 * provider snapshot scans the same locations directly, mirroring how the
9 * Codex app-server reports its skills.
10 *
11 * @module provider/Drivers/ClaudeSkills
12 */
13import * as NodeOS from "node:os";
14
15import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Path from "effect/Path";
19import { parse as parseYamlDocument } from "yaml";
20
21import { expandHomePath } from "../../pathExpansion.ts";
22
23type ClaudeSkillScope = "user" | "project";
24
25const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
26
27type SkillFrontmatter =
28 | { readonly kind: "missing" }
29 | { readonly kind: "malformed" }
30 | { readonly kind: "parsed"; readonly name?: string; readonly description?: string };
31
32function parseSkillFrontmatter(contents: string): SkillFrontmatter {
33 const match = FRONTMATTER_PATTERN.exec(contents);
34 if (!match) {
35 return { kind: "missing" };
36 }
37
38 let parsed: unknown;
39 try {
40 parsed = parseYamlDocument(match[1] ?? "");
41 } catch {
42 return { kind: "malformed" };
43 }
44 if (typeof parsed !== "object" || parsed === null) {
45 return { kind: "malformed" };
46 }
47
48 const record = parsed as Record<string, unknown>;
49 const name = typeof record.name === "string" ? record.name.trim() : "";
50 const description = typeof record.description === "string" ? record.description.trim() : "";
51 return {
52 kind: "parsed",
53 ...(name ? { name } : {}),
54 ...(description ? { description } : {}),
55 };
56}
57
58/**
59 * Resolve the Claude config directory the CLI would use, matching the
60 * precedence the spawned CLI sees: the instance's `homePath` (exported as
61 * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR`
62 * already present in the process environment, then `~/.claude`.
63 */
64const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* (
65 config: Pick<ClaudeSettings, "homePath">,
66 environment: NodeJS.ProcessEnv,
67 cwd?: string,
68): Effect.fn.Return<string, never, Path.Path> {
69 const path = yield* Path.Path;
70 const homePath = config.homePath.trim();
71 if (homePath.length > 0) {
72 return path.resolve(expandHomePath(homePath));
73 }
74 // No tilde expansion here: the spawned CLI receives this env var verbatim
75 // (env vars are never shell-expanded), so a literal `~` must stay literal
76 // for discovery to scan the same directory the runtime would. A relative
77 // value is resolved against the workspace cwd — the subprocess's own cwd —
78 // for the same reason.
79 const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
80 if (environmentConfigDir.length > 0) {
81 return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir);
82 }
83 return path.join(NodeOS.homedir(), ".claude");
84});
85
86/**
87 * Enumerate Claude Code skills from the user config dir and the workspace.
88 * Discovery is best-effort: unreadable roots and malformed skill entries are
89 * skipped so a broken skill never degrades the provider snapshot. On name
90 * collisions the project-scoped skill wins, matching Claude Code's
91 * most-specific-wins resolution.
92 */
93export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
94 config: Pick<ClaudeSettings, "homePath">,
95 cwd?: string,
96 environment?: NodeJS.ProcessEnv,
97): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
98 const fileSystem = yield* FileSystem.FileSystem;
99 const path = yield* Path.Path;
100 const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
101
102 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
103 { directory: path.join(configDirPath, "skills"), scope: "user" },
104 ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
105 ];
106
107 const skillsByName = new Map<string, ServerProviderSkill>();
108 for (const root of roots) {
109 const entries = yield* fileSystem
110 .readDirectory(root.directory)
111 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
112
113 for (const entry of [...entries].sort()) {
114 const skillPath = path.join(root.directory, entry, "SKILL.md");
115 const contents = yield* fileSystem
116 .readFileString(skillPath)
117 .pipe(Effect.orElseSucceed(() => undefined));
118 if (contents === undefined) {
119 continue;
120 }
121
122 const frontmatter = parseSkillFrontmatter(contents);
123 // Malformed frontmatter means the skill won't load in Claude Code
124 // either — skip it rather than surfacing a broken entry under its
125 // directory name.
126 if (frontmatter.kind === "malformed") {
127 continue;
128 }
129
130 const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim();
131 if (!name) {
132 continue;
133 }
134
135 skillsByName.set(name, {
136 name,
137 path: skillPath,
138 enabled: true,
139 scope: root.scope,
140 ...(frontmatter.kind === "parsed" && frontmatter.description
141 ? { description: frontmatter.description }
142 : {}),
143 });
144 }
145 }
146
147 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
148});
apps/server/src/provider/Layers/ClaudeProvider.ts+7-1
......@@ -7,6 +7,7 @@ import {
77} from "@t3tools/contracts";
88import * as DateTime from "effect/DateTime";
99import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
1011import * as Option from "effect/Option";
1112import * as Path from "effect/Path";
1213import * as Result from "effect/Result";
......@@ -40,6 +41,7 @@ import {
4041} from "../providerSnapshot.ts";
4142import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
4243import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
44import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
4345
4446const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
4547 optionDescriptors: [],
......@@ -732,10 +734,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
732734 claudeSettings: ClaudeSettings,
733735 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
734736 environment?: NodeJS.ProcessEnv,
737 cwd?: string,
735738): Effect.fn.Return<
736739 ServerProviderDraft,
737740 never,
738 ChildProcessSpawner.ChildProcessSpawner | Path.Path
741 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
739742> {
740743 const resolvedEnvironment = environment ?? process.env;
741744 const checkedAt = DateTime.formatIso(yield* DateTime.now);
......@@ -845,6 +848,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
845848 const capabilities = resolveCapabilities
846849 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
847850 : undefined;
851 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
848852 const slashCommands = capabilities?.slashCommands ?? [];
849853 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
850854
......@@ -855,6 +859,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
855859 checkedAt,
856860 models,
857861 slashCommands: dedupedSlashCommands,
862 skills,
858863 probe: {
859864 installed: true,
860865 version: parsedVersion,
......@@ -876,6 +881,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
876881 checkedAt,
877882 models,
878883 slashCommands: dedupedSlashCommands,
884 skills,
879885 probe: {
880886 installed: true,
881887 version: parsedVersion,
pnpm-lock.yaml+3
......@@ -471,6 +471,9 @@ importers:
471471 node-pty:
472472 specifier: ^1.1.0
473473 version: 1.1.0
474 yaml:
475 specifier: ^2.9.0
476 version: 2.9.0
474477 devDependencies:
475478 '@effect/vitest':
476479 specifier: 4.0.0-beta.78