loadSkillsDir.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086
  1. import { realpath } from 'fs/promises'
  2. import ignore from 'ignore'
  3. import memoize from 'lodash-es/memoize.js'
  4. import {
  5. basename,
  6. dirname,
  7. isAbsolute,
  8. join,
  9. sep as pathSep,
  10. relative,
  11. } from 'path'
  12. import {
  13. getAdditionalDirectoriesForClaudeMd,
  14. getSessionId,
  15. } from '../bootstrap/state.js'
  16. import {
  17. type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  18. logEvent,
  19. } from '../services/analytics/index.js'
  20. import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
  21. import type { Command, PromptCommand } from '../types/command.js'
  22. import {
  23. parseArgumentNames,
  24. substituteArguments,
  25. } from '../utils/argumentSubstitution.js'
  26. import { logForDebugging } from '../utils/debug.js'
  27. import {
  28. EFFORT_LEVELS,
  29. type EffortValue,
  30. parseEffortValue,
  31. } from '../utils/effort.js'
  32. import {
  33. getClaudeConfigHomeDir,
  34. isBareMode,
  35. isEnvTruthy,
  36. } from '../utils/envUtils.js'
  37. import { isENOENT, isFsInaccessible } from '../utils/errors.js'
  38. import {
  39. coerceDescriptionToString,
  40. type FrontmatterData,
  41. type FrontmatterShell,
  42. parseBooleanFrontmatter,
  43. parseFrontmatter,
  44. parseShellFrontmatter,
  45. splitPathInFrontmatter,
  46. } from '../utils/frontmatterParser.js'
  47. import { getFsImplementation } from '../utils/fsOperations.js'
  48. import { isPathGitignored } from '../utils/git/gitignore.js'
  49. import { logError } from '../utils/log.js'
  50. import {
  51. extractDescriptionFromMarkdown,
  52. getProjectDirsUpToHome,
  53. loadMarkdownFilesForSubdir,
  54. type MarkdownFile,
  55. parseSlashCommandToolsFromFrontmatter,
  56. } from '../utils/markdownConfigLoader.js'
  57. import { parseUserSpecifiedModel } from '../utils/model/model.js'
  58. import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
  59. import type { SettingSource } from '../utils/settings/constants.js'
  60. import { isSettingSourceEnabled } from '../utils/settings/constants.js'
  61. import { getManagedFilePath } from '../utils/settings/managedPath.js'
  62. import { isRestrictedToPluginOnly } from '../utils/settings/pluginOnlyPolicy.js'
  63. import { HooksSchema, type HooksSettings } from '../utils/settings/types.js'
  64. import { createSignal } from '../utils/signal.js'
  65. import { registerMCPSkillBuilders } from './mcpSkillBuilders.js'
  66. export type LoadedFrom =
  67. | 'commands_DEPRECATED'
  68. | 'skills'
  69. | 'plugin'
  70. | 'managed'
  71. | 'bundled'
  72. | 'mcp'
  73. /**
  74. * Returns a claude config directory path for a given source.
  75. */
  76. export function getSkillsPath(
  77. source: SettingSource | 'plugin',
  78. dir: 'skills' | 'commands',
  79. ): string {
  80. switch (source) {
  81. case 'policySettings':
  82. return join(getManagedFilePath(), '.claude', dir)
  83. case 'userSettings':
  84. return join(getClaudeConfigHomeDir(), dir)
  85. case 'projectSettings':
  86. return `.claude/${dir}`
  87. case 'plugin':
  88. return 'plugin'
  89. default:
  90. return ''
  91. }
  92. }
  93. /**
  94. * Estimates token count for a skill based on frontmatter only
  95. * (name, description, whenToUse) since full content is only loaded on invocation.
  96. */
  97. export function estimateSkillFrontmatterTokens(skill: Command): number {
  98. const frontmatterText = [skill.name, skill.description, skill.whenToUse]
  99. .filter(Boolean)
  100. .join(' ')
  101. return roughTokenCountEstimation(frontmatterText)
  102. }
  103. /**
  104. * Gets a unique identifier for a file by resolving symlinks to a canonical path.
  105. * This allows detection of duplicate files accessed through different paths
  106. * (e.g., via symlinks or overlapping parent directories).
  107. * Returns null if the file doesn't exist or can't be resolved.
  108. *
  109. * Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids
  110. * issues with filesystems that report unreliable inode values (e.g., inode 0 on
  111. * some virtual/container/NFS filesystems, or precision loss on ExFAT).
  112. * See: https://github.com/anthropics/claude-code/issues/13893
  113. */
  114. async function getFileIdentity(filePath: string): Promise<string | null> {
  115. try {
  116. return await realpath(filePath)
  117. } catch {
  118. return null
  119. }
  120. }
  121. // Internal type to track skill with its file path for deduplication
  122. type SkillWithPath = {
  123. skill: Command
  124. filePath: string
  125. }
  126. /**
  127. * Parse and validate hooks from frontmatter.
  128. * Returns undefined if hooks are not defined or invalid.
  129. */
  130. function parseHooksFromFrontmatter(
  131. frontmatter: FrontmatterData,
  132. skillName: string,
  133. ): HooksSettings | undefined {
  134. if (!frontmatter.hooks) {
  135. return undefined
  136. }
  137. const result = HooksSchema().safeParse(frontmatter.hooks)
  138. if (!result.success) {
  139. logForDebugging(
  140. `Invalid hooks in skill '${skillName}': ${result.error.message}`,
  141. )
  142. return undefined
  143. }
  144. return result.data
  145. }
  146. /**
  147. * Parse paths frontmatter from a skill, using the same format as CLAUDE.md rules.
  148. * Returns undefined if no paths are specified or if all patterns are match-all.
  149. */
  150. function parseSkillPaths(frontmatter: FrontmatterData): string[] | undefined {
  151. if (!frontmatter.paths) {
  152. return undefined
  153. }
  154. const patterns = splitPathInFrontmatter(frontmatter.paths)
  155. .map(pattern => {
  156. // Remove /** suffix - ignore library treats 'path' as matching both
  157. // the path itself and everything inside it
  158. return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern
  159. })
  160. .filter((p: string) => p.length > 0)
  161. // If all patterns are ** (match-all), treat as no paths (undefined)
  162. if (patterns.length === 0 || patterns.every((p: string) => p === '**')) {
  163. return undefined
  164. }
  165. return patterns
  166. }
  167. /**
  168. * Parses all skill frontmatter fields that are shared between file-based and
  169. * MCP skill loading. Caller supplies the resolved skill name and the
  170. * source/loadedFrom/baseDir/paths fields separately.
  171. */
  172. export function parseSkillFrontmatterFields(
  173. frontmatter: FrontmatterData,
  174. markdownContent: string,
  175. resolvedName: string,
  176. descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill',
  177. ): {
  178. displayName: string | undefined
  179. description: string
  180. hasUserSpecifiedDescription: boolean
  181. allowedTools: string[]
  182. argumentHint: string | undefined
  183. argumentNames: string[]
  184. whenToUse: string | undefined
  185. version: string | undefined
  186. model: ReturnType<typeof parseUserSpecifiedModel> | undefined
  187. disableModelInvocation: boolean
  188. userInvocable: boolean
  189. hooks: HooksSettings | undefined
  190. executionContext: 'fork' | undefined
  191. agent: string | undefined
  192. effort: EffortValue | undefined
  193. shell: FrontmatterShell | undefined
  194. } {
  195. const validatedDescription = coerceDescriptionToString(
  196. frontmatter.description,
  197. resolvedName,
  198. )
  199. const description =
  200. validatedDescription ??
  201. extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel)
  202. const userInvocable =
  203. frontmatter['user-invocable'] === undefined
  204. ? true
  205. : parseBooleanFrontmatter(frontmatter['user-invocable'])
  206. const model =
  207. frontmatter.model === 'inherit'
  208. ? undefined
  209. : frontmatter.model
  210. ? parseUserSpecifiedModel(frontmatter.model as string)
  211. : undefined
  212. const effortRaw = frontmatter['effort']
  213. const effort =
  214. effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
  215. if (effortRaw !== undefined && effort === undefined) {
  216. logForDebugging(
  217. `Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
  218. )
  219. }
  220. return {
  221. displayName:
  222. frontmatter.name != null ? String(frontmatter.name) : undefined,
  223. description,
  224. hasUserSpecifiedDescription: validatedDescription !== null,
  225. allowedTools: parseSlashCommandToolsFromFrontmatter(
  226. frontmatter['allowed-tools'],
  227. ),
  228. argumentHint:
  229. frontmatter['argument-hint'] != null
  230. ? String(frontmatter['argument-hint'])
  231. : undefined,
  232. argumentNames: parseArgumentNames(
  233. frontmatter.arguments as string | string[] | undefined,
  234. ),
  235. whenToUse: frontmatter.when_to_use as string | undefined,
  236. version: frontmatter.version as string | undefined,
  237. model,
  238. disableModelInvocation: parseBooleanFrontmatter(
  239. frontmatter['disable-model-invocation'],
  240. ),
  241. userInvocable,
  242. hooks: parseHooksFromFrontmatter(frontmatter, resolvedName),
  243. executionContext: frontmatter.context === 'fork' ? 'fork' : undefined,
  244. agent: frontmatter.agent as string | undefined,
  245. effort,
  246. shell: parseShellFrontmatter(frontmatter.shell, resolvedName),
  247. }
  248. }
  249. /**
  250. * Creates a skill command from parsed data
  251. */
  252. export function createSkillCommand({
  253. skillName,
  254. displayName,
  255. description,
  256. hasUserSpecifiedDescription,
  257. markdownContent,
  258. allowedTools,
  259. argumentHint,
  260. argumentNames,
  261. whenToUse,
  262. version,
  263. model,
  264. disableModelInvocation,
  265. userInvocable,
  266. source,
  267. baseDir,
  268. loadedFrom,
  269. hooks,
  270. executionContext,
  271. agent,
  272. paths,
  273. effort,
  274. shell,
  275. }: {
  276. skillName: string
  277. displayName: string | undefined
  278. description: string
  279. hasUserSpecifiedDescription: boolean
  280. markdownContent: string
  281. allowedTools: string[]
  282. argumentHint: string | undefined
  283. argumentNames: string[]
  284. whenToUse: string | undefined
  285. version: string | undefined
  286. model: string | undefined
  287. disableModelInvocation: boolean
  288. userInvocable: boolean
  289. source: PromptCommand['source']
  290. baseDir: string | undefined
  291. loadedFrom: LoadedFrom
  292. hooks: HooksSettings | undefined
  293. executionContext: 'inline' | 'fork' | undefined
  294. agent: string | undefined
  295. paths: string[] | undefined
  296. effort: EffortValue | undefined
  297. shell: FrontmatterShell | undefined
  298. }): Command {
  299. return {
  300. type: 'prompt',
  301. name: skillName,
  302. description,
  303. hasUserSpecifiedDescription,
  304. allowedTools,
  305. argumentHint,
  306. argNames: argumentNames.length > 0 ? argumentNames : undefined,
  307. whenToUse,
  308. version,
  309. model,
  310. disableModelInvocation,
  311. userInvocable,
  312. context: executionContext,
  313. agent,
  314. effort,
  315. paths,
  316. contentLength: markdownContent.length,
  317. isHidden: !userInvocable,
  318. progressMessage: 'running',
  319. userFacingName(): string {
  320. return displayName || skillName
  321. },
  322. source,
  323. loadedFrom,
  324. hooks,
  325. skillRoot: baseDir,
  326. async getPromptForCommand(args, toolUseContext) {
  327. let finalContent = baseDir
  328. ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
  329. : markdownContent
  330. finalContent = substituteArguments(
  331. finalContent,
  332. args,
  333. true,
  334. argumentNames,
  335. )
  336. // Replace ${CLAUDE_SKILL_DIR} with the skill's own directory so bash
  337. // injection (!`...`) can reference bundled scripts. Normalize backslashes
  338. // to forward slashes on Windows so shell commands don't treat them as escapes.
  339. if (baseDir) {
  340. const skillDir =
  341. process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir
  342. finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
  343. }
  344. // Replace ${CLAUDE_SESSION_ID} with the current session ID
  345. finalContent = finalContent.replace(
  346. /\$\{CLAUDE_SESSION_ID\}/g,
  347. getSessionId(),
  348. )
  349. // Security: MCP skills are remote and untrusted — never execute inline
  350. // shell commands (!`…` / ```! … ```) from their markdown body.
  351. // ${CLAUDE_SKILL_DIR} is meaningless for MCP skills anyway.
  352. if (loadedFrom !== 'mcp') {
  353. finalContent = await executeShellCommandsInPrompt(
  354. finalContent,
  355. {
  356. ...toolUseContext,
  357. getAppState() {
  358. const appState = toolUseContext.getAppState()
  359. return {
  360. ...appState,
  361. toolPermissionContext: {
  362. ...appState.toolPermissionContext,
  363. alwaysAllowRules: {
  364. ...appState.toolPermissionContext.alwaysAllowRules,
  365. command: allowedTools,
  366. },
  367. },
  368. }
  369. },
  370. },
  371. `/${skillName}`,
  372. shell,
  373. )
  374. }
  375. return [{ type: 'text', text: finalContent }]
  376. },
  377. } satisfies Command
  378. }
  379. /**
  380. * Loads skills from a /skills/ directory path.
  381. * Only supports directory format: skill-name/SKILL.md
  382. */
  383. async function loadSkillsFromSkillsDir(
  384. basePath: string,
  385. source: SettingSource,
  386. ): Promise<SkillWithPath[]> {
  387. const fs = getFsImplementation()
  388. let entries
  389. try {
  390. entries = await fs.readdir(basePath)
  391. } catch (e: unknown) {
  392. if (!isFsInaccessible(e)) logError(e)
  393. return []
  394. }
  395. const results = await Promise.all(
  396. entries.map(async (entry): Promise<SkillWithPath | null> => {
  397. try {
  398. // Only support directory format: skill-name/SKILL.md
  399. if (!entry.isDirectory() && !entry.isSymbolicLink()) {
  400. // Single .md files are NOT supported in /skills/ directory
  401. return null
  402. }
  403. const skillDirPath = join(basePath, entry.name)
  404. const skillFilePath = join(skillDirPath, 'SKILL.md')
  405. let content: string
  406. try {
  407. content = await fs.readFile(skillFilePath, { encoding: 'utf-8' })
  408. } catch (e: unknown) {
  409. // SKILL.md doesn't exist, skip this entry. Log non-ENOENT errors
  410. // (EACCES/EPERM/EIO) so permission/IO problems are diagnosable.
  411. if (!isENOENT(e)) {
  412. logForDebugging(`[skills] failed to read ${skillFilePath}: ${e}`, {
  413. level: 'warn',
  414. })
  415. }
  416. return null
  417. }
  418. const { frontmatter, content: markdownContent } = parseFrontmatter(
  419. content,
  420. skillFilePath,
  421. )
  422. const skillName = entry.name
  423. const parsed = parseSkillFrontmatterFields(
  424. frontmatter,
  425. markdownContent,
  426. skillName,
  427. )
  428. const paths = parseSkillPaths(frontmatter)
  429. return {
  430. skill: createSkillCommand({
  431. ...parsed,
  432. skillName,
  433. markdownContent,
  434. source,
  435. baseDir: skillDirPath,
  436. loadedFrom: 'skills',
  437. paths,
  438. }),
  439. filePath: skillFilePath,
  440. }
  441. } catch (error) {
  442. logError(error)
  443. return null
  444. }
  445. }),
  446. )
  447. return results.filter((r): r is SkillWithPath => r !== null)
  448. }
  449. // --- Legacy /commands/ loader ---
  450. function isSkillFile(filePath: string): boolean {
  451. return /^skill\.md$/i.test(basename(filePath))
  452. }
  453. /**
  454. * Transforms markdown files to handle "skill" commands in legacy /commands/ folder.
  455. * When a SKILL.md file exists in a directory, only that file is loaded
  456. * and it takes the name of its parent directory.
  457. */
  458. function transformSkillFiles(files: MarkdownFile[]): MarkdownFile[] {
  459. const filesByDir = new Map<string, MarkdownFile[]>()
  460. for (const file of files) {
  461. const dir = dirname(file.filePath)
  462. const dirFiles = filesByDir.get(dir) ?? []
  463. dirFiles.push(file)
  464. filesByDir.set(dir, dirFiles)
  465. }
  466. const result: MarkdownFile[] = []
  467. for (const [dir, dirFiles] of filesByDir) {
  468. const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath))
  469. if (skillFiles.length > 0) {
  470. const skillFile = skillFiles[0]!
  471. if (skillFiles.length > 1) {
  472. logForDebugging(
  473. `Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`,
  474. )
  475. }
  476. result.push(skillFile)
  477. } else {
  478. result.push(...dirFiles)
  479. }
  480. }
  481. return result
  482. }
  483. function buildNamespace(targetDir: string, baseDir: string): string {
  484. const normalizedBaseDir = baseDir.endsWith(pathSep)
  485. ? baseDir.slice(0, -1)
  486. : baseDir
  487. if (targetDir === normalizedBaseDir) {
  488. return ''
  489. }
  490. const relativePath = targetDir.slice(normalizedBaseDir.length + 1)
  491. return relativePath ? relativePath.split(pathSep).join(':') : ''
  492. }
  493. function getSkillCommandName(filePath: string, baseDir: string): string {
  494. const skillDirectory = dirname(filePath)
  495. const parentOfSkillDir = dirname(skillDirectory)
  496. const commandBaseName = basename(skillDirectory)
  497. const namespace = buildNamespace(parentOfSkillDir, baseDir)
  498. return namespace ? `${namespace}:${commandBaseName}` : commandBaseName
  499. }
  500. function getRegularCommandName(filePath: string, baseDir: string): string {
  501. const fileName = basename(filePath)
  502. const fileDirectory = dirname(filePath)
  503. const commandBaseName = fileName.replace(/\.md$/, '')
  504. const namespace = buildNamespace(fileDirectory, baseDir)
  505. return namespace ? `${namespace}:${commandBaseName}` : commandBaseName
  506. }
  507. function getCommandName(file: MarkdownFile): string {
  508. const isSkill = isSkillFile(file.filePath)
  509. return isSkill
  510. ? getSkillCommandName(file.filePath, file.baseDir)
  511. : getRegularCommandName(file.filePath, file.baseDir)
  512. }
  513. /**
  514. * Loads skills from legacy /commands/ directories.
  515. * Supports both directory format (SKILL.md) and single .md file format.
  516. * Commands from /commands/ default to user-invocable: true
  517. */
  518. async function loadSkillsFromCommandsDir(
  519. cwd: string,
  520. ): Promise<SkillWithPath[]> {
  521. try {
  522. const markdownFiles = await loadMarkdownFilesForSubdir('commands', cwd)
  523. const processedFiles = transformSkillFiles(markdownFiles)
  524. const skills: SkillWithPath[] = []
  525. for (const {
  526. baseDir,
  527. filePath,
  528. frontmatter,
  529. content,
  530. source,
  531. } of processedFiles) {
  532. try {
  533. const isSkillFormat = isSkillFile(filePath)
  534. const skillDirectory = isSkillFormat ? dirname(filePath) : undefined
  535. const cmdName = getCommandName({
  536. baseDir,
  537. filePath,
  538. frontmatter,
  539. content,
  540. source,
  541. })
  542. const parsed = parseSkillFrontmatterFields(
  543. frontmatter,
  544. content,
  545. cmdName,
  546. 'Custom command',
  547. )
  548. skills.push({
  549. skill: createSkillCommand({
  550. ...parsed,
  551. skillName: cmdName,
  552. displayName: undefined,
  553. markdownContent: content,
  554. source,
  555. baseDir: skillDirectory,
  556. loadedFrom: 'commands_DEPRECATED',
  557. paths: undefined,
  558. }),
  559. filePath,
  560. })
  561. } catch (error) {
  562. logError(error)
  563. }
  564. }
  565. return skills
  566. } catch (error) {
  567. logError(error)
  568. return []
  569. }
  570. }
  571. /**
  572. * Loads all skills from both /skills/ and legacy /commands/ directories.
  573. *
  574. * Skills from /skills/ directories:
  575. * - Only support directory format: skill-name/SKILL.md
  576. * - Default to user-invocable: true (can opt-out with user-invocable: false)
  577. *
  578. * Skills from legacy /commands/ directories:
  579. * - Support both directory format (SKILL.md) and single .md file format
  580. * - Default to user-invocable: true (user can type /cmd)
  581. *
  582. * @param cwd Current working directory for project directory traversal
  583. */
  584. export const getSkillDirCommands = memoize(
  585. async (cwd: string): Promise<Command[]> => {
  586. const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills')
  587. const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills')
  588. const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd)
  589. logForDebugging(
  590. `Loading skills from: managed=${managedSkillsDir}, user=${userSkillsDir}, project=[${projectSkillsDirs.join(', ')}]`,
  591. )
  592. // Load from additional directories (--add-dir)
  593. const additionalDirs = getAdditionalDirectoriesForClaudeMd()
  594. const skillsLocked = isRestrictedToPluginOnly('skills')
  595. const projectSettingsEnabled =
  596. isSettingSourceEnabled('projectSettings') && !skillsLocked
  597. // --bare: skip auto-discovery (managed/user/project dir walks + legacy
  598. // commands-dir). Load ONLY explicit --add-dir paths. Bundled skills
  599. // register separately. skillsLocked still applies — --bare is not a
  600. // policy bypass.
  601. if (isBareMode()) {
  602. if (additionalDirs.length === 0 || !projectSettingsEnabled) {
  603. logForDebugging(
  604. `[bare] Skipping skill dir discovery (${additionalDirs.length === 0 ? 'no --add-dir' : 'projectSettings disabled or skillsLocked'})`,
  605. )
  606. return []
  607. }
  608. const additionalSkillsNested = await Promise.all(
  609. additionalDirs.map(dir =>
  610. loadSkillsFromSkillsDir(
  611. join(dir, '.claude', 'skills'),
  612. 'projectSettings',
  613. ),
  614. ),
  615. )
  616. // No dedup needed — explicit dirs, user controls uniqueness.
  617. return additionalSkillsNested.flat().map(s => s.skill)
  618. }
  619. // Load from /skills/ directories, additional dirs, and legacy /commands/ in parallel
  620. // (all independent — different directories, no shared state)
  621. const [
  622. managedSkills,
  623. userSkills,
  624. projectSkillsNested,
  625. additionalSkillsNested,
  626. legacyCommands,
  627. ] = await Promise.all([
  628. isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS)
  629. ? Promise.resolve([])
  630. : loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
  631. isSettingSourceEnabled('userSettings') && !skillsLocked
  632. ? loadSkillsFromSkillsDir(userSkillsDir, 'userSettings')
  633. : Promise.resolve([]),
  634. projectSettingsEnabled
  635. ? Promise.all(
  636. projectSkillsDirs.map(dir =>
  637. loadSkillsFromSkillsDir(dir, 'projectSettings'),
  638. ),
  639. )
  640. : Promise.resolve([]),
  641. projectSettingsEnabled
  642. ? Promise.all(
  643. additionalDirs.map(dir =>
  644. loadSkillsFromSkillsDir(
  645. join(dir, '.claude', 'skills'),
  646. 'projectSettings',
  647. ),
  648. ),
  649. )
  650. : Promise.resolve([]),
  651. // Legacy commands-as-skills goes through markdownConfigLoader with
  652. // subdir='commands', which our agents-only guard there skips. Block
  653. // here when skills are locked — these ARE skills, regardless of the
  654. // directory they load from.
  655. skillsLocked ? Promise.resolve([]) : loadSkillsFromCommandsDir(cwd),
  656. ])
  657. // Flatten and combine all skills
  658. const allSkillsWithPaths = [
  659. ...managedSkills,
  660. ...userSkills,
  661. ...projectSkillsNested.flat(),
  662. ...additionalSkillsNested.flat(),
  663. ...legacyCommands,
  664. ]
  665. // Deduplicate by resolved path (handles symlinks and duplicate parent directories)
  666. // Pre-compute file identities in parallel (realpath calls are independent),
  667. // then dedup synchronously (order-dependent first-wins)
  668. const fileIds = await Promise.all(
  669. allSkillsWithPaths.map(({ skill, filePath }) =>
  670. skill.type === 'prompt'
  671. ? getFileIdentity(filePath)
  672. : Promise.resolve(null),
  673. ),
  674. )
  675. const seenFileIds = new Map<
  676. string,
  677. SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
  678. >()
  679. const deduplicatedSkills: Command[] = []
  680. for (let i = 0; i < allSkillsWithPaths.length; i++) {
  681. const entry = allSkillsWithPaths[i]
  682. if (entry === undefined || entry.skill.type !== 'prompt') continue
  683. const { skill } = entry
  684. const fileId = fileIds[i]
  685. if (fileId === null || fileId === undefined) {
  686. deduplicatedSkills.push(skill)
  687. continue
  688. }
  689. const existingSource = seenFileIds.get(fileId)
  690. if (existingSource !== undefined) {
  691. logForDebugging(
  692. `Skipping duplicate skill '${skill.name}' from ${skill.source} (same file already loaded from ${existingSource})`,
  693. )
  694. continue
  695. }
  696. seenFileIds.set(fileId, skill.source)
  697. deduplicatedSkills.push(skill)
  698. }
  699. const duplicatesRemoved =
  700. allSkillsWithPaths.length - deduplicatedSkills.length
  701. if (duplicatesRemoved > 0) {
  702. logForDebugging(`Deduplicated ${duplicatesRemoved} skills (same file)`)
  703. }
  704. // Separate conditional skills (with paths frontmatter) from unconditional ones
  705. const unconditionalSkills: Command[] = []
  706. const newConditionalSkills: Command[] = []
  707. for (const skill of deduplicatedSkills) {
  708. if (
  709. skill.type === 'prompt' &&
  710. skill.paths &&
  711. skill.paths.length > 0 &&
  712. !activatedConditionalSkillNames.has(skill.name)
  713. ) {
  714. newConditionalSkills.push(skill)
  715. } else {
  716. unconditionalSkills.push(skill)
  717. }
  718. }
  719. // Store conditional skills for later activation when matching files are touched
  720. for (const skill of newConditionalSkills) {
  721. conditionalSkills.set(skill.name, skill)
  722. }
  723. if (newConditionalSkills.length > 0) {
  724. logForDebugging(
  725. `[skills] ${newConditionalSkills.length} conditional skills stored (activated when matching files are touched)`,
  726. )
  727. }
  728. logForDebugging(
  729. `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})`,
  730. )
  731. return unconditionalSkills
  732. },
  733. )
  734. export function clearSkillCaches() {
  735. getSkillDirCommands.cache?.clear?.()
  736. loadMarkdownFilesForSubdir.cache?.clear?.()
  737. conditionalSkills.clear()
  738. activatedConditionalSkillNames.clear()
  739. }
  740. // Backwards-compatible aliases for tests
  741. export { getSkillDirCommands as getCommandDirCommands }
  742. export { clearSkillCaches as clearCommandCaches }
  743. export { transformSkillFiles }
  744. // --- Dynamic skill discovery ---
  745. // State for dynamically discovered skills
  746. const dynamicSkillDirs = new Set<string>()
  747. const dynamicSkills = new Map<string, Command>()
  748. // --- Conditional skills (path-filtered) ---
  749. // Skills with paths frontmatter that haven't been activated yet
  750. const conditionalSkills = new Map<string, Command>()
  751. // Names of skills that have been activated (survives cache clears within a session)
  752. const activatedConditionalSkillNames = new Set<string>()
  753. // Signal fired when dynamic skills are loaded
  754. const skillsLoaded = createSignal()
  755. /**
  756. * Register a callback to be invoked when dynamic skills are loaded.
  757. * Used by other modules to clear caches without creating import cycles.
  758. * Returns an unsubscribe function.
  759. */
  760. export function onDynamicSkillsLoaded(callback: () => void): () => void {
  761. // Wrap at subscribe time so a throwing listener is logged and skipped
  762. // rather than aborting skillsLoaded.emit() and breaking skill loading.
  763. // Same callSafe pattern as growthbook.ts — createSignal.emit() has no
  764. // per-listener try/catch.
  765. return skillsLoaded.subscribe(() => {
  766. try {
  767. callback()
  768. } catch (error) {
  769. logError(error)
  770. }
  771. })
  772. }
  773. /**
  774. * Discovers skill directories by walking up from file paths to cwd.
  775. * Only discovers directories below cwd (cwd-level skills are loaded at startup).
  776. *
  777. * @param filePaths Array of file paths to check
  778. * @param cwd Current working directory (upper bound for discovery)
  779. * @returns Array of newly discovered skill directories, sorted deepest first
  780. */
  781. export async function discoverSkillDirsForPaths(
  782. filePaths: string[],
  783. cwd: string,
  784. ): Promise<string[]> {
  785. const fs = getFsImplementation()
  786. const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd
  787. const newDirs: string[] = []
  788. for (const filePath of filePaths) {
  789. // Start from the file's parent directory
  790. let currentDir = dirname(filePath)
  791. // Walk up to cwd but NOT including cwd itself
  792. // CWD-level skills are already loaded at startup, so we only discover nested ones
  793. // Use prefix+separator check to avoid matching /project-backup when cwd is /project
  794. while (currentDir.startsWith(resolvedCwd + pathSep)) {
  795. const skillDir = join(currentDir, '.claude', 'skills')
  796. // Skip if we've already checked this path (hit or miss) — avoids
  797. // repeating the same failed stat on every Read/Write/Edit call when
  798. // the directory doesn't exist (the common case).
  799. if (!dynamicSkillDirs.has(skillDir)) {
  800. dynamicSkillDirs.add(skillDir)
  801. try {
  802. await fs.stat(skillDir)
  803. // Skills dir exists. Before loading, check if the containing dir
  804. // is gitignored — blocks e.g. node_modules/pkg/.claude/skills from
  805. // loading silently. `git check-ignore` handles nested .gitignore,
  806. // .git/info/exclude, and global gitignore. Fails open outside a
  807. // git repo (exit 128 → false); the invocation-time trust dialog
  808. // is the actual security boundary.
  809. if (await isPathGitignored(currentDir, resolvedCwd)) {
  810. logForDebugging(
  811. `[skills] Skipped gitignored skills dir: ${skillDir}`,
  812. )
  813. continue
  814. }
  815. newDirs.push(skillDir)
  816. } catch {
  817. // Directory doesn't exist — already recorded above, continue
  818. }
  819. }
  820. // Move to parent
  821. const parent = dirname(currentDir)
  822. if (parent === currentDir) break // Reached root
  823. currentDir = parent
  824. }
  825. }
  826. // Sort by path depth (deepest first) so skills closer to the file take precedence
  827. return newDirs.sort(
  828. (a, b) => b.split(pathSep).length - a.split(pathSep).length,
  829. )
  830. }
  831. /**
  832. * Loads skills from the given directories and merges them into the dynamic skills map.
  833. * Skills from directories closer to the file (deeper paths) take precedence.
  834. *
  835. * @param dirs Array of skill directories to load from (should be sorted deepest first)
  836. */
  837. export async function addSkillDirectories(dirs: string[]): Promise<void> {
  838. if (
  839. !isSettingSourceEnabled('projectSettings') ||
  840. isRestrictedToPluginOnly('skills')
  841. ) {
  842. logForDebugging(
  843. '[skills] Dynamic skill discovery skipped: projectSettings disabled or plugin-only policy',
  844. )
  845. return
  846. }
  847. if (dirs.length === 0) {
  848. return
  849. }
  850. const previousSkillNamesForLogging = new Set(dynamicSkills.keys())
  851. // Load skills from all directories
  852. const loadedSkills = await Promise.all(
  853. dirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')),
  854. )
  855. // Process in reverse order (shallower first) so deeper paths override
  856. for (let i = loadedSkills.length - 1; i >= 0; i--) {
  857. for (const { skill } of loadedSkills[i] ?? []) {
  858. if (skill.type === 'prompt') {
  859. dynamicSkills.set(skill.name, skill)
  860. }
  861. }
  862. }
  863. const newSkillCount = loadedSkills.flat().length
  864. if (newSkillCount > 0) {
  865. const addedSkills = [...dynamicSkills.keys()].filter(
  866. n => !previousSkillNamesForLogging.has(n),
  867. )
  868. logForDebugging(
  869. `[skills] Dynamically discovered ${newSkillCount} skills from ${dirs.length} directories`,
  870. )
  871. if (addedSkills.length > 0) {
  872. logEvent('tengu_dynamic_skills_changed', {
  873. source:
  874. 'file_operation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  875. previousCount: previousSkillNamesForLogging.size,
  876. newCount: dynamicSkills.size,
  877. addedCount: addedSkills.length,
  878. directoryCount: dirs.length,
  879. })
  880. }
  881. }
  882. // Notify listeners that skills were loaded (so they can clear caches)
  883. skillsLoaded.emit()
  884. }
  885. /**
  886. * Gets all dynamically discovered skills.
  887. * These are skills discovered from file paths during the session.
  888. */
  889. export function getDynamicSkills(): Command[] {
  890. return Array.from(dynamicSkills.values())
  891. }
  892. /**
  893. * Activates conditional skills (skills with paths frontmatter) whose path
  894. * patterns match the given file paths. Activated skills are added to the
  895. * dynamic skills map, making them available to the model.
  896. *
  897. * Uses the `ignore` library (gitignore-style matching), matching the behavior
  898. * of CLAUDE.md conditional rules.
  899. *
  900. * @param filePaths Array of file paths being operated on
  901. * @param cwd Current working directory (paths are matched relative to cwd)
  902. * @returns Array of newly activated skill names
  903. */
  904. export function activateConditionalSkillsForPaths(
  905. filePaths: string[],
  906. cwd: string,
  907. ): string[] {
  908. if (conditionalSkills.size === 0) {
  909. return []
  910. }
  911. const activated: string[] = []
  912. for (const [name, skill] of conditionalSkills) {
  913. if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) {
  914. continue
  915. }
  916. const skillIgnore = ignore().add(skill.paths)
  917. for (const filePath of filePaths) {
  918. const relativePath = isAbsolute(filePath)
  919. ? relative(cwd, filePath)
  920. : filePath
  921. // ignore() throws on empty strings, paths escaping the base (../),
  922. // and absolute paths (Windows cross-drive relative() returns absolute).
  923. // Files outside cwd can't match cwd-relative patterns anyway.
  924. if (
  925. !relativePath ||
  926. relativePath.startsWith('..') ||
  927. isAbsolute(relativePath)
  928. ) {
  929. continue
  930. }
  931. if (skillIgnore.ignores(relativePath)) {
  932. // Activate this skill by moving it to dynamic skills
  933. dynamicSkills.set(name, skill)
  934. conditionalSkills.delete(name)
  935. activatedConditionalSkillNames.add(name)
  936. activated.push(name)
  937. logForDebugging(
  938. `[skills] Activated conditional skill '${name}' (matched path: ${relativePath})`,
  939. )
  940. break
  941. }
  942. }
  943. }
  944. if (activated.length > 0) {
  945. logEvent('tengu_dynamic_skills_changed', {
  946. source:
  947. 'conditional_paths' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  948. previousCount: dynamicSkills.size - activated.length,
  949. newCount: dynamicSkills.size,
  950. addedCount: activated.length,
  951. directoryCount: 0,
  952. })
  953. // Notify listeners that skills were loaded (so they can clear caches)
  954. skillsLoaded.emit()
  955. }
  956. return activated
  957. }
  958. /**
  959. * Gets the number of pending conditional skills (for testing/debugging).
  960. */
  961. export function getConditionalSkillCount(): number {
  962. return conditionalSkills.size
  963. }
  964. /**
  965. * Clears dynamic skill state (for testing).
  966. */
  967. export function clearDynamicSkills(): void {
  968. dynamicSkillDirs.clear()
  969. dynamicSkills.clear()
  970. conditionalSkills.clear()
  971. activatedConditionalSkillNames.clear()
  972. }
  973. // Expose createSkillCommand + parseSkillFrontmatterFields to MCP skill
  974. // discovery via a leaf registry module. See mcpSkillBuilders.ts for why this
  975. // indirection exists (a literal dynamic import from mcpSkills.ts fans a single
  976. // edge out into many cycle violations; a variable-specifier dynamic import
  977. // passes dep-cruiser but fails to resolve in Bun-bundled binaries at runtime).
  978. // eslint-disable-next-line custom-rules/no-top-level-side-effects -- write-once registration, idempotent
  979. registerMCPSkillBuilders({
  980. createSkillCommand,
  981. parseSkillFrontmatterFields,
  982. })