Skip to content

Commit ad47d23

Browse files
authored
fix(claude): discover repo-local .agents/skills in skill discovery (#5488)
1 parent 684d703 commit ad47d23

3 files changed

Lines changed: 125 additions & 12 deletions

File tree

apps/server/src/provider/Drivers/ClaudeSkills.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
9395
export 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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
3535
the rest of your environment stay as they are.
3636

37+
## Where Claude Skills Are Loaded
38+
39+
T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then
40+
`<workspace>/.agents/skills`, then `<workspace>/.claude/skills`.
41+
42+
If the same skill name exists in more than one folder, the later folder wins.
43+
3744
## I Want Work And Personal Claude Accounts
3845

3946
Use a different Claude config directory for each account.

0 commit comments

Comments
 (0)