155 lines · 5.8 KB · TypeScript
RawDownload
/** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * * Claude Code loads skills from `<config dir>/skills` (user scope), then * `<cwd>/.agents/skills` and `<cwd>/.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<string, unknown>;  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<ClaudeSettings, "homePath">,  environment: NodeJS.ProcessEnv,  cwd?: string,): Effect.fn.Return<string, never, Path.Path> {  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<ClaudeSettings, "homePath">,  cwd?: string,  environment?: NodeJS.ProcessEnv,): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, 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<string, ServerProviderSkill>();  for (const root of roots) {    const entries = yield* fileSystem      .readDirectory(root.directory)      .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));     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));});