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 * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; type ClaudeSkillScope = "user" | "project"; type ClaudeSkillRoot = { readonly directory: string; readonly scope: ClaudeSkillScope; readonly prefix?: string; }; 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; readonly userInvocationOnly?: boolean; readonly userInvocable?: boolean; }; /** Claude accepts YAML 1.1 booleans that this YAML 1.2 parser leaves as strings or numbers. */ function parseFrontmatterBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "number") { return value === 1 ? true : value === 0 ? false : undefined; } if (typeof value !== "string") return undefined; switch (value.trim().toLowerCase()) { case "true": case "yes": case "on": case "y": return true; case "false": case "no": case "off": case "n": return false; default: return undefined; } } function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); if (!match) { return { kind: "missing" }; } const frontmatter = match[1] ?? ""; let parsed: unknown; try { parsed = parseYamlDocument(frontmatter); } catch { // Repair Claude's accepted ': ' scalars without disturbing comments or YAML structure. const repaired = frontmatter.replace( /^([\w-]+:[ \t]*)([^\r\n]*)/gm, (line, prefix: string, value: string) => { const scalar = value.split(/[ \t]+#/)[0] ?? ""; if (!/:[ \t]/.test(scalar) || /^(?:["'[\]{}|>&*!#%@`]|[-?:](?:[ \t]|$))/.test(scalar)) { return line; } return `${prefix}${JSON.stringify(scalar)}${value.slice(scalar.length)}`; }, ); try { parsed = parseYamlDocument(repaired); } 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 } : {}), ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true ? { userInvocationOnly: true } : {}), ...(parseFrontmatterBoolean(record["user-invocable"]) === false ? { userInvocable: false } : {}), }; } function claudeManagedSettingsPath( path: Path.Path, platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, ): string | undefined { if (platform === "darwin") { return "/Library/Application Support/ClaudeCode/managed-settings.json"; } if (platform === "win32") { const programData = environment.PROGRAMDATA?.trim(); return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined; } return "/etc/claude-code/managed-settings.json"; } /** Claude Code settings files, lowest precedence first. */ function claudeSettingsPaths( path: Path.Path, configDirPath: string, cwd: string | undefined, platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, repositoryRoot?: string, ): ReadonlyArray { const managedPath = claudeManagedSettingsPath(path, platform, environment); const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined; return [ path.join(configDirPath, "settings.json"), ...(cwd ? [ path.join(cwd, ".claude", "settings.json"), path.join(cwd, ".claude", "settings.local.json"), ] : []), ...(root ? [path.join(root, ".claude", "settings.local.json")] : []), ...(managedPath ? [managedPath] : []), ]; } const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( cwd: string, ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; let current = path.resolve(cwd); while (true) { const isRoot = yield* fileSystem .exists(path.join(current, ".git")) .pipe(Effect.orElseSucceed(() => false)); if (isRoot) { return current; } const parent = path.dirname(current); if (parent === current) { return undefined; } current = parent; } }); const findPluginRepositoryRoot = Effect.fn("findPluginRepositoryRoot")(function* (cwd: string) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* findRepositoryRoot(cwd); if (root === undefined) return undefined; const gitFile = yield* fileSystem .readFileString(path.join(root, ".git")) .pipe(Effect.orElseSucceed(() => "")); if (!gitFile.trim().startsWith("gitdir:")) return root; const gitDirectory = path.resolve(root, gitFile.trim().slice(7).trim()); const commonFile = yield* fileSystem .readFileString(path.join(gitDirectory, "commondir")) .pipe(Effect.orElseSucceed(() => "")); const worktreeFile = yield* fileSystem .readFileString(path.join(gitDirectory, "gitdir")) .pipe(Effect.orElseSucceed(() => "")); if (!commonFile.trim() || !worktreeFile.trim()) return root; const commonDirectory = path.resolve(gitDirectory, commonFile.trim()); const backlinkPath = yield* fileSystem .realPath(path.resolve(gitDirectory, worktreeFile.trim())) .pipe(Effect.orElseSucceed(() => undefined)); const worktreeGitPath = yield* fileSystem .realPath(path.join(root, ".git")) .pipe(Effect.orElseSucceed(() => undefined)); if ( path.dirname(gitDirectory) !== path.join(commonDirectory, "worktrees") || backlinkPath === undefined || backlinkPath !== worktreeGitPath ) { return root; } // Claude shares plugin install records across linked worktrees, using their common repository. return path.basename(commonDirectory) === ".git" ? path.dirname(commonDirectory) : commonDirectory; }); /** * The CLI validates the whole map, not each entry: verified against it, one * entry with an unknown value (or a boolean) makes it drop every override in * that file, so this schema does the same rather than applying the valid * siblings the CLI ignores. */ const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]); // Lenient because these settings files are hand-edited and Claude Code itself // tolerates comments and trailing commas in them. const SkillSettings = fromLenientJson( Schema.Struct({ skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)), enabledPlugins: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), ); const decodeSkillSettings = Schema.decodeUnknownEffect(SkillSettings); type SkillOverride = { readonly enabled: boolean; readonly userInvocationOnly: boolean; }; function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride { switch (value) { case "off": return { enabled: false, userInvocationOnly: false }; case "user-invocable-only": return { enabled: true, userInvocationOnly: true }; case "on": case "name-only": return { enabled: true, userInvocationOnly: false }; } } const readSkillSettings = Effect.fn("readSkillSettings")(function* ( configDirPath: string, cwd: string | undefined, environment: NodeJS.ProcessEnv, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const overridesByName = new Map(); const enabledPlugins = new Map(); const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); for (const settingsPath of claudeSettingsPaths( path, configDirPath, cwd, platform, environment, repositoryRoot, )) { const contents = yield* fileSystem .readFileString(settingsPath) .pipe(Effect.orElseSucceed(() => undefined)); if (contents === undefined) { continue; } const parsed = yield* decodeSkillSettings(contents).pipe( Effect.tapError((cause) => Effect.logDebug("claude settings file is unreadable; ignoring skill settings", { path: settingsPath, cause, }), ), Effect.orElseSucceed(() => undefined), ); for (const [name, value] of Object.entries(parsed?.skillOverrides ?? {})) { overridesByName.set(name, parseSkillOverride(value)); } for (const [key, enabled] of Object.entries(parsed?.enabledPlugins ?? {})) { if (typeof enabled === "boolean") { enabledPlugins.set(key, enabled); } else { // Extended formats replace lower-precedence values without enabling the plugin here. enabledPlugins.delete(key); } } } return { skillOverrides: overridesByName, enabledPlugins }; }); const InstalledPlugins = Schema.fromJsonString( Schema.Struct({ plugins: Schema.Record(Schema.String, Schema.Array(Schema.Unknown)) }), ); const decodeInstalledPlugin = Schema.decodeUnknownOption( Schema.Struct({ scope: Schema.Literals(["user", "project", "local", "managed"]), installPath: Schema.NonEmptyString, projectPath: Schema.optional(Schema.NonEmptyString), }), ); const PluginManifest = Schema.fromJsonString( Schema.Struct({ name: Schema.optional(Schema.NonEmptyString), skills: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), }), ); function isInsideDirectory(path: Path.Path, directory: string, candidate: string): boolean { const relative = path.relative(directory, candidate); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); } const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* ( configDirPath: string, cwd: string | undefined, enabledPlugins: ReadonlyMap, environment: NodeJS.ProcessEnv, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; const pluginDirectory = environment.CLAUDE_CODE_PLUGIN_CACHE_DIR; const pluginsPath = pluginDirectory ? path.resolve(cwd ?? ".", expandHomePath(pluginDirectory, home)) : path.join(configDirPath, "plugins"); const installed = yield* fileSystem .readFileString(path.join(pluginsPath, "installed_plugins.json")) .pipe( Effect.flatMap(Schema.decodeUnknownEffect(InstalledPlugins)), Effect.orElseSucceed(() => undefined), ); if (installed === undefined) return []; const roots: Array = []; // The CLI's process.cwd() resolves symlinks; registry projectPath values remain lexical. const workspace = cwd === undefined ? undefined : yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => path.resolve(cwd))); const repositoryRoot = workspace === undefined ? undefined : yield* findPluginRepositoryRoot(workspace); for (const [key, entries] of Object.entries(installed.plugins).sort()) { if (enabledPlugins.get(key) !== true) continue; const applicable = yield* Effect.filter( entries.flatMap((entry) => Option.toArray(decodeInstalledPlugin(entry))), (record) => Effect.gen(function* () { if (record.scope === "user" || record.scope === "managed") return true; if (record.projectPath === undefined || workspace === undefined) return false; if (path.resolve(record.projectPath) === workspace) return true; return ( repositoryRoot !== undefined && (yield* findPluginRepositoryRoot(record.projectPath)) === repositoryRoot ); }), ); // The native loader keeps registry order, preferring the first install that exists. const existing = yield* Effect.findFirst(applicable, (record) => fileSystem .exists(path.resolve(cwd ?? ".", record.installPath)) .pipe(Effect.orElseSucceed(() => false)), ); const install = Option.getOrElse(existing, () => applicable[0]); if (!install) continue; const scope = install.scope === "user" || install.scope === "managed" ? "user" : "project"; const installPath = path.resolve(cwd ?? ".", install.installPath); const manifest = yield* fileSystem .readFileString(path.join(installPath, ".claude-plugin", "plugin.json")) .pipe( Effect.flatMap(Schema.decodeUnknownEffect(PluginManifest)), Effect.orElseSucceed(() => undefined), ); const prefix = manifest?.name ?? key.split("@")[0]; if (!prefix) continue; const defaultDirectory = path.join(installPath, "skills"); const hasDefaultDirectory = yield* fileSystem .exists(defaultDirectory) .pipe(Effect.orElseSucceed(() => false)); if (hasDefaultDirectory) roots.push({ directory: defaultDirectory, scope, prefix }); const declared = manifest?.skills; if (declared === undefined && !hasDefaultDirectory) { const hasRootSkill = yield* fileSystem .exists(path.join(installPath, "SKILL.md")) .pipe(Effect.orElseSucceed(() => false)); if (hasRootSkill) roots.push({ directory: installPath, scope, prefix }); } const canonicalInstallPath = yield* fileSystem .realPath(installPath) .pipe(Effect.orElseSucceed(() => undefined)); for (const relative of typeof declared === "string" ? [declared] : (declared ?? [])) { const directory = path.resolve(installPath, relative); if (!isInsideDirectory(path, installPath, directory) || canonicalInstallPath === undefined) { continue; } const canonicalDirectory = yield* fileSystem .realPath(directory) .pipe(Effect.orElseSucceed(() => undefined)); // The native loader rejects manifest roots that escape through a symlink, too. if ( canonicalDirectory !== undefined && isInsideDirectory(path, canonicalInstallPath, canonicalDirectory) ) { roots.push({ directory, scope, prefix }); } } } return roots; }); const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( config: Pick, environment: NodeJS.ProcessEnv, cwd?: string, ): Effect.fn.Return { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; const homePath = config.homePath.trim(); if (homePath.length > 0) { return path.resolve(expandHomePath(homePath, home)); } // Match the spawned CLI: preserve a literal ~ and resolve relative config against workspace cwd. const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (environmentConfigDir.length > 0) { return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); } return path.join(home, ".claude"); }); const SETTINGS_ENV_NAMES = [ "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_ENTRYPOINT", ] as const; export type ClaudeSettingsEnv = Partial>; // Strict like Claude Code, which skips a settings file with comments or trailing commas. const decodeSettingsEnv = Schema.decodeUnknownEffect( Schema.fromJsonString( Schema.Struct({ env: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)) }), ), ); function assignSettingsEnv( target: ClaudeSettingsEnv, env: Readonly> | undefined, ): void { for (const name of SETTINGS_ENV_NAMES) { const value = env?.[name]; if (typeof value === "string") target[name] = value; } } const readSettingsFileEnv = Effect.fn("readSettingsFileEnv")(function* (settingsPath: string) { const fileSystem = yield* FileSystem.FileSystem; const contents = yield* fileSystem .readFileString(settingsPath) .pipe(Effect.orElseSucceed(() => undefined)); if (contents === undefined) return undefined; const parsed = yield* decodeSettingsEnv(contents).pipe(Effect.orElseSucceed(() => undefined)); return parsed?.env; }); /** The `env` values T3 Code reads from Claude's user and managed settings; later files win. */ export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* ( config: Pick, cwd: string | undefined, environment: NodeJS.ProcessEnv, ): Effect.fn.Return { const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); const settingsEnv: ClaudeSettingsEnv = {}; for (const settingsPath of claudeSettingsPaths( path, configDirPath, undefined, platform, environment, )) { assignSettingsEnv(settingsEnv, yield* readSettingsFileEnv(settingsPath)); } return settingsEnv; }); // A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to //
/.git. A bare repository has no main checkout, so its worktree keeps its own file. const mainCheckoutRoot = Effect.fn("mainCheckoutRoot")( function* (checkout: string) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const pointer = yield* fileSystem.readFileString(path.join(checkout, ".git")); const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); if (gitDir === undefined) return checkout; const worktreeGitDir = path.resolve(checkout, gitDir); const commonDir = yield* fileSystem.readFileString(path.join(worktreeGitDir, "commondir")); const common = path.resolve(worktreeGitDir, commonDir.trim()); return path.basename(common) === ".git" ? path.dirname(common) : checkout; }, (effect, checkout) => effect.pipe(Effect.orElseSucceed(() => checkout)), ); /** * Claude Code keeps .claude/settings.local.json at the repository root, or in cwd outside a * repository, on Windows, or when the root is home. Its owner check is skipped: callers treat a * file it would ignore the same as the settings it applies. */ export const resolveClaudeLocalSettingsDirectory = Effect.fn("resolveClaudeLocalSettingsDirectory")( function* (cwd: string) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; if ((yield* HostProcess.Platform) === "win32") return cwd; for (let directory = cwd; ; directory = path.dirname(directory)) { const isCheckout = yield* fileSystem .exists(path.join(directory, ".git")) .pipe(Effect.orElseSucceed(() => false)); if (isCheckout) { const root = yield* mainCheckoutRoot(directory); return root === (yield* HostProcess.HomeDirectory) ? cwd : root; } if (path.dirname(directory) === directory) return cwd; } }, ); /** * The `env` values a Claude process in `cwd` sees: user, project, local, then --settings, with * managed settings over all of them. Reads the files each call so a new session sees edits. */ export const readClaudeSessionSettingsEnv = Effect.fn("readClaudeSessionSettingsEnv")(function* ( config: Pick, cwd: string | undefined, environment: NodeJS.ProcessEnv, launchArgEnv?: Readonly>, ): Effect.fn.Return { const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); const managedPath = claudeManagedSettingsPath(path, platform, environment); const localDirectory = cwd === undefined ? undefined : yield* resolveClaudeLocalSettingsDirectory(cwd); const settingsEnv: ClaudeSettingsEnv = {}; for (const settingsPath of claudeSettingsPaths( path, configDirPath, cwd, platform, environment, localDirectory, )) { if (settingsPath !== managedPath) { assignSettingsEnv(settingsEnv, yield* readSettingsFileEnv(settingsPath)); } } assignSettingsEnv(settingsEnv, launchArgEnv); if (managedPath !== undefined) { assignSettingsEnv(settingsEnv, yield* readSettingsFileEnv(managedPath)); } return settingsEnv; }); // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { const directories: string[] = []; let collecting = false; for (const token of tokenizeCliArgs(launchArgs)) { if (token === "--") break; if (token === "--add-dir") { collecting = true; } else if (token.startsWith("--add-dir=")) { directories.push(token.slice("--add-dir=".length)); collecting = false; } else if (token.startsWith("-")) { collecting = false; } else if (collecting) { directories.push(token); } } return directories; } /** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, 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 { skillOverrides, enabledPlugins } = yield* readSkillSettings( configDirPath, cwd, environment ?? process.env, ); const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), ...claudeLaunchArgDirectories(config.launchArgs) .filter((directory) => directory && (cwd || path.isAbsolute(directory))) .map((directory) => ({ directory: path.join(path.resolve(cwd ?? ".", directory), ".claude", "skills"), scope: "project" as const, })), ...(yield* discoverPluginSkillRoots( configDirPath, cwd, enabledPlugins, environment ?? process.env, )), ]; const skillsByName = new Map(); for (const root of roots) { const singleSkill = root.prefix !== undefined && (yield* fileSystem .exists(path.join(root.directory, "SKILL.md")) .pipe(Effect.orElseSucceed(() => false))); const entries = singleSkill ? [path.basename(root.directory)] : yield* fileSystem .readDirectory(root.directory) .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); for (const entry of [...entries].sort()) { const skillPath = singleSkill ? path.join(root.directory, "SKILL.md") : 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); if (frontmatter.kind === "malformed") { continue; } const directoryName = entry.trim(); if (!directoryName) continue; let name = directoryName; if (root.prefix) { // Native folder skills use their folder; only a single SKILL.md uses its name field. const declaredName = singleSkill && frontmatter.kind === "parsed" ? frontmatter.name : ""; const unprefixedName = declaredName?.startsWith(`${root.prefix}:`) ? declaredName.slice(root.prefix.length + 1) : declaredName; name = `${root.prefix}:${(unprefixedName || entry).replace(/[^a-zA-Z0-9_-]/g, "-")}`; // Advertise only exact native names the shared composer grammar can invoke. const [mention] = `$${name}`.matchAll(SKILL_MENTION_PATTERN); if (mention?.[2] !== name) continue; } if (skillsByName.has(name)) { continue; } const override = root.prefix ? undefined : skillOverrides.get(name); const userInvocationOnly = (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) || override?.userInvocationOnly === true; skillsByName.set(name, { name, path: skillPath, enabled: override?.enabled ?? true, scope: root.scope, ...(frontmatter.kind === "parsed" && frontmatter.description ? { description: frontmatter.description } : {}), ...(userInvocationOnly ? { userInvocationOnly: true } : {}), ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false ? { userInvocable: false } : {}), }); } } return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); });