/** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * * Claude Code loads skills from `/skills` (user scope), then * `/.agents/skills` and `/.claude/skills` (project scope), one * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots * win on name collisions, so precedence is user, `.agents`, then `.claude`. * The Agent SDK init handshake surfaces skills only as slash commands without * their filesystem paths, so the provider snapshot scans the same locations * directly, mirroring how the Codex app-server reports its skills. * * @module provider/Drivers/ClaudeSkills */ import * as NodeOS from "node:os"; import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "../../pathExpansion.ts"; type ClaudeSkillScope = "user" | "project"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; type SkillFrontmatter = | { readonly kind: "missing" } | { readonly kind: "malformed" } | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); if (!match) { return { kind: "missing" }; } let parsed: unknown; try { parsed = parseYamlDocument(match[1] ?? ""); } catch { return { kind: "malformed" }; } if (typeof parsed !== "object" || parsed === null) { return { kind: "malformed" }; } const record = parsed as Record; const name = typeof record.name === "string" ? record.name.trim() : ""; const description = typeof record.description === "string" ? record.description.trim() : ""; return { kind: "parsed", ...(name ? { name } : {}), ...(description ? { description } : {}), }; } /** * Resolve the Claude config directory the CLI would use, matching the * precedence the spawned CLI sees: the instance's `homePath` (exported as * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` * already present in the process environment, then `~/.claude`. */ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( config: Pick, environment: NodeJS.ProcessEnv, cwd?: string, ): Effect.fn.Return { const path = yield* Path.Path; const homePath = config.homePath.trim(); if (homePath.length > 0) { return path.resolve(expandHomePath(homePath)); } // No tilde expansion here: the spawned CLI receives this env var verbatim // (env vars are never shell-expanded), so a literal `~` must stay literal // for discovery to scan the same directory the runtime would. A relative // value is resolved against the workspace cwd — the subprocess's own cwd — // for the same reason. const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (environmentConfigDir.length > 0) { return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); } return path.join(NodeOS.homedir(), ".claude"); }); /** * Enumerate Claude Code skills from the user config dir, workspace * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery * is best-effort: unreadable roots and malformed skill entries are skipped so * a broken skill never degrades the provider snapshot. On name collisions, * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching * Claude Code's resolution. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, cwd?: string, environment?: NodeJS.ProcessEnv, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [ { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, ] : []), ]; const skillsByName = new Map(); for (const root of roots) { const entries = yield* fileSystem .readDirectory(root.directory) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); for (const entry of [...entries].sort()) { const skillPath = path.join(root.directory, entry, "SKILL.md"); const contents = yield* fileSystem .readFileString(skillPath) .pipe(Effect.orElseSucceed(() => undefined)); if (contents === undefined) { continue; } const frontmatter = parseSkillFrontmatter(contents); // Malformed frontmatter means the skill won't load in Claude Code // either — skip it rather than surfacing a broken entry under its // directory name. if (frontmatter.kind === "malformed") { continue; } const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); if (!name) { continue; } skillsByName.set(name, { name, path: skillPath, enabled: true, scope: root.scope, ...(frontmatter.kind === "parsed" && frontmatter.description ? { description: frontmatter.description } : {}), }); } } return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); });