authorgravatar for officialroshanm@gmail.comRoshan Mhatre <officialroshanm@gmail.com> 2026-08-15 17:37:31+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:07:31+00:00
logad47d2347c6917f7db33e6e3902e1e8e5d5281ec
treef228855eb468fac1337fe576b6f54cf81d65c37b
parent684d703b0a8a0632a18c8453277f7e5e6312b200
signature Signed by PGP key B5690EEEBB952194

fix(claude): discover repo-local .agents/skills in skill discovery (#5488)


3 files changed, 125 insertions(+), 12 deletions(-)

apps/server/src/provider/Drivers/ClaudeSkills.test.ts+99
......@@ -66,6 +66,105 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
6666 }),
6767 );
6868
69 it.effect("discovers project skills from the workspace .agents directory", () =>
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(workspace, ".agents", "skills"),
79 "review",
80 ["---", "name: review", "description: Review the changes.", "---"].join("\n"),
81 );
82
83 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
84
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 ]);
94 }),
95 );
96
97 it.effect("prefers workspace .claude skills on three-way name collisions", () =>
98 Effect.gen(function* () {
99 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;
101 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
102 const configDir = path.join(tempDir, "claude-home");
103 const workspace = path.join(tempDir, "workspace");
104
105 yield* writeSkill(
106 path.join(configDir, "skills"),
107 "deploy",
108 ["---", "name: deploy", "description: User deploy.", "---"].join("\n"),
109 );
110 yield* writeSkill(
111 path.join(workspace, ".agents", "skills"),
112 "deploy",
113 ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"),
114 );
115 yield* writeSkill(
116 path.join(workspace, ".claude", "skills"),
117 "deploy",
118 ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"),
119 );
120
121 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
122
123 assert.deepEqual(skills, [
124 {
125 name: "deploy",
126 path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"),
127 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.",
163 },
164 ]);
165 }),
166 );
167
69168 it.effect("prefers project skills over user skills on name collisions", () =>
70169 Effect.gen(function* () {
71170 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/Drivers/ClaudeSkills.ts+19-12
......@@ -1,12 +1,13 @@
11/**
22 * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.
33 *
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.
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`.
8 * The Agent SDK init handshake surfaces skills only as slash commands without
9 * their filesystem paths, so the provider snapshot scans the same locations
10 * directly, mirroring how the Codex app-server reports its skills.
1011 *
1112 * @module provider/Drivers/ClaudeSkills
1213 */
......@@ -84,11 +85,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct
8485});
8586
8687/**
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.
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.
9294 */
9395export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
9496 config: Pick<ClaudeSettings, "homePath">,
......@@ -101,7 +103,12 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
101103
102104 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
103105 { directory: path.join(configDirPath, "skills"), scope: "user" },
104 ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
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 : []),
105112 ];
106113
107114 const skillsByName = new Map<string, ServerProviderSkill>();
docs/user/providers-claude.md+7
......@@ -34,6 +34,13 @@ When you set this field, T3 Code points Claude Code at that directory with the
3434`CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and
3535the rest of your environment stay as they are.
3636
37## Where Claude Skills Are Loaded
38
39T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then
40`<workspace>/.agents/skills`, then `<workspace>/.claude/skills`.
41
42If the same skill name exists in more than one folder, the later folder wins.
43
3744## I Want Work And Personal Claude Accounts
3845
3946Use a different Claude config directory for each account.