Skip to content

Commit 9b685bc

Browse files
t3dotggclaude
authored andcommitted
fix: Claude Code skills discoverable for the composer $ picker (pingdotgg#4414)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 6b9a598)
1 parent 1b678ec commit 9b685bc

6 files changed

Lines changed: 368 additions & 2 deletions

File tree

apps/server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"@opencode-ai/sdk": "^1.3.15",
3636
"@pierre/diffs": "catalog:",
3737
"effect": "catalog:",
38-
"node-pty": "^1.1.0"
38+
"node-pty": "^1.1.0",
39+
"yaml": "catalog:"
3940
},
4041
"devDependencies": {
4142
"@effect/vitest": "catalog:",

apps/server/src/provider/Drivers/ClaudeDriver.ts

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

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { assert, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Path from "effect/Path";
6+
7+
import { discoverClaudeSkills } from "./ClaudeSkills.ts";
8+
9+
const 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+
21+
it.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+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
*/
13+
import * as NodeOS from "node:os";
14+
15+
import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
16+
import * as Effect from "effect/Effect";
17+
import * as FileSystem from "effect/FileSystem";
18+
import * as Path from "effect/Path";
19+
import { parse as parseYamlDocument } from "yaml";
20+
21+
import { expandHomePath } from "../../pathExpansion.ts";
22+
23+
type ClaudeSkillScope = "user" | "project";
24+
25+
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
26+
27+
type SkillFrontmatter =
28+
| { readonly kind: "missing" }
29+
| { readonly kind: "malformed" }
30+
| { readonly kind: "parsed"; readonly name?: string; readonly description?: string };
31+
32+
function 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+
*/
64+
const 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+
*/
93+
export 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+
});

0 commit comments

Comments
 (0)