| 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 | |
| 17 | import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as FileSystem from "effect/FileSystem"; |
| 20 | import * as Path from "effect/Path"; |
| 21 | import * as Schema from "effect/Schema"; |
| 22 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 23 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 24 | import { parse as parseYamlDocument } from "yaml"; |
| 25 | |
| 26 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 27 | |
| 28 | type ClaudeSkillScope = "user" | "project"; |
| 29 | |
| 30 | const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; |
| 31 | |
| 32 | type 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 | */ |
| 49 | function 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 | |
| 71 | function 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 | */ |
| 123 | function 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 | */ |
| 150 | export 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 | */ |
| 178 | const 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 | */ |
| 205 | const 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. |
| 209 | const SkillOverrideSettings = fromLenientJson( |
| 210 | Schema.Struct({ |
| 211 | skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)), |
| 212 | }), |
| 213 | ); |
| 214 | const 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 | */ |
| 221 | type SkillOverride = { |
| 222 | readonly enabled: boolean; |
| 223 | readonly userInvocationOnly: boolean; |
| 224 | }; |
| 225 | |
| 226 | function 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 | |
| 238 | const 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 | */ |
| 292 | const 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 | */ |
| 325 | export 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 | }); |