/ utils / plugins / loadPluginCommands.ts
loadPluginCommands.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import { basename, dirname, join } from 'path'
  3  import { getInlinePlugins, getSessionId } from '../../bootstrap/state.js'
  4  import type { Command } from '../../types/command.js'
  5  import { getPluginErrorMessage } from '../../types/plugin.js'
  6  import {
  7    parseArgumentNames,
  8    substituteArguments,
  9  } from '../argumentSubstitution.js'
 10  import { logForDebugging } from '../debug.js'
 11  import { EFFORT_LEVELS, parseEffortValue } from '../effort.js'
 12  import { isBareMode } from '../envUtils.js'
 13  import { isENOENT } from '../errors.js'
 14  import {
 15    coerceDescriptionToString,
 16    type FrontmatterData,
 17    parseBooleanFrontmatter,
 18    parseFrontmatter,
 19    parseShellFrontmatter,
 20  } from '../frontmatterParser.js'
 21  import { getFsImplementation, isDuplicatePath } from '../fsOperations.js'
 22  import {
 23    extractDescriptionFromMarkdown,
 24    parseSlashCommandToolsFromFrontmatter,
 25  } from '../markdownConfigLoader.js'
 26  import { parseUserSpecifiedModel } from '../model/model.js'
 27  import { executeShellCommandsInPrompt } from '../promptShellExecution.js'
 28  import { loadAllPluginsCacheOnly } from './pluginLoader.js'
 29  import {
 30    loadPluginOptions,
 31    substitutePluginVariables,
 32    substituteUserConfigInContent,
 33  } from './pluginOptionsStorage.js'
 34  import type { CommandMetadata, PluginManifest } from './schemas.js'
 35  import { walkPluginMarkdown } from './walkPluginMarkdown.js'
 36  
 37  // Similar to MarkdownFile but for plugin sources
 38  type PluginMarkdownFile = {
 39    filePath: string
 40    baseDir: string
 41    frontmatter: FrontmatterData
 42    content: string
 43  }
 44  
 45  // Configuration for loading commands or skills
 46  type LoadConfig = {
 47    isSkillMode: boolean // true when loading from skills/ directory
 48  }
 49  
 50  /**
 51   * Check if a file path is a skill file (SKILL.md)
 52   */
 53  function isSkillFile(filePath: string): boolean {
 54    return /^skill\.md$/i.test(basename(filePath))
 55  }
 56  
 57  /**
 58   * Get command name from file path, handling both regular files and skills
 59   */
 60  function getCommandNameFromFile(
 61    filePath: string,
 62    baseDir: string,
 63    pluginName: string,
 64  ): string {
 65    const isSkill = isSkillFile(filePath)
 66  
 67    if (isSkill) {
 68      // For skills, use the parent directory name
 69      const skillDirectory = dirname(filePath)
 70      const parentOfSkillDir = dirname(skillDirectory)
 71      const commandBaseName = basename(skillDirectory)
 72  
 73      // Build namespace from parent of skill directory
 74      const relativePath = parentOfSkillDir.startsWith(baseDir)
 75        ? parentOfSkillDir.slice(baseDir.length).replace(/^\//, '')
 76        : ''
 77      const namespace = relativePath ? relativePath.split('/').join(':') : ''
 78  
 79      return namespace
 80        ? `${pluginName}:${namespace}:${commandBaseName}`
 81        : `${pluginName}:${commandBaseName}`
 82    } else {
 83      // For regular files, use filename without .md
 84      const fileDirectory = dirname(filePath)
 85      const commandBaseName = basename(filePath).replace(/\.md$/, '')
 86  
 87      // Build namespace from file directory
 88      const relativePath = fileDirectory.startsWith(baseDir)
 89        ? fileDirectory.slice(baseDir.length).replace(/^\//, '')
 90        : ''
 91      const namespace = relativePath ? relativePath.split('/').join(':') : ''
 92  
 93      return namespace
 94        ? `${pluginName}:${namespace}:${commandBaseName}`
 95        : `${pluginName}:${commandBaseName}`
 96    }
 97  }
 98  
 99  /**
100   * Recursively collects all markdown files from a directory
101   */
102  async function collectMarkdownFiles(
103    dirPath: string,
104    baseDir: string,
105    loadedPaths: Set<string>,
106  ): Promise<PluginMarkdownFile[]> {
107    const files: PluginMarkdownFile[] = []
108    const fs = getFsImplementation()
109  
110    await walkPluginMarkdown(
111      dirPath,
112      async fullPath => {
113        if (isDuplicatePath(fs, fullPath, loadedPaths)) return
114        const content = await fs.readFile(fullPath, { encoding: 'utf-8' })
115        const { frontmatter, content: markdownContent } = parseFrontmatter(
116          content,
117          fullPath,
118        )
119        files.push({
120          filePath: fullPath,
121          baseDir,
122          frontmatter,
123          content: markdownContent,
124        })
125      },
126      { stopAtSkillDir: true, logLabel: 'commands' },
127    )
128  
129    return files
130  }
131  
132  /**
133   * Transforms plugin markdown files to handle skill directories
134   */
135  function transformPluginSkillFiles(
136    files: PluginMarkdownFile[],
137  ): PluginMarkdownFile[] {
138    const filesByDir = new Map<string, PluginMarkdownFile[]>()
139  
140    for (const file of files) {
141      const dir = dirname(file.filePath)
142      const dirFiles = filesByDir.get(dir) ?? []
143      dirFiles.push(file)
144      filesByDir.set(dir, dirFiles)
145    }
146  
147    const result: PluginMarkdownFile[] = []
148  
149    for (const [dir, dirFiles] of filesByDir) {
150      const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath))
151      if (skillFiles.length > 0) {
152        // Use the first skill file if multiple exist
153        const skillFile = skillFiles[0]!
154        if (skillFiles.length > 1) {
155          logForDebugging(
156            `Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`,
157          )
158        }
159        // Directory has a skill - only include the skill file
160        result.push(skillFile)
161      } else {
162        result.push(...dirFiles)
163      }
164    }
165  
166    return result
167  }
168  
169  async function loadCommandsFromDirectory(
170    commandsPath: string,
171    pluginName: string,
172    sourceName: string,
173    pluginManifest: PluginManifest,
174    pluginPath: string,
175    config: LoadConfig = { isSkillMode: false },
176    loadedPaths: Set<string> = new Set(),
177  ): Promise<Command[]> {
178    // Collect all markdown files
179    const markdownFiles = await collectMarkdownFiles(
180      commandsPath,
181      commandsPath,
182      loadedPaths,
183    )
184  
185    // Apply skill transformation
186    const processedFiles = transformPluginSkillFiles(markdownFiles)
187  
188    // Convert to commands
189    const commands: Command[] = []
190    for (const file of processedFiles) {
191      const commandName = getCommandNameFromFile(
192        file.filePath,
193        file.baseDir,
194        pluginName,
195      )
196  
197      const command = createPluginCommand(
198        commandName,
199        file,
200        sourceName,
201        pluginManifest,
202        pluginPath,
203        isSkillFile(file.filePath),
204        config,
205      )
206  
207      if (command) {
208        commands.push(command)
209      }
210    }
211  
212    return commands
213  }
214  
215  /**
216   * Create a Command from a plugin markdown file
217   */
218  function createPluginCommand(
219    commandName: string,
220    file: PluginMarkdownFile,
221    sourceName: string,
222    pluginManifest: PluginManifest,
223    pluginPath: string,
224    isSkill: boolean,
225    config: LoadConfig = { isSkillMode: false },
226  ): Command | null {
227    try {
228      const { frontmatter, content } = file
229  
230      const validatedDescription = coerceDescriptionToString(
231        frontmatter.description,
232        commandName,
233      )
234      const description =
235        validatedDescription ??
236        extractDescriptionFromMarkdown(
237          content,
238          isSkill ? 'Plugin skill' : 'Plugin command',
239        )
240  
241      // Substitute ${CLAUDE_PLUGIN_ROOT} in allowed-tools before parsing
242      const rawAllowedTools = frontmatter['allowed-tools']
243      const substitutedAllowedTools =
244        typeof rawAllowedTools === 'string'
245          ? substitutePluginVariables(rawAllowedTools, {
246              path: pluginPath,
247              source: sourceName,
248            })
249          : Array.isArray(rawAllowedTools)
250            ? rawAllowedTools.map(tool =>
251                typeof tool === 'string'
252                  ? substitutePluginVariables(tool, {
253                      path: pluginPath,
254                      source: sourceName,
255                    })
256                  : tool,
257              )
258            : rawAllowedTools
259      const allowedTools = parseSlashCommandToolsFromFrontmatter(
260        substitutedAllowedTools,
261      )
262  
263      const argumentHint = frontmatter['argument-hint'] as string | undefined
264      const argumentNames = parseArgumentNames(
265        frontmatter.arguments as string | string[] | undefined,
266      )
267      const whenToUse = frontmatter.when_to_use as string | undefined
268      const version = frontmatter.version as string | undefined
269      const displayName = frontmatter.name as string | undefined
270  
271      // Handle model configuration, resolving aliases like 'haiku', 'sonnet', 'opus'
272      const model =
273        frontmatter.model === 'inherit'
274          ? undefined
275          : frontmatter.model
276            ? parseUserSpecifiedModel(frontmatter.model as string)
277            : undefined
278  
279      const effortRaw = frontmatter['effort']
280      const effort =
281        effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
282      if (effortRaw !== undefined && effort === undefined) {
283        logForDebugging(
284          `Plugin command ${commandName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
285        )
286      }
287  
288      const disableModelInvocation = parseBooleanFrontmatter(
289        frontmatter['disable-model-invocation'],
290      )
291  
292      const userInvocableValue = frontmatter['user-invocable']
293      const userInvocable =
294        userInvocableValue === undefined
295          ? true
296          : parseBooleanFrontmatter(userInvocableValue)
297  
298      const shell = parseShellFrontmatter(frontmatter.shell, commandName)
299  
300      return {
301        type: 'prompt',
302        name: commandName,
303        description,
304        hasUserSpecifiedDescription: validatedDescription !== null,
305        allowedTools,
306        argumentHint,
307        argNames: argumentNames.length > 0 ? argumentNames : undefined,
308        whenToUse,
309        version,
310        model,
311        effort,
312        disableModelInvocation,
313        userInvocable,
314        contentLength: content.length,
315        source: 'plugin' as const,
316        loadedFrom: isSkill || config.isSkillMode ? 'plugin' : undefined,
317        pluginInfo: {
318          pluginManifest,
319          repository: sourceName,
320        },
321        isHidden: !userInvocable,
322        progressMessage: isSkill || config.isSkillMode ? 'loading' : 'running',
323        userFacingName(): string {
324          return displayName || commandName
325        },
326        async getPromptForCommand(args, context) {
327          // For skills from skills/ directory, include base directory
328          let finalContent = config.isSkillMode
329            ? `Base directory for this skill: ${dirname(file.filePath)}\n\n${content}`
330            : content
331  
332          finalContent = substituteArguments(
333            finalContent,
334            args,
335            true,
336            argumentNames,
337          )
338  
339          // Replace ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} with their paths
340          finalContent = substitutePluginVariables(finalContent, {
341            path: pluginPath,
342            source: sourceName,
343          })
344  
345          // Replace ${user_config.X} with saved option values. Sensitive keys
346          // resolve to a descriptive placeholder instead — skill content goes to
347          // the model prompt and we don't put secrets there.
348          if (pluginManifest.userConfig) {
349            finalContent = substituteUserConfigInContent(
350              finalContent,
351              loadPluginOptions(sourceName),
352              pluginManifest.userConfig,
353            )
354          }
355  
356          // Replace ${CLAUDE_SKILL_DIR} with this specific skill's directory.
357          // Distinct from ${CLAUDE_PLUGIN_ROOT}: a plugin can contain multiple
358          // skills, so CLAUDE_PLUGIN_ROOT points to the plugin root while
359          // CLAUDE_SKILL_DIR points to the individual skill's subdirectory.
360          if (config.isSkillMode) {
361            const rawSkillDir = dirname(file.filePath)
362            const skillDir =
363              process.platform === 'win32'
364                ? rawSkillDir.replace(/\\/g, '/')
365                : rawSkillDir
366            finalContent = finalContent.replace(
367              /\$\{CLAUDE_SKILL_DIR\}/g,
368              skillDir,
369            )
370          }
371  
372          // Replace ${CLAUDE_SESSION_ID} with the current session ID
373          finalContent = finalContent.replace(
374            /\$\{CLAUDE_SESSION_ID\}/g,
375            getSessionId(),
376          )
377  
378          finalContent = await executeShellCommandsInPrompt(
379            finalContent,
380            {
381              ...context,
382              getAppState() {
383                const appState = context.getAppState()
384                return {
385                  ...appState,
386                  toolPermissionContext: {
387                    ...appState.toolPermissionContext,
388                    alwaysAllowRules: {
389                      ...appState.toolPermissionContext.alwaysAllowRules,
390                      command: allowedTools,
391                    },
392                  },
393                }
394              },
395            },
396            `/${commandName}`,
397            shell,
398          )
399  
400          return [{ type: 'text', text: finalContent }]
401        },
402      } satisfies Command
403    } catch (error) {
404      logForDebugging(
405        `Failed to create command from ${file.filePath}: ${error}`,
406        {
407          level: 'error',
408        },
409      )
410      return null
411    }
412  }
413  
414  export const getPluginCommands = memoize(async (): Promise<Command[]> => {
415    // --bare: skip marketplace plugin auto-load. Explicit --plugin-dir still
416    // works — getInlinePlugins() is set by main.tsx from --plugin-dir.
417    // loadAllPluginsCacheOnly already short-circuits to inline-only when
418    // inlinePlugins.length > 0.
419    if (isBareMode() && getInlinePlugins().length === 0) {
420      return []
421    }
422    // Only load commands from enabled plugins
423    const { enabled, errors } = await loadAllPluginsCacheOnly()
424  
425    if (errors.length > 0) {
426      logForDebugging(
427        `Plugin loading errors: ${errors.map(e => getPluginErrorMessage(e)).join(', ')}`,
428      )
429    }
430  
431    // Process plugins in parallel; each plugin has its own loadedPaths scope
432    const perPluginCommands = await Promise.all(
433      enabled.map(async (plugin): Promise<Command[]> => {
434        // Track loaded file paths to prevent duplicates within this plugin
435        const loadedPaths = new Set<string>()
436        const pluginCommands: Command[] = []
437  
438        // Load commands from default commands directory
439        if (plugin.commandsPath) {
440          try {
441            const commands = await loadCommandsFromDirectory(
442              plugin.commandsPath,
443              plugin.name,
444              plugin.source,
445              plugin.manifest,
446              plugin.path,
447              { isSkillMode: false },
448              loadedPaths,
449            )
450            pluginCommands.push(...commands)
451  
452            if (commands.length > 0) {
453              logForDebugging(
454                `Loaded ${commands.length} commands from plugin ${plugin.name} default directory`,
455              )
456            }
457          } catch (error) {
458            logForDebugging(
459              `Failed to load commands from plugin ${plugin.name} default directory: ${error}`,
460              { level: 'error' },
461            )
462          }
463        }
464  
465        // Load commands from additional paths specified in manifest
466        if (plugin.commandsPaths) {
467          logForDebugging(
468            `Plugin ${plugin.name} has commandsPaths: ${plugin.commandsPaths.join(', ')}`,
469          )
470          // Process all commandsPaths in parallel. isDuplicatePath is synchronous
471          // (check-and-add), so concurrent access to loadedPaths is safe.
472          const pathResults = await Promise.all(
473            plugin.commandsPaths.map(async (commandPath): Promise<Command[]> => {
474              try {
475                const fs = getFsImplementation()
476                const stats = await fs.stat(commandPath)
477                logForDebugging(
478                  `Checking commandPath ${commandPath} - isDirectory: ${stats.isDirectory()}, isFile: ${stats.isFile()}`,
479                )
480  
481                if (stats.isDirectory()) {
482                  // Load all .md files and skill directories from directory
483                  const commands = await loadCommandsFromDirectory(
484                    commandPath,
485                    plugin.name,
486                    plugin.source,
487                    plugin.manifest,
488                    plugin.path,
489                    { isSkillMode: false },
490                    loadedPaths,
491                  )
492  
493                  if (commands.length > 0) {
494                    logForDebugging(
495                      `Loaded ${commands.length} commands from plugin ${plugin.name} custom path: ${commandPath}`,
496                    )
497                  } else {
498                    logForDebugging(
499                      `Warning: No commands found in plugin ${plugin.name} custom directory: ${commandPath}. Expected .md files or SKILL.md in subdirectories.`,
500                      { level: 'warn' },
501                    )
502                  }
503                  return commands
504                } else if (stats.isFile() && commandPath.endsWith('.md')) {
505                  if (isDuplicatePath(fs, commandPath, loadedPaths)) {
506                    return []
507                  }
508  
509                  // Load single command file
510                  const content = await fs.readFile(commandPath, {
511                    encoding: 'utf-8',
512                  })
513                  const { frontmatter, content: markdownContent } =
514                    parseFrontmatter(content, commandPath)
515  
516                  // Check if there's metadata for this command (object-mapping format)
517                  let commandName: string | undefined
518                  let metadataOverride: CommandMetadata | undefined
519  
520                  if (plugin.commandsMetadata) {
521                    // Find metadata by matching the command's absolute path to the metadata source
522                    // Convert metadata.source (relative to plugin root) to absolute path for comparison
523                    for (const [name, metadata] of Object.entries(
524                      plugin.commandsMetadata,
525                    )) {
526                      if (metadata.source) {
527                        const fullMetadataPath = join(
528                          plugin.path,
529                          metadata.source,
530                        )
531                        if (commandPath === fullMetadataPath) {
532                          commandName = `${plugin.name}:${name}`
533                          metadataOverride = metadata
534                          break
535                        }
536                      }
537                    }
538                  }
539  
540                  // Fall back to filename-based naming if no metadata
541                  if (!commandName) {
542                    commandName = `${plugin.name}:${basename(commandPath).replace(/\.md$/, '')}`
543                  }
544  
545                  // Apply metadata overrides to frontmatter
546                  const finalFrontmatter = metadataOverride
547                    ? {
548                        ...frontmatter,
549                        ...(metadataOverride.description && {
550                          description: metadataOverride.description,
551                        }),
552                        ...(metadataOverride.argumentHint && {
553                          'argument-hint': metadataOverride.argumentHint,
554                        }),
555                        ...(metadataOverride.model && {
556                          model: metadataOverride.model,
557                        }),
558                        ...(metadataOverride.allowedTools && {
559                          'allowed-tools':
560                            metadataOverride.allowedTools.join(','),
561                        }),
562                      }
563                    : frontmatter
564  
565                  const file: PluginMarkdownFile = {
566                    filePath: commandPath,
567                    baseDir: dirname(commandPath),
568                    frontmatter: finalFrontmatter,
569                    content: markdownContent,
570                  }
571  
572                  const command = createPluginCommand(
573                    commandName,
574                    file,
575                    plugin.source,
576                    plugin.manifest,
577                    plugin.path,
578                    false,
579                  )
580  
581                  if (command) {
582                    logForDebugging(
583                      `Loaded command from plugin ${plugin.name} custom file: ${commandPath}${metadataOverride ? ' (with metadata override)' : ''}`,
584                    )
585                    return [command]
586                  }
587                }
588                return []
589              } catch (error) {
590                logForDebugging(
591                  `Failed to load commands from plugin ${plugin.name} custom path ${commandPath}: ${error}`,
592                  { level: 'error' },
593                )
594                return []
595              }
596            }),
597          )
598          for (const commands of pathResults) {
599            pluginCommands.push(...commands)
600          }
601        }
602  
603        // Load commands with inline content (no source file)
604        // Note: Commands with source files were already loaded in the previous loop
605        // when iterating through commandsPaths. This loop handles metadata entries
606        // that specify inline content instead of file references.
607        if (plugin.commandsMetadata) {
608          for (const [name, metadata] of Object.entries(
609            plugin.commandsMetadata,
610          )) {
611            // Only process entries with inline content (no source)
612            if (metadata.content && !metadata.source) {
613              try {
614                // Parse inline content for frontmatter
615                const { frontmatter, content: markdownContent } =
616                  parseFrontmatter(
617                    metadata.content,
618                    `<inline:${plugin.name}:${name}>`,
619                  )
620  
621                // Apply metadata overrides to frontmatter
622                const finalFrontmatter: FrontmatterData = {
623                  ...frontmatter,
624                  ...(metadata.description && {
625                    description: metadata.description,
626                  }),
627                  ...(metadata.argumentHint && {
628                    'argument-hint': metadata.argumentHint,
629                  }),
630                  ...(metadata.model && {
631                    model: metadata.model,
632                  }),
633                  ...(metadata.allowedTools && {
634                    'allowed-tools': metadata.allowedTools.join(','),
635                  }),
636                }
637  
638                const commandName = `${plugin.name}:${name}`
639                const file: PluginMarkdownFile = {
640                  filePath: `<inline:${commandName}>`, // Virtual path for inline content
641                  baseDir: plugin.path, // Use plugin root as base directory
642                  frontmatter: finalFrontmatter,
643                  content: markdownContent,
644                }
645  
646                const command = createPluginCommand(
647                  commandName,
648                  file,
649                  plugin.source,
650                  plugin.manifest,
651                  plugin.path,
652                  false,
653                )
654  
655                if (command) {
656                  pluginCommands.push(command)
657                  logForDebugging(
658                    `Loaded inline content command from plugin ${plugin.name}: ${commandName}`,
659                  )
660                }
661              } catch (error) {
662                logForDebugging(
663                  `Failed to load inline content command ${name} from plugin ${plugin.name}: ${error}`,
664                  { level: 'error' },
665                )
666              }
667            }
668          }
669        }
670        return pluginCommands
671      }),
672    )
673  
674    const allCommands = perPluginCommands.flat()
675    logForDebugging(`Total plugin commands loaded: ${allCommands.length}`)
676    return allCommands
677  })
678  
679  export function clearPluginCommandCache(): void {
680    getPluginCommands.cache?.clear?.()
681  }
682  
683  /**
684   * Loads skills from plugin skills directories
685   * Skills are directories containing SKILL.md files
686   */
687  async function loadSkillsFromDirectory(
688    skillsPath: string,
689    pluginName: string,
690    sourceName: string,
691    pluginManifest: PluginManifest,
692    pluginPath: string,
693    loadedPaths: Set<string>,
694  ): Promise<Command[]> {
695    const fs = getFsImplementation()
696    const skills: Command[] = []
697  
698    // First, check if skillsPath itself contains SKILL.md (direct skill directory)
699    const directSkillPath = join(skillsPath, 'SKILL.md')
700    let directSkillContent: string | null = null
701    try {
702      directSkillContent = await fs.readFile(directSkillPath, {
703        encoding: 'utf-8',
704      })
705    } catch (e: unknown) {
706      if (!isENOENT(e)) {
707        logForDebugging(`Failed to load skill from ${directSkillPath}: ${e}`, {
708          level: 'error',
709        })
710        return skills
711      }
712      // ENOENT: no direct SKILL.md, fall through to scan subdirectories
713    }
714  
715    if (directSkillContent !== null) {
716      // This is a direct skill directory, load the skill from here
717      if (isDuplicatePath(fs, directSkillPath, loadedPaths)) {
718        return skills
719      }
720      try {
721        const { frontmatter, content: markdownContent } = parseFrontmatter(
722          directSkillContent,
723          directSkillPath,
724        )
725  
726        const skillName = `${pluginName}:${basename(skillsPath)}`
727  
728        const file: PluginMarkdownFile = {
729          filePath: directSkillPath,
730          baseDir: dirname(directSkillPath),
731          frontmatter,
732          content: markdownContent,
733        }
734  
735        const skill = createPluginCommand(
736          skillName,
737          file,
738          sourceName,
739          pluginManifest,
740          pluginPath,
741          true, // isSkill
742          { isSkillMode: true }, // config
743        )
744  
745        if (skill) {
746          skills.push(skill)
747        }
748      } catch (error) {
749        logForDebugging(
750          `Failed to load skill from ${directSkillPath}: ${error}`,
751          {
752            level: 'error',
753          },
754        )
755      }
756      return skills
757    }
758  
759    // Otherwise, scan for subdirectories containing SKILL.md files
760    let entries
761    try {
762      entries = await fs.readdir(skillsPath)
763    } catch (e: unknown) {
764      if (!isENOENT(e)) {
765        logForDebugging(
766          `Failed to load skills from directory ${skillsPath}: ${e}`,
767          { level: 'error' },
768        )
769      }
770      return skills
771    }
772  
773    await Promise.all(
774      entries.map(async entry => {
775        // Accept both directories and symlinks (symlinks may point to skill directories)
776        if (!entry.isDirectory() && !entry.isSymbolicLink()) {
777          return
778        }
779  
780        const skillDirPath = join(skillsPath, entry.name)
781        const skillFilePath = join(skillDirPath, 'SKILL.md')
782  
783        // Try to read SKILL.md directly; skip if it doesn't exist
784        let content: string
785        try {
786          content = await fs.readFile(skillFilePath, { encoding: 'utf-8' })
787        } catch (e: unknown) {
788          if (!isENOENT(e)) {
789            logForDebugging(`Failed to load skill from ${skillFilePath}: ${e}`, {
790              level: 'error',
791            })
792          }
793          return
794        }
795  
796        if (isDuplicatePath(fs, skillFilePath, loadedPaths)) {
797          return
798        }
799  
800        try {
801          const { frontmatter, content: markdownContent } = parseFrontmatter(
802            content,
803            skillFilePath,
804          )
805  
806          const skillName = `${pluginName}:${entry.name}`
807  
808          const file: PluginMarkdownFile = {
809            filePath: skillFilePath,
810            baseDir: dirname(skillFilePath),
811            frontmatter,
812            content: markdownContent,
813          }
814  
815          const skill = createPluginCommand(
816            skillName,
817            file,
818            sourceName,
819            pluginManifest,
820            pluginPath,
821            true, // isSkill
822            { isSkillMode: true }, // config
823          )
824  
825          if (skill) {
826            skills.push(skill)
827          }
828        } catch (error) {
829          logForDebugging(
830            `Failed to load skill from ${skillFilePath}: ${error}`,
831            { level: 'error' },
832          )
833        }
834      }),
835    )
836  
837    return skills
838  }
839  
840  export const getPluginSkills = memoize(async (): Promise<Command[]> => {
841    // --bare: same gate as getPluginCommands above — honor explicit
842    // --plugin-dir, skip marketplace auto-load.
843    if (isBareMode() && getInlinePlugins().length === 0) {
844      return []
845    }
846    // Only load skills from enabled plugins
847    const { enabled, errors } = await loadAllPluginsCacheOnly()
848  
849    if (errors.length > 0) {
850      logForDebugging(
851        `Plugin loading errors: ${errors.map(e => getPluginErrorMessage(e)).join(', ')}`,
852      )
853    }
854  
855    logForDebugging(
856      `getPluginSkills: Processing ${enabled.length} enabled plugins`,
857    )
858  
859    // Process plugins in parallel; each plugin has its own loadedPaths scope
860    const perPluginSkills = await Promise.all(
861      enabled.map(async (plugin): Promise<Command[]> => {
862        // Track loaded file paths to prevent duplicates within this plugin
863        const loadedPaths = new Set<string>()
864        const pluginSkills: Command[] = []
865  
866        logForDebugging(
867          `Checking plugin ${plugin.name}: skillsPath=${plugin.skillsPath ? 'exists' : 'none'}, skillsPaths=${plugin.skillsPaths ? plugin.skillsPaths.length : 0} paths`,
868        )
869        // Load skills from default skills directory
870        if (plugin.skillsPath) {
871          logForDebugging(
872            `Attempting to load skills from plugin ${plugin.name} default skillsPath: ${plugin.skillsPath}`,
873          )
874          try {
875            const skills = await loadSkillsFromDirectory(
876              plugin.skillsPath,
877              plugin.name,
878              plugin.source,
879              plugin.manifest,
880              plugin.path,
881              loadedPaths,
882            )
883            pluginSkills.push(...skills)
884  
885            logForDebugging(
886              `Loaded ${skills.length} skills from plugin ${plugin.name} default directory`,
887            )
888          } catch (error) {
889            logForDebugging(
890              `Failed to load skills from plugin ${plugin.name} default directory: ${error}`,
891              { level: 'error' },
892            )
893          }
894        }
895  
896        // Load skills from additional paths specified in manifest
897        if (plugin.skillsPaths) {
898          logForDebugging(
899            `Attempting to load skills from plugin ${plugin.name} skillsPaths: ${plugin.skillsPaths.join(', ')}`,
900          )
901          // Process all skillsPaths in parallel. isDuplicatePath is synchronous
902          // (check-and-add), so concurrent access to loadedPaths is safe.
903          const pathResults = await Promise.all(
904            plugin.skillsPaths.map(async (skillPath): Promise<Command[]> => {
905              try {
906                logForDebugging(
907                  `Loading from skillPath: ${skillPath} for plugin ${plugin.name}`,
908                )
909                const skills = await loadSkillsFromDirectory(
910                  skillPath,
911                  plugin.name,
912                  plugin.source,
913                  plugin.manifest,
914                  plugin.path,
915                  loadedPaths,
916                )
917  
918                logForDebugging(
919                  `Loaded ${skills.length} skills from plugin ${plugin.name} custom path: ${skillPath}`,
920                )
921                return skills
922              } catch (error) {
923                logForDebugging(
924                  `Failed to load skills from plugin ${plugin.name} custom path ${skillPath}: ${error}`,
925                  { level: 'error' },
926                )
927                return []
928              }
929            }),
930          )
931          for (const skills of pathResults) {
932            pluginSkills.push(...skills)
933          }
934        }
935        return pluginSkills
936      }),
937    )
938  
939    const allSkills = perPluginSkills.flat()
940    logForDebugging(`Total plugin skills loaded: ${allSkills.length}`)
941    return allSkills
942  })
943  
944  export function clearPluginSkillsCache(): void {
945    getPluginSkills.cache?.clear?.()
946  }