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 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.
10 * The Agent SDK init handshake surfaces skills only as slash commands without
11 * their filesystem paths, so the provider snapshot scans the same locations
12 * directly, mirroring how the Codex app-server reports its skills.
13 *
14 * @module provider/Drivers/ClaudeSkills
15 */
16
17import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
18import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23import { fromLenientJson } from "@t3tools/shared/schemaJson";
24import { parse as parseYamlDocument } from "yaml";
25
26import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
27
28type ClaudeSkillScope = "user" | "project";
29
30const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
31
32type SkillFrontmatter =
33 | { readonly kind: "missing" }
34 | { readonly kind: "malformed" }
35 | {
36 readonly kind: "parsed";
37 readonly description?: string;
38 readonly userInvocationOnly?: boolean;
39 readonly userInvocable?: boolean;
40 };
41
42/**
43 * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`,
44 * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and
45 * numbers. Verified against the CLI: a skill carrying `user-invocable: no` is
46 * absent from its published slash commands, so a strict `=== false` here would
47 * offer a command the CLI rejects.
48 */
49function parseFrontmatterBoolean(value: unknown): boolean | undefined {
50 if (typeof value === "boolean") return value;
51 if (typeof value === "number") {
52 return value === 1 ? true : value === 0 ? false : undefined;
53 }
54 if (typeof value !== "string") return undefined;
55 switch (value.trim().toLowerCase()) {
56 case "true":
57 case "yes":
58 case "on":
59 case "y":
60 return true;
61 case "false":
62 case "no":
63 case "off":
64 case "n":
65 return false;
66 default:
67 return undefined;
68 }
69}
70
71function parseSkillFrontmatter(contents: string): SkillFrontmatter {
72 const match = FRONTMATTER_PATTERN.exec(contents);
73 if (!match) {
74 return { kind: "missing" };
75 }
76
77 const frontmatter = match[1] ?? "";
78 let parsed: unknown;
79 try {
80 parsed = parseYamlDocument(frontmatter);
81 } catch {
82 // Claude Code accepts plain scalars containing `: `. Repair only those,
83 // leaving comments and YAML structure for the full-document parser.
84 const repaired = frontmatter.replace(
85 /^([\w-]+:[ \t]*)([^\r\n]*)/gm,
86 (line, prefix: string, value: string) => {
87 const scalar = value.split(/[ \t]+#/)[0] ?? "";
88 if (!/:[ \t]/.test(scalar) || /^(?:["'[\]{}|>&*!#%@`]|[-?:](?:[ \t]|$))/.test(scalar)) {
89 return line;
90 }
91 return `${prefix}${JSON.stringify(scalar)}${value.slice(scalar.length)}`;
92 },
93 );
94 try {
95 parsed = parseYamlDocument(repaired);
96 } catch {
97 return { kind: "malformed" };
98 }
99 }
100 if (typeof parsed !== "object" || parsed === null) {
101 return { kind: "malformed" };
102 }
103
104 const record = parsed as Record<string, unknown>;
105 const description = typeof record.description === "string" ? record.description.trim() : "";
106 return {
107 kind: "parsed",
108 ...(description ? { description } : {}),
109 ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
110 ? { userInvocationOnly: true }
111 : {}),
112 ...(parseFrontmatterBoolean(record["user-invocable"]) === false
113 ? { userInvocable: false }
114 : {}),
115 };
116}
117
118/**
119 * Where an administrator installs the policy file whose settings outrank every
120 * user and project one. Absent on almost every machine, which is why a missing
121 * file is the normal case rather than an error.
122 */
123function claudeManagedSettingsPath(
124 path: Path.Path,
125 platform: NodeJS.Platform,
126 environment: NodeJS.ProcessEnv,
127): string | undefined {
128 if (platform === "darwin") {
129 return "/Library/Application Support/ClaudeCode/managed-settings.json";
130 }
131 if (platform === "win32") {
132 const programData = environment.PROGRAMDATA?.trim();
133 return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined;
134 }
135 return "/etc/claude-code/managed-settings.json";
136}
137
138/**
139 * Settings files Claude Code merges for `skillOverrides`, in increasing
140 * precedence: user, project, project-local, then the administrator's managed
141 * policy, which wins outright. When the workspace sits inside a git
142 * repository, the repository root's `settings.local.json` is read too and
143 * outranks the workspace's own local file. Verified against the CLI from a
144 * nested cwd: a root local file switching a skill off wins over a cwd one
145 * switching it on, the root's plain `settings.json` is not consulted, and
146 * without a `.git` above the cwd no root file is read. A skill the user
147 * switched off is reported disabled rather than dropped, so the picker can
148 * grey it out instead of silently losing it.
149 */
150export function skillOverrideSettingsPaths(
151 path: Path.Path,
152 configDirPath: string,
153 cwd: string | undefined,
154 platform: NodeJS.Platform,
155 environment: NodeJS.ProcessEnv,
156 repositoryRoot?: string,
157): ReadonlyArray<string> {
158 const managedPath = claudeManagedSettingsPath(path, platform, environment);
159 const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined;
160 return [
161 path.join(configDirPath, "settings.json"),
162 ...(cwd
163 ? [
164 path.join(cwd, ".claude", "settings.json"),
165 path.join(cwd, ".claude", "settings.local.json"),
166 ]
167 : []),
168 ...(root ? [path.join(root, ".claude", "settings.local.json")] : []),
169 ...(managedPath ? [managedPath] : []),
170 ];
171}
172
173/**
174 * Nearest ancestor of `cwd` (inclusive) holding a `.git` entry, which is the
175 * boundary Claude Code walks up to for project settings. `undefined` outside
176 * a repository.
177 */
178const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* (
179 cwd: string,
180): Effect.fn.Return<string | undefined, never, FileSystem.FileSystem | Path.Path> {
181 const fileSystem = yield* FileSystem.FileSystem;
182 const path = yield* Path.Path;
183 let current = path.resolve(cwd);
184 while (true) {
185 const isRoot = yield* fileSystem
186 .exists(path.join(current, ".git"))
187 .pipe(Effect.orElseSucceed(() => false));
188 if (isRoot) {
189 return current;
190 }
191 const parent = path.dirname(current);
192 if (parent === current) {
193 return undefined;
194 }
195 current = parent;
196 }
197});
198
199/**
200 * The four states Claude Code accepts. The CLI validates the whole map, not
201 * each entry: verified against it, one entry with an unknown value (or a
202 * boolean) makes it drop every override in that file, so this schema does the
203 * same rather than applying the valid siblings the CLI ignores.
204 */
205const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]);
206
207// Lenient because these settings files are hand-edited and Claude Code itself
208// tolerates comments and trailing commas in them.
209const SkillOverrideSettings = fromLenientJson(
210 Schema.Struct({
211 skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)),
212 }),
213);
214const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings);
215
216/**
217 * What a `skillOverrides` entry says about one skill. `"user-invocable-only"`
218 * hides it from the agent exactly as `disable-model-invocation` does, so it is
219 * kept apart from a plain on/off decision rather than collapsed into one.
220 */
221type SkillOverride = {
222 readonly enabled: boolean;
223 readonly userInvocationOnly: boolean;
224};
225
226function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride {
227 switch (value) {
228 case "off":
229 return { enabled: false, userInvocationOnly: false };
230 case "user-invocable-only":
231 return { enabled: true, userInvocationOnly: true };
232 case "on":
233 case "name-only":
234 return { enabled: true, userInvocationOnly: false };
235 }
236}
237
238const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
239 configDirPath: string,
240 cwd: string | undefined,
241 environment: NodeJS.ProcessEnv,
242): Effect.fn.Return<ReadonlyMap<string, SkillOverride>, never, FileSystem.FileSystem | Path.Path> {
243 const fileSystem = yield* FileSystem.FileSystem;
244 const path = yield* Path.Path;
245 const platform = yield* HostProcess.Platform;
246 const overridesByName = new Map<string, SkillOverride>();
247 const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd);
248
249 for (const settingsPath of skillOverrideSettingsPaths(
250 path,
251 configDirPath,
252 cwd,
253 platform,
254 environment,
255 repositoryRoot,
256 )) {
257 const contents = yield* fileSystem
258 .readFileString(settingsPath)
259 .pipe(Effect.orElseSucceed(() => undefined));
260 if (contents === undefined) {
261 continue;
262 }
263
264 const parsed = yield* decodeSkillOverrideSettings(contents).pipe(
265 Effect.tapError((cause) =>
266 Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", {
267 path: settingsPath,
268 cause,
269 }),
270 ),
271 Effect.orElseSucceed(() => undefined),
272 );
273 const overrides = parsed?.skillOverrides;
274 if (!overrides) {
275 continue;
276 }
277
278 for (const [name, value] of Object.entries(overrides)) {
279 overridesByName.set(name, parseSkillOverride(value));
280 }
281 }
282
283 return overridesByName;
284});
285
286/**
287 * Resolve the Claude config directory the CLI would use, matching the
288 * precedence the spawned CLI sees: the instance's `homePath` (exported as
289 * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR`
290 * already present in the process environment, then `~/.claude`.
291 */
292const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* (
293 config: Pick<ClaudeSettings, "homePath">,
294 environment: NodeJS.ProcessEnv,
295 cwd?: string,
296): Effect.fn.Return<string, never, Path.Path> {
297 const path = yield* Path.Path;
298 const home = yield* HostProcess.HomeDirectory;
299 const homePath = config.homePath.trim();
300 if (homePath.length > 0) {
301 return path.resolve(expandHomePath(homePath, home));
302 }
303 // No tilde expansion here: the spawned CLI receives this env var verbatim
304 // (env vars are never shell-expanded), so a literal `~` must stay literal
305 // for discovery to scan the same directory the runtime would. A relative
306 // value is resolved against the workspace cwd — the subprocess's own cwd —
307 // for the same reason.
308 const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
309 if (environmentConfigDir.length > 0) {
310 return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir);
311 }
312 return path.join(home, ".claude");
313});
314
315/**
316 * Enumerate Claude Code skills from the user config dir and the workspace
317 * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed
318 * skill entries are skipped so a broken skill never degrades the provider
319 * snapshot. Roots are listed highest precedence first and the first hit for a
320 * name wins, matching Claude Code: verified against the CLI with the same
321 * skill name in both scopes, the user copy is the one that runs. Reporting the
322 * project copy instead would attach its invocation metadata to a command
323 * Claude Code resolves elsewhere.
324 */
325export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
326 config: Pick<ClaudeSettings, "homePath">,
327 cwd?: string,
328 environment?: NodeJS.ProcessEnv,
329): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
330 const fileSystem = yield* FileSystem.FileSystem;
331 const path = yield* Path.Path;
332 const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
333 const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env);
334
335 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
336 { directory: path.join(configDirPath, "skills"), scope: "user" },
337 ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
338 ];
339
340 const skillsByName = new Map<string, ServerProviderSkill>();
341 for (const root of roots) {
342 const entries = yield* fileSystem
343 .readDirectory(root.directory)
344 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
345
346 for (const entry of [...entries].sort()) {
347 const skillPath = path.join(root.directory, entry, "SKILL.md");
348 const contents = yield* fileSystem
349 .readFileString(skillPath)
350 .pipe(Effect.orElseSucceed(() => undefined));
351 if (contents === undefined) {
352 continue;
353 }
354
355 const frontmatter = parseSkillFrontmatter(contents);
356 // Malformed frontmatter means the skill won't load in Claude Code
357 // either — skip it rather than surfacing a broken entry under its
358 // directory name.
359 if (frontmatter.kind === "malformed") {
360 continue;
361 }
362
363 // Claude Code identifies a skill by its directory, not by the
364 // frontmatter `name`: verified against the CLI, a skill in `probe-alias/`
365 // declaring `name: probe-alias-frontmatter` is published as
366 // `probe-alias`, and only `skillOverrides["probe-alias"]` switches it
367 // off. Keying off the frontmatter name would report a command that does
368 // not exist and miss the override that disables it.
369 const name = entry.trim();
370 if (!name) {
371 continue;
372 }
373
374 // First root wins, so a later root never displaces a higher-precedence
375 // skill of the same name.
376 if (skillsByName.has(name)) {
377 continue;
378 }
379
380 const override = skillOverrides.get(name);
381 const userInvocationOnly =
382 (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) ||
383 override?.userInvocationOnly === true;
384 skillsByName.set(name, {
385 name,
386 path: skillPath,
387 enabled: override?.enabled ?? true,
388 scope: root.scope,
389 ...(frontmatter.kind === "parsed" && frontmatter.description
390 ? { description: frontmatter.description }
391 : {}),
392 ...(userInvocationOnly ? { userInvocationOnly: true } : {}),
393 ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false
394 ? { userInvocable: false }
395 : {}),
396 });
397 }
398 }
399
400 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
401});