| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086 |
- import { realpath } from 'fs/promises'
- import ignore from 'ignore'
- import memoize from 'lodash-es/memoize.js'
- import {
- basename,
- dirname,
- isAbsolute,
- join,
- sep as pathSep,
- relative,
- } from 'path'
- import {
- getAdditionalDirectoriesForClaudeMd,
- getSessionId,
- } from '../bootstrap/state.js'
- import {
- type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- logEvent,
- } from '../services/analytics/index.js'
- import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
- import type { Command, PromptCommand } from '../types/command.js'
- import {
- parseArgumentNames,
- substituteArguments,
- } from '../utils/argumentSubstitution.js'
- import { logForDebugging } from '../utils/debug.js'
- import {
- EFFORT_LEVELS,
- type EffortValue,
- parseEffortValue,
- } from '../utils/effort.js'
- import {
- getClaudeConfigHomeDir,
- isBareMode,
- isEnvTruthy,
- } from '../utils/envUtils.js'
- import { isENOENT, isFsInaccessible } from '../utils/errors.js'
- import {
- coerceDescriptionToString,
- type FrontmatterData,
- type FrontmatterShell,
- parseBooleanFrontmatter,
- parseFrontmatter,
- parseShellFrontmatter,
- splitPathInFrontmatter,
- } from '../utils/frontmatterParser.js'
- import { getFsImplementation } from '../utils/fsOperations.js'
- import { isPathGitignored } from '../utils/git/gitignore.js'
- import { logError } from '../utils/log.js'
- import {
- extractDescriptionFromMarkdown,
- getProjectDirsUpToHome,
- loadMarkdownFilesForSubdir,
- type MarkdownFile,
- parseSlashCommandToolsFromFrontmatter,
- } from '../utils/markdownConfigLoader.js'
- import { parseUserSpecifiedModel } from '../utils/model/model.js'
- import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
- import type { SettingSource } from '../utils/settings/constants.js'
- import { isSettingSourceEnabled } from '../utils/settings/constants.js'
- import { getManagedFilePath } from '../utils/settings/managedPath.js'
- import { isRestrictedToPluginOnly } from '../utils/settings/pluginOnlyPolicy.js'
- import { HooksSchema, type HooksSettings } from '../utils/settings/types.js'
- import { createSignal } from '../utils/signal.js'
- import { registerMCPSkillBuilders } from './mcpSkillBuilders.js'
- export type LoadedFrom =
- | 'commands_DEPRECATED'
- | 'skills'
- | 'plugin'
- | 'managed'
- | 'bundled'
- | 'mcp'
- /**
- * Returns a claude config directory path for a given source.
- */
- export function getSkillsPath(
- source: SettingSource | 'plugin',
- dir: 'skills' | 'commands',
- ): string {
- switch (source) {
- case 'policySettings':
- return join(getManagedFilePath(), '.claude', dir)
- case 'userSettings':
- return join(getClaudeConfigHomeDir(), dir)
- case 'projectSettings':
- return `.claude/${dir}`
- case 'plugin':
- return 'plugin'
- default:
- return ''
- }
- }
- /**
- * Estimates token count for a skill based on frontmatter only
- * (name, description, whenToUse) since full content is only loaded on invocation.
- */
- export function estimateSkillFrontmatterTokens(skill: Command): number {
- const frontmatterText = [skill.name, skill.description, skill.whenToUse]
- .filter(Boolean)
- .join(' ')
- return roughTokenCountEstimation(frontmatterText)
- }
- /**
- * Gets a unique identifier for a file by resolving symlinks to a canonical path.
- * This allows detection of duplicate files accessed through different paths
- * (e.g., via symlinks or overlapping parent directories).
- * Returns null if the file doesn't exist or can't be resolved.
- *
- * Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids
- * issues with filesystems that report unreliable inode values (e.g., inode 0 on
- * some virtual/container/NFS filesystems, or precision loss on ExFAT).
- * See: https://github.com/anthropics/claude-code/issues/13893
- */
- async function getFileIdentity(filePath: string): Promise<string | null> {
- try {
- return await realpath(filePath)
- } catch {
- return null
- }
- }
- // Internal type to track skill with its file path for deduplication
- type SkillWithPath = {
- skill: Command
- filePath: string
- }
- /**
- * Parse and validate hooks from frontmatter.
- * Returns undefined if hooks are not defined or invalid.
- */
- function parseHooksFromFrontmatter(
- frontmatter: FrontmatterData,
- skillName: string,
- ): HooksSettings | undefined {
- if (!frontmatter.hooks) {
- return undefined
- }
- const result = HooksSchema().safeParse(frontmatter.hooks)
- if (!result.success) {
- logForDebugging(
- `Invalid hooks in skill '${skillName}': ${result.error.message}`,
- )
- return undefined
- }
- return result.data
- }
- /**
- * Parse paths frontmatter from a skill, using the same format as CLAUDE.md rules.
- * Returns undefined if no paths are specified or if all patterns are match-all.
- */
- function parseSkillPaths(frontmatter: FrontmatterData): string[] | undefined {
- if (!frontmatter.paths) {
- return undefined
- }
- const patterns = splitPathInFrontmatter(frontmatter.paths)
- .map(pattern => {
- // Remove /** suffix - ignore library treats 'path' as matching both
- // the path itself and everything inside it
- return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern
- })
- .filter((p: string) => p.length > 0)
- // If all patterns are ** (match-all), treat as no paths (undefined)
- if (patterns.length === 0 || patterns.every((p: string) => p === '**')) {
- return undefined
- }
- return patterns
- }
- /**
- * Parses all skill frontmatter fields that are shared between file-based and
- * MCP skill loading. Caller supplies the resolved skill name and the
- * source/loadedFrom/baseDir/paths fields separately.
- */
- export function parseSkillFrontmatterFields(
- frontmatter: FrontmatterData,
- markdownContent: string,
- resolvedName: string,
- descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill',
- ): {
- displayName: string | undefined
- description: string
- hasUserSpecifiedDescription: boolean
- allowedTools: string[]
- argumentHint: string | undefined
- argumentNames: string[]
- whenToUse: string | undefined
- version: string | undefined
- model: ReturnType<typeof parseUserSpecifiedModel> | undefined
- disableModelInvocation: boolean
- userInvocable: boolean
- hooks: HooksSettings | undefined
- executionContext: 'fork' | undefined
- agent: string | undefined
- effort: EffortValue | undefined
- shell: FrontmatterShell | undefined
- } {
- const validatedDescription = coerceDescriptionToString(
- frontmatter.description,
- resolvedName,
- )
- const description =
- validatedDescription ??
- extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel)
- const userInvocable =
- frontmatter['user-invocable'] === undefined
- ? true
- : parseBooleanFrontmatter(frontmatter['user-invocable'])
- const model =
- frontmatter.model === 'inherit'
- ? undefined
- : frontmatter.model
- ? parseUserSpecifiedModel(frontmatter.model as string)
- : undefined
- const effortRaw = frontmatter['effort']
- const effort =
- effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
- if (effortRaw !== undefined && effort === undefined) {
- logForDebugging(
- `Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
- )
- }
- return {
- displayName:
- frontmatter.name != null ? String(frontmatter.name) : undefined,
- description,
- hasUserSpecifiedDescription: validatedDescription !== null,
- allowedTools: parseSlashCommandToolsFromFrontmatter(
- frontmatter['allowed-tools'],
- ),
- argumentHint:
- frontmatter['argument-hint'] != null
- ? String(frontmatter['argument-hint'])
- : undefined,
- argumentNames: parseArgumentNames(
- frontmatter.arguments as string | string[] | undefined,
- ),
- whenToUse: frontmatter.when_to_use as string | undefined,
- version: frontmatter.version as string | undefined,
- model,
- disableModelInvocation: parseBooleanFrontmatter(
- frontmatter['disable-model-invocation'],
- ),
- userInvocable,
- hooks: parseHooksFromFrontmatter(frontmatter, resolvedName),
- executionContext: frontmatter.context === 'fork' ? 'fork' : undefined,
- agent: frontmatter.agent as string | undefined,
- effort,
- shell: parseShellFrontmatter(frontmatter.shell, resolvedName),
- }
- }
- /**
- * Creates a skill command from parsed data
- */
- export function createSkillCommand({
- skillName,
- displayName,
- description,
- hasUserSpecifiedDescription,
- markdownContent,
- allowedTools,
- argumentHint,
- argumentNames,
- whenToUse,
- version,
- model,
- disableModelInvocation,
- userInvocable,
- source,
- baseDir,
- loadedFrom,
- hooks,
- executionContext,
- agent,
- paths,
- effort,
- shell,
- }: {
- skillName: string
- displayName: string | undefined
- description: string
- hasUserSpecifiedDescription: boolean
- markdownContent: string
- allowedTools: string[]
- argumentHint: string | undefined
- argumentNames: string[]
- whenToUse: string | undefined
- version: string | undefined
- model: string | undefined
- disableModelInvocation: boolean
- userInvocable: boolean
- source: PromptCommand['source']
- baseDir: string | undefined
- loadedFrom: LoadedFrom
- hooks: HooksSettings | undefined
- executionContext: 'inline' | 'fork' | undefined
- agent: string | undefined
- paths: string[] | undefined
- effort: EffortValue | undefined
- shell: FrontmatterShell | undefined
- }): Command {
- return {
- type: 'prompt',
- name: skillName,
- description,
- hasUserSpecifiedDescription,
- allowedTools,
- argumentHint,
- argNames: argumentNames.length > 0 ? argumentNames : undefined,
- whenToUse,
- version,
- model,
- disableModelInvocation,
- userInvocable,
- context: executionContext,
- agent,
- effort,
- paths,
- contentLength: markdownContent.length,
- isHidden: !userInvocable,
- progressMessage: 'running',
- userFacingName(): string {
- return displayName || skillName
- },
- source,
- loadedFrom,
- hooks,
- skillRoot: baseDir,
- async getPromptForCommand(args, toolUseContext) {
- let finalContent = baseDir
- ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
- : markdownContent
- finalContent = substituteArguments(
- finalContent,
- args,
- true,
- argumentNames,
- )
- // Replace ${CLAUDE_SKILL_DIR} with the skill's own directory so bash
- // injection (!`...`) can reference bundled scripts. Normalize backslashes
- // to forward slashes on Windows so shell commands don't treat them as escapes.
- if (baseDir) {
- const skillDir =
- process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir
- finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
- }
- // Replace ${CLAUDE_SESSION_ID} with the current session ID
- finalContent = finalContent.replace(
- /\$\{CLAUDE_SESSION_ID\}/g,
- getSessionId(),
- )
- // Security: MCP skills are remote and untrusted — never execute inline
- // shell commands (!`…` / ```! … ```) from their markdown body.
- // ${CLAUDE_SKILL_DIR} is meaningless for MCP skills anyway.
- if (loadedFrom !== 'mcp') {
- finalContent = await executeShellCommandsInPrompt(
- finalContent,
- {
- ...toolUseContext,
- getAppState() {
- const appState = toolUseContext.getAppState()
- return {
- ...appState,
- toolPermissionContext: {
- ...appState.toolPermissionContext,
- alwaysAllowRules: {
- ...appState.toolPermissionContext.alwaysAllowRules,
- command: allowedTools,
- },
- },
- }
- },
- },
- `/${skillName}`,
- shell,
- )
- }
- return [{ type: 'text', text: finalContent }]
- },
- } satisfies Command
- }
- /**
- * Loads skills from a /skills/ directory path.
- * Only supports directory format: skill-name/SKILL.md
- */
- async function loadSkillsFromSkillsDir(
- basePath: string,
- source: SettingSource,
- ): Promise<SkillWithPath[]> {
- const fs = getFsImplementation()
- let entries
- try {
- entries = await fs.readdir(basePath)
- } catch (e: unknown) {
- if (!isFsInaccessible(e)) logError(e)
- return []
- }
- const results = await Promise.all(
- entries.map(async (entry): Promise<SkillWithPath | null> => {
- try {
- // Only support directory format: skill-name/SKILL.md
- if (!entry.isDirectory() && !entry.isSymbolicLink()) {
- // Single .md files are NOT supported in /skills/ directory
- return null
- }
- const skillDirPath = join(basePath, entry.name)
- const skillFilePath = join(skillDirPath, 'SKILL.md')
- let content: string
- try {
- content = await fs.readFile(skillFilePath, { encoding: 'utf-8' })
- } catch (e: unknown) {
- // SKILL.md doesn't exist, skip this entry. Log non-ENOENT errors
- // (EACCES/EPERM/EIO) so permission/IO problems are diagnosable.
- if (!isENOENT(e)) {
- logForDebugging(`[skills] failed to read ${skillFilePath}: ${e}`, {
- level: 'warn',
- })
- }
- return null
- }
- const { frontmatter, content: markdownContent } = parseFrontmatter(
- content,
- skillFilePath,
- )
- const skillName = entry.name
- const parsed = parseSkillFrontmatterFields(
- frontmatter,
- markdownContent,
- skillName,
- )
- const paths = parseSkillPaths(frontmatter)
- return {
- skill: createSkillCommand({
- ...parsed,
- skillName,
- markdownContent,
- source,
- baseDir: skillDirPath,
- loadedFrom: 'skills',
- paths,
- }),
- filePath: skillFilePath,
- }
- } catch (error) {
- logError(error)
- return null
- }
- }),
- )
- return results.filter((r): r is SkillWithPath => r !== null)
- }
- // --- Legacy /commands/ loader ---
- function isSkillFile(filePath: string): boolean {
- return /^skill\.md$/i.test(basename(filePath))
- }
- /**
- * Transforms markdown files to handle "skill" commands in legacy /commands/ folder.
- * When a SKILL.md file exists in a directory, only that file is loaded
- * and it takes the name of its parent directory.
- */
- function transformSkillFiles(files: MarkdownFile[]): MarkdownFile[] {
- const filesByDir = new Map<string, MarkdownFile[]>()
- for (const file of files) {
- const dir = dirname(file.filePath)
- const dirFiles = filesByDir.get(dir) ?? []
- dirFiles.push(file)
- filesByDir.set(dir, dirFiles)
- }
- const result: MarkdownFile[] = []
- for (const [dir, dirFiles] of filesByDir) {
- const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath))
- if (skillFiles.length > 0) {
- const skillFile = skillFiles[0]!
- if (skillFiles.length > 1) {
- logForDebugging(
- `Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`,
- )
- }
- result.push(skillFile)
- } else {
- result.push(...dirFiles)
- }
- }
- return result
- }
- function buildNamespace(targetDir: string, baseDir: string): string {
- const normalizedBaseDir = baseDir.endsWith(pathSep)
- ? baseDir.slice(0, -1)
- : baseDir
- if (targetDir === normalizedBaseDir) {
- return ''
- }
- const relativePath = targetDir.slice(normalizedBaseDir.length + 1)
- return relativePath ? relativePath.split(pathSep).join(':') : ''
- }
- function getSkillCommandName(filePath: string, baseDir: string): string {
- const skillDirectory = dirname(filePath)
- const parentOfSkillDir = dirname(skillDirectory)
- const commandBaseName = basename(skillDirectory)
- const namespace = buildNamespace(parentOfSkillDir, baseDir)
- return namespace ? `${namespace}:${commandBaseName}` : commandBaseName
- }
- function getRegularCommandName(filePath: string, baseDir: string): string {
- const fileName = basename(filePath)
- const fileDirectory = dirname(filePath)
- const commandBaseName = fileName.replace(/\.md$/, '')
- const namespace = buildNamespace(fileDirectory, baseDir)
- return namespace ? `${namespace}:${commandBaseName}` : commandBaseName
- }
- function getCommandName(file: MarkdownFile): string {
- const isSkill = isSkillFile(file.filePath)
- return isSkill
- ? getSkillCommandName(file.filePath, file.baseDir)
- : getRegularCommandName(file.filePath, file.baseDir)
- }
- /**
- * Loads skills from legacy /commands/ directories.
- * Supports both directory format (SKILL.md) and single .md file format.
- * Commands from /commands/ default to user-invocable: true
- */
- async function loadSkillsFromCommandsDir(
- cwd: string,
- ): Promise<SkillWithPath[]> {
- try {
- const markdownFiles = await loadMarkdownFilesForSubdir('commands', cwd)
- const processedFiles = transformSkillFiles(markdownFiles)
- const skills: SkillWithPath[] = []
- for (const {
- baseDir,
- filePath,
- frontmatter,
- content,
- source,
- } of processedFiles) {
- try {
- const isSkillFormat = isSkillFile(filePath)
- const skillDirectory = isSkillFormat ? dirname(filePath) : undefined
- const cmdName = getCommandName({
- baseDir,
- filePath,
- frontmatter,
- content,
- source,
- })
- const parsed = parseSkillFrontmatterFields(
- frontmatter,
- content,
- cmdName,
- 'Custom command',
- )
- skills.push({
- skill: createSkillCommand({
- ...parsed,
- skillName: cmdName,
- displayName: undefined,
- markdownContent: content,
- source,
- baseDir: skillDirectory,
- loadedFrom: 'commands_DEPRECATED',
- paths: undefined,
- }),
- filePath,
- })
- } catch (error) {
- logError(error)
- }
- }
- return skills
- } catch (error) {
- logError(error)
- return []
- }
- }
- /**
- * Loads all skills from both /skills/ and legacy /commands/ directories.
- *
- * Skills from /skills/ directories:
- * - Only support directory format: skill-name/SKILL.md
- * - Default to user-invocable: true (can opt-out with user-invocable: false)
- *
- * Skills from legacy /commands/ directories:
- * - Support both directory format (SKILL.md) and single .md file format
- * - Default to user-invocable: true (user can type /cmd)
- *
- * @param cwd Current working directory for project directory traversal
- */
- export const getSkillDirCommands = memoize(
- async (cwd: string): Promise<Command[]> => {
- const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills')
- const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills')
- const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd)
- logForDebugging(
- `Loading skills from: managed=${managedSkillsDir}, user=${userSkillsDir}, project=[${projectSkillsDirs.join(', ')}]`,
- )
- // Load from additional directories (--add-dir)
- const additionalDirs = getAdditionalDirectoriesForClaudeMd()
- const skillsLocked = isRestrictedToPluginOnly('skills')
- const projectSettingsEnabled =
- isSettingSourceEnabled('projectSettings') && !skillsLocked
- // --bare: skip auto-discovery (managed/user/project dir walks + legacy
- // commands-dir). Load ONLY explicit --add-dir paths. Bundled skills
- // register separately. skillsLocked still applies — --bare is not a
- // policy bypass.
- if (isBareMode()) {
- if (additionalDirs.length === 0 || !projectSettingsEnabled) {
- logForDebugging(
- `[bare] Skipping skill dir discovery (${additionalDirs.length === 0 ? 'no --add-dir' : 'projectSettings disabled or skillsLocked'})`,
- )
- return []
- }
- const additionalSkillsNested = await Promise.all(
- additionalDirs.map(dir =>
- loadSkillsFromSkillsDir(
- join(dir, '.claude', 'skills'),
- 'projectSettings',
- ),
- ),
- )
- // No dedup needed — explicit dirs, user controls uniqueness.
- return additionalSkillsNested.flat().map(s => s.skill)
- }
- // Load from /skills/ directories, additional dirs, and legacy /commands/ in parallel
- // (all independent — different directories, no shared state)
- const [
- managedSkills,
- userSkills,
- projectSkillsNested,
- additionalSkillsNested,
- legacyCommands,
- ] = await Promise.all([
- isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS)
- ? Promise.resolve([])
- : loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
- isSettingSourceEnabled('userSettings') && !skillsLocked
- ? loadSkillsFromSkillsDir(userSkillsDir, 'userSettings')
- : Promise.resolve([]),
- projectSettingsEnabled
- ? Promise.all(
- projectSkillsDirs.map(dir =>
- loadSkillsFromSkillsDir(dir, 'projectSettings'),
- ),
- )
- : Promise.resolve([]),
- projectSettingsEnabled
- ? Promise.all(
- additionalDirs.map(dir =>
- loadSkillsFromSkillsDir(
- join(dir, '.claude', 'skills'),
- 'projectSettings',
- ),
- ),
- )
- : Promise.resolve([]),
- // Legacy commands-as-skills goes through markdownConfigLoader with
- // subdir='commands', which our agents-only guard there skips. Block
- // here when skills are locked — these ARE skills, regardless of the
- // directory they load from.
- skillsLocked ? Promise.resolve([]) : loadSkillsFromCommandsDir(cwd),
- ])
- // Flatten and combine all skills
- const allSkillsWithPaths = [
- ...managedSkills,
- ...userSkills,
- ...projectSkillsNested.flat(),
- ...additionalSkillsNested.flat(),
- ...legacyCommands,
- ]
- // Deduplicate by resolved path (handles symlinks and duplicate parent directories)
- // Pre-compute file identities in parallel (realpath calls are independent),
- // then dedup synchronously (order-dependent first-wins)
- const fileIds = await Promise.all(
- allSkillsWithPaths.map(({ skill, filePath }) =>
- skill.type === 'prompt'
- ? getFileIdentity(filePath)
- : Promise.resolve(null),
- ),
- )
- const seenFileIds = new Map<
- string,
- SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
- >()
- const deduplicatedSkills: Command[] = []
- for (let i = 0; i < allSkillsWithPaths.length; i++) {
- const entry = allSkillsWithPaths[i]
- if (entry === undefined || entry.skill.type !== 'prompt') continue
- const { skill } = entry
- const fileId = fileIds[i]
- if (fileId === null || fileId === undefined) {
- deduplicatedSkills.push(skill)
- continue
- }
- const existingSource = seenFileIds.get(fileId)
- if (existingSource !== undefined) {
- logForDebugging(
- `Skipping duplicate skill '${skill.name}' from ${skill.source} (same file already loaded from ${existingSource})`,
- )
- continue
- }
- seenFileIds.set(fileId, skill.source)
- deduplicatedSkills.push(skill)
- }
- const duplicatesRemoved =
- allSkillsWithPaths.length - deduplicatedSkills.length
- if (duplicatesRemoved > 0) {
- logForDebugging(`Deduplicated ${duplicatesRemoved} skills (same file)`)
- }
- // Separate conditional skills (with paths frontmatter) from unconditional ones
- const unconditionalSkills: Command[] = []
- const newConditionalSkills: Command[] = []
- for (const skill of deduplicatedSkills) {
- if (
- skill.type === 'prompt' &&
- skill.paths &&
- skill.paths.length > 0 &&
- !activatedConditionalSkillNames.has(skill.name)
- ) {
- newConditionalSkills.push(skill)
- } else {
- unconditionalSkills.push(skill)
- }
- }
- // Store conditional skills for later activation when matching files are touched
- for (const skill of newConditionalSkills) {
- conditionalSkills.set(skill.name, skill)
- }
- if (newConditionalSkills.length > 0) {
- logForDebugging(
- `[skills] ${newConditionalSkills.length} conditional skills stored (activated when matching files are touched)`,
- )
- }
- logForDebugging(
- `Loaded ${deduplicatedSkills.length} unique skills (${unconditionalSkills.length} unconditional, ${newConditionalSkills.length} conditional, managed: ${managedSkills.length}, user: ${userSkills.length}, project: ${projectSkillsNested.flat().length}, additional: ${additionalSkillsNested.flat().length}, legacy commands: ${legacyCommands.length})`,
- )
- return unconditionalSkills
- },
- )
- export function clearSkillCaches() {
- getSkillDirCommands.cache?.clear?.()
- loadMarkdownFilesForSubdir.cache?.clear?.()
- conditionalSkills.clear()
- activatedConditionalSkillNames.clear()
- }
- // Backwards-compatible aliases for tests
- export { getSkillDirCommands as getCommandDirCommands }
- export { clearSkillCaches as clearCommandCaches }
- export { transformSkillFiles }
- // --- Dynamic skill discovery ---
- // State for dynamically discovered skills
- const dynamicSkillDirs = new Set<string>()
- const dynamicSkills = new Map<string, Command>()
- // --- Conditional skills (path-filtered) ---
- // Skills with paths frontmatter that haven't been activated yet
- const conditionalSkills = new Map<string, Command>()
- // Names of skills that have been activated (survives cache clears within a session)
- const activatedConditionalSkillNames = new Set<string>()
- // Signal fired when dynamic skills are loaded
- const skillsLoaded = createSignal()
- /**
- * Register a callback to be invoked when dynamic skills are loaded.
- * Used by other modules to clear caches without creating import cycles.
- * Returns an unsubscribe function.
- */
- export function onDynamicSkillsLoaded(callback: () => void): () => void {
- // Wrap at subscribe time so a throwing listener is logged and skipped
- // rather than aborting skillsLoaded.emit() and breaking skill loading.
- // Same callSafe pattern as growthbook.ts — createSignal.emit() has no
- // per-listener try/catch.
- return skillsLoaded.subscribe(() => {
- try {
- callback()
- } catch (error) {
- logError(error)
- }
- })
- }
- /**
- * Discovers skill directories by walking up from file paths to cwd.
- * Only discovers directories below cwd (cwd-level skills are loaded at startup).
- *
- * @param filePaths Array of file paths to check
- * @param cwd Current working directory (upper bound for discovery)
- * @returns Array of newly discovered skill directories, sorted deepest first
- */
- export async function discoverSkillDirsForPaths(
- filePaths: string[],
- cwd: string,
- ): Promise<string[]> {
- const fs = getFsImplementation()
- const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd
- const newDirs: string[] = []
- for (const filePath of filePaths) {
- // Start from the file's parent directory
- let currentDir = dirname(filePath)
- // Walk up to cwd but NOT including cwd itself
- // CWD-level skills are already loaded at startup, so we only discover nested ones
- // Use prefix+separator check to avoid matching /project-backup when cwd is /project
- while (currentDir.startsWith(resolvedCwd + pathSep)) {
- const skillDir = join(currentDir, '.claude', 'skills')
- // Skip if we've already checked this path (hit or miss) — avoids
- // repeating the same failed stat on every Read/Write/Edit call when
- // the directory doesn't exist (the common case).
- if (!dynamicSkillDirs.has(skillDir)) {
- dynamicSkillDirs.add(skillDir)
- try {
- await fs.stat(skillDir)
- // Skills dir exists. Before loading, check if the containing dir
- // is gitignored — blocks e.g. node_modules/pkg/.claude/skills from
- // loading silently. `git check-ignore` handles nested .gitignore,
- // .git/info/exclude, and global gitignore. Fails open outside a
- // git repo (exit 128 → false); the invocation-time trust dialog
- // is the actual security boundary.
- if (await isPathGitignored(currentDir, resolvedCwd)) {
- logForDebugging(
- `[skills] Skipped gitignored skills dir: ${skillDir}`,
- )
- continue
- }
- newDirs.push(skillDir)
- } catch {
- // Directory doesn't exist — already recorded above, continue
- }
- }
- // Move to parent
- const parent = dirname(currentDir)
- if (parent === currentDir) break // Reached root
- currentDir = parent
- }
- }
- // Sort by path depth (deepest first) so skills closer to the file take precedence
- return newDirs.sort(
- (a, b) => b.split(pathSep).length - a.split(pathSep).length,
- )
- }
- /**
- * Loads skills from the given directories and merges them into the dynamic skills map.
- * Skills from directories closer to the file (deeper paths) take precedence.
- *
- * @param dirs Array of skill directories to load from (should be sorted deepest first)
- */
- export async function addSkillDirectories(dirs: string[]): Promise<void> {
- if (
- !isSettingSourceEnabled('projectSettings') ||
- isRestrictedToPluginOnly('skills')
- ) {
- logForDebugging(
- '[skills] Dynamic skill discovery skipped: projectSettings disabled or plugin-only policy',
- )
- return
- }
- if (dirs.length === 0) {
- return
- }
- const previousSkillNamesForLogging = new Set(dynamicSkills.keys())
- // Load skills from all directories
- const loadedSkills = await Promise.all(
- dirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')),
- )
- // Process in reverse order (shallower first) so deeper paths override
- for (let i = loadedSkills.length - 1; i >= 0; i--) {
- for (const { skill } of loadedSkills[i] ?? []) {
- if (skill.type === 'prompt') {
- dynamicSkills.set(skill.name, skill)
- }
- }
- }
- const newSkillCount = loadedSkills.flat().length
- if (newSkillCount > 0) {
- const addedSkills = [...dynamicSkills.keys()].filter(
- n => !previousSkillNamesForLogging.has(n),
- )
- logForDebugging(
- `[skills] Dynamically discovered ${newSkillCount} skills from ${dirs.length} directories`,
- )
- if (addedSkills.length > 0) {
- logEvent('tengu_dynamic_skills_changed', {
- source:
- 'file_operation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- previousCount: previousSkillNamesForLogging.size,
- newCount: dynamicSkills.size,
- addedCount: addedSkills.length,
- directoryCount: dirs.length,
- })
- }
- }
- // Notify listeners that skills were loaded (so they can clear caches)
- skillsLoaded.emit()
- }
- /**
- * Gets all dynamically discovered skills.
- * These are skills discovered from file paths during the session.
- */
- export function getDynamicSkills(): Command[] {
- return Array.from(dynamicSkills.values())
- }
- /**
- * Activates conditional skills (skills with paths frontmatter) whose path
- * patterns match the given file paths. Activated skills are added to the
- * dynamic skills map, making them available to the model.
- *
- * Uses the `ignore` library (gitignore-style matching), matching the behavior
- * of CLAUDE.md conditional rules.
- *
- * @param filePaths Array of file paths being operated on
- * @param cwd Current working directory (paths are matched relative to cwd)
- * @returns Array of newly activated skill names
- */
- export function activateConditionalSkillsForPaths(
- filePaths: string[],
- cwd: string,
- ): string[] {
- if (conditionalSkills.size === 0) {
- return []
- }
- const activated: string[] = []
- for (const [name, skill] of conditionalSkills) {
- if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) {
- continue
- }
- const skillIgnore = ignore().add(skill.paths)
- for (const filePath of filePaths) {
- const relativePath = isAbsolute(filePath)
- ? relative(cwd, filePath)
- : filePath
- // ignore() throws on empty strings, paths escaping the base (../),
- // and absolute paths (Windows cross-drive relative() returns absolute).
- // Files outside cwd can't match cwd-relative patterns anyway.
- if (
- !relativePath ||
- relativePath.startsWith('..') ||
- isAbsolute(relativePath)
- ) {
- continue
- }
- if (skillIgnore.ignores(relativePath)) {
- // Activate this skill by moving it to dynamic skills
- dynamicSkills.set(name, skill)
- conditionalSkills.delete(name)
- activatedConditionalSkillNames.add(name)
- activated.push(name)
- logForDebugging(
- `[skills] Activated conditional skill '${name}' (matched path: ${relativePath})`,
- )
- break
- }
- }
- }
- if (activated.length > 0) {
- logEvent('tengu_dynamic_skills_changed', {
- source:
- 'conditional_paths' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
- previousCount: dynamicSkills.size - activated.length,
- newCount: dynamicSkills.size,
- addedCount: activated.length,
- directoryCount: 0,
- })
- // Notify listeners that skills were loaded (so they can clear caches)
- skillsLoaded.emit()
- }
- return activated
- }
- /**
- * Gets the number of pending conditional skills (for testing/debugging).
- */
- export function getConditionalSkillCount(): number {
- return conditionalSkills.size
- }
- /**
- * Clears dynamic skill state (for testing).
- */
- export function clearDynamicSkills(): void {
- dynamicSkillDirs.clear()
- dynamicSkills.clear()
- conditionalSkills.clear()
- activatedConditionalSkillNames.clear()
- }
- // Expose createSkillCommand + parseSkillFrontmatterFields to MCP skill
- // discovery via a leaf registry module. See mcpSkillBuilders.ts for why this
- // indirection exists (a literal dynamic import from mcpSkills.ts fans a single
- // edge out into many cycle violations; a variable-specifier dynamic import
- // passes dep-cruiser but fails to resolve in Bun-bundled binaries at runtime).
- // eslint-disable-next-line custom-rules/no-top-level-side-effects -- write-once registration, idempotent
- registerMCPSkillBuilders({
- createSkillCommand,
- parseSkillFrontmatterFields,
- })
|