| import memoize from 'lodash-es/memoize.js' |
| import { basename, dirname, join } from 'path' |
| import { getInlinePlugins, getSessionId } from '../../bootstrap/state.js' |
| import type { Command } from '../../types/command.js' |
| import { getPluginErrorMessage } from '../../types/plugin.js' |
| import { |
| parseArgumentNames, |
| substituteArguments, |
| } from '../argumentSubstitution.js' |
| import { logForDebugging } from '../debug.js' |
| import { EFFORT_LEVELS, parseEffortValue } from '../effort.js' |
| import { isBareMode } from '../envUtils.js' |
| import { isENOENT } from '../errors.js' |
| import { |
| coerceDescriptionToString, |
| type FrontmatterData, |
| parseBooleanFrontmatter, |
| parseFrontmatter, |
| parseShellFrontmatter, |
| } from '../frontmatterParser.js' |
| import { getFsImplementation, isDuplicatePath } from '../fsOperations.js' |
| import { |
| extractDescriptionFromMarkdown, |
| parseSlashCommandToolsFromFrontmatter, |
| } from '../markdownConfigLoader.js' |
| import { parseUserSpecifiedModel } from '../model/model.js' |
| import { executeShellCommandsInPrompt } from '../promptShellExecution.js' |
| import { loadAllPluginsCacheOnly } from './pluginLoader.js' |
| import { |
| loadPluginOptions, |
| substitutePluginVariables, |
| substituteUserConfigInContent, |
| } from './pluginOptionsStorage.js' |
| import type { CommandMetadata, PluginManifest } from './schemas.js' |
| import { walkPluginMarkdown } from './walkPluginMarkdown.js' |
|
|
| |
| type PluginMarkdownFile = { |
| filePath: string |
| baseDir: string |
| frontmatter: FrontmatterData |
| content: string |
| } |
|
|
| |
| type LoadConfig = { |
| isSkillMode: boolean |
| } |
|
|
| |
| |
| |
| function isSkillFile(filePath: string): boolean { |
| return /^skill\.md$/i.test(basename(filePath)) |
| } |
|
|
| |
| |
| |
| function getCommandNameFromFile( |
| filePath: string, |
| baseDir: string, |
| pluginName: string, |
| ): string { |
| const isSkill = isSkillFile(filePath) |
|
|
| if (isSkill) { |
| |
| const skillDirectory = dirname(filePath) |
| const parentOfSkillDir = dirname(skillDirectory) |
| const commandBaseName = basename(skillDirectory) |
|
|
| |
| const relativePath = parentOfSkillDir.startsWith(baseDir) |
| ? parentOfSkillDir.slice(baseDir.length).replace(/^\//, '') |
| : '' |
| const namespace = relativePath ? relativePath.split('/').join(':') : '' |
|
|
| return namespace |
| ? `${pluginName}:${namespace}:${commandBaseName}` |
| : `${pluginName}:${commandBaseName}` |
| } else { |
| // For regular files, use filename without .md |
| const fileDirectory = dirname(filePath) |
| const commandBaseName = basename(filePath).replace(/\.md$/, '') |
| |
| // Build namespace from file directory |
| const relativePath = fileDirectory.startsWith(baseDir) |
| ? fileDirectory.slice(baseDir.length).replace(/^\//, '') |
| : '' |
| const namespace = relativePath ? relativePath.split('/').join(':') : '' |
| |
| return namespace |
| ? `${pluginName}:${namespace}:${commandBaseName}` |
| : `${pluginName}:${commandBaseName}` |
| } |
| } |
| |
| /** |
| * Recursively collects all markdown files from a directory |
| */ |
| async function collectMarkdownFiles( |
| dirPath: string, |
| baseDir: string, |
| loadedPaths: Set<string>, |
| ): Promise<PluginMarkdownFile[]> { |
| const files: PluginMarkdownFile[] = [] |
| const fs = getFsImplementation() |
| |
| await walkPluginMarkdown( |
| dirPath, |
| async fullPath => { |
| if (isDuplicatePath(fs, fullPath, loadedPaths)) return |
| const content = await fs.readFile(fullPath, { encoding: 'utf-8' }) |
| const { frontmatter, content: markdownContent } = parseFrontmatter( |
| content, |
| fullPath, |
| ) |
| files.push({ |
| filePath: fullPath, |
| baseDir, |
| frontmatter, |
| content: markdownContent, |
| }) |
| }, |
| { stopAtSkillDir: true, logLabel: 'commands' }, |
| ) |
| |
| return files |
| } |
| |
| /** |
| * Transforms plugin markdown files to handle skill directories |
| */ |
| function transformPluginSkillFiles( |
| files: PluginMarkdownFile[], |
| ): PluginMarkdownFile[] { |
| const filesByDir = new Map<string, PluginMarkdownFile[]>() |
| |
| for (const file of files) { |
| const dir = dirname(file.filePath) |
| const dirFiles = filesByDir.get(dir) ?? [] |
| dirFiles.push(file) |
| filesByDir.set(dir, dirFiles) |
| } |
| |
| const result: PluginMarkdownFile[] = [] |
| |
| for (const [dir, dirFiles] of filesByDir) { |
| const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath)) |
| if (skillFiles.length > 0) { |
| // Use the first skill file if multiple exist |
| const skillFile = skillFiles[0]! |
| if (skillFiles.length > 1) { |
| logForDebugging( |
| `Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`, |
| ) |
| } |
| // Directory has a skill - only include the skill file |
| result.push(skillFile) |
| } else { |
| result.push(...dirFiles) |
| } |
| } |
| |
| return result |
| } |
| |
| async function loadCommandsFromDirectory( |
| commandsPath: string, |
| pluginName: string, |
| sourceName: string, |
| pluginManifest: PluginManifest, |
| pluginPath: string, |
| config: LoadConfig = { isSkillMode: false }, |
| loadedPaths: Set<string> = new Set(), |
| ): Promise<Command[]> { |
| // Collect all markdown files |
| const markdownFiles = await collectMarkdownFiles( |
| commandsPath, |
| commandsPath, |
| loadedPaths, |
| ) |
| |
| // Apply skill transformation |
| const processedFiles = transformPluginSkillFiles(markdownFiles) |
| |
| // Convert to commands |
| const commands: Command[] = [] |
| for (const file of processedFiles) { |
| const commandName = getCommandNameFromFile( |
| file.filePath, |
| file.baseDir, |
| pluginName, |
| ) |
| |
| const command = createPluginCommand( |
| commandName, |
| file, |
| sourceName, |
| pluginManifest, |
| pluginPath, |
| isSkillFile(file.filePath), |
| config, |
| ) |
| |
| if (command) { |
| commands.push(command) |
| } |
| } |
| |
| return commands |
| } |
| |
| /** |
| * Create a Command from a plugin markdown file |
| */ |
| function createPluginCommand( |
| commandName: string, |
| file: PluginMarkdownFile, |
| sourceName: string, |
| pluginManifest: PluginManifest, |
| pluginPath: string, |
| isSkill: boolean, |
| config: LoadConfig = { isSkillMode: false }, |
| ): Command | null { |
| try { |
| const { frontmatter, content } = file |
| |
| const validatedDescription = coerceDescriptionToString( |
| frontmatter.description, |
| commandName, |
| ) |
| const description = |
| validatedDescription ?? |
| extractDescriptionFromMarkdown( |
| content, |
| isSkill ? 'Plugin skill' : 'Plugin command', |
| ) |
| |
| // Substitute ${CLAUDE_PLUGIN_ROOT} in allowed-tools before parsing |
| const rawAllowedTools = frontmatter['allowed-tools'] |
| const substitutedAllowedTools = |
| typeof rawAllowedTools === 'string' |
| ? substitutePluginVariables(rawAllowedTools, { |
| path: pluginPath, |
| source: sourceName, |
| }) |
| : Array.isArray(rawAllowedTools) |
| ? rawAllowedTools.map(tool => |
| typeof tool === 'string' |
| ? substitutePluginVariables(tool, { |
| path: pluginPath, |
| source: sourceName, |
| }) |
| : tool, |
| ) |
| : rawAllowedTools |
| const allowedTools = parseSlashCommandToolsFromFrontmatter( |
| substitutedAllowedTools, |
| ) |
| |
| const argumentHint = frontmatter['argument-hint'] as string | undefined |
| const argumentNames = parseArgumentNames( |
| frontmatter.arguments as string | string[] | undefined, |
| ) |
| const whenToUse = frontmatter.when_to_use as string | undefined |
| const version = frontmatter.version as string | undefined |
| const displayName = frontmatter.name as string | undefined |
| |
| // Handle model configuration, resolving aliases like 'haiku', 'sonnet', 'opus' |
| 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( |
| `Plugin command ${commandName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`, |
| ) |
| } |
| |
| const disableModelInvocation = parseBooleanFrontmatter( |
| frontmatter['disable-model-invocation'], |
| ) |
| |
| const userInvocableValue = frontmatter['user-invocable'] |
| const userInvocable = |
| userInvocableValue === undefined |
| ? true |
| : parseBooleanFrontmatter(userInvocableValue) |
| |
| const shell = parseShellFrontmatter(frontmatter.shell, commandName) |
| |
| return { |
| type: 'prompt', |
| name: commandName, |
| description, |
| hasUserSpecifiedDescription: validatedDescription !== null, |
| allowedTools, |
| argumentHint, |
| argNames: argumentNames.length > 0 ? argumentNames : undefined, |
| whenToUse, |
| version, |
| model, |
| effort, |
| disableModelInvocation, |
| userInvocable, |
| contentLength: content.length, |
| source: 'plugin' as const, |
| loadedFrom: isSkill || config.isSkillMode ? 'plugin' : undefined, |
| pluginInfo: { |
| pluginManifest, |
| repository: sourceName, |
| }, |
| isHidden: !userInvocable, |
| progressMessage: isSkill || config.isSkillMode ? 'loading' : 'running', |
| userFacingName(): string { |
| return displayName || commandName |
| }, |
| async getPromptForCommand(args, context) { |
| // For skills from skills/ directory, include base directory |
| let finalContent = config.isSkillMode |
| ? `Base directory for this skill: ${dirname(file.filePath)}\n\n${content}` |
| : content |
| |
| finalContent = substituteArguments( |
| finalContent, |
| args, |
| true, |
| argumentNames, |
| ) |
| |
| // Replace ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} with their paths |
| finalContent = substitutePluginVariables(finalContent, { |
| path: pluginPath, |
| source: sourceName, |
| }) |
| |
| // Replace ${user_config.X} with saved option values. Sensitive keys |
| // resolve to a descriptive placeholder instead — skill content goes to |
| // the model prompt and we don't put secrets there. |
| if (pluginManifest.userConfig) { |
| finalContent = substituteUserConfigInContent( |
| finalContent, |
| loadPluginOptions(sourceName), |
| pluginManifest.userConfig, |
| ) |
| } |
| |
| // Replace ${CLAUDE_SKILL_DIR} with this specific skill's directory. |
| // Distinct from ${CLAUDE_PLUGIN_ROOT}: a plugin can contain multiple |
| // skills, so CLAUDE_PLUGIN_ROOT points to the plugin root while |
| // CLAUDE_SKILL_DIR points to the individual skill's subdirectory. |
| if (config.isSkillMode) { |
| const rawSkillDir = dirname(file.filePath) |
| const skillDir = |
| process.platform === 'win32' |
| ? rawSkillDir.replace(/\\/g, '/') |
| : rawSkillDir |
| 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(), |
| ) |
| |
| finalContent = await executeShellCommandsInPrompt( |
| finalContent, |
| { |
| ...context, |
| getAppState() { |
| const appState = context.getAppState() |
| return { |
| ...appState, |
| toolPermissionContext: { |
| ...appState.toolPermissionContext, |
| alwaysAllowRules: { |
| ...appState.toolPermissionContext.alwaysAllowRules, |
| command: allowedTools, |
| }, |
| }, |
| } |
| }, |
| }, |
| `/${commandName}`, |
| shell, |
| ) |
| |
| return [{ type: 'text', text: finalContent }] |
| }, |
| } satisfies Command |
| } catch (error) { |
| logForDebugging( |
| `Failed to create command from ${file.filePath}: ${error}`, |
| { |
| level: 'error', |
| }, |
| ) |
| return null |
| } |
| } |
| |
| export const getPluginCommands = memoize(async (): Promise<Command[]> => { |
| // --bare: skip marketplace plugin auto-load. Explicit --plugin-dir still |
| // works — getInlinePlugins() is set by main.tsx from --plugin-dir. |
| // loadAllPluginsCacheOnly already short-circuits to inline-only when |
| // inlinePlugins.length > 0. |
| if (isBareMode() && getInlinePlugins().length === 0) { |
| return [] |
| } |
| // Only load commands from enabled plugins |
| const { enabled, errors } = await loadAllPluginsCacheOnly() |
| |
| if (errors.length > 0) { |
| logForDebugging( |
| `Plugin loading errors: ${errors.map(e => getPluginErrorMessage(e)).join(', ')}`, |
| ) |
| } |
| |
| // Process plugins in parallel; each plugin has its own loadedPaths scope |
| const perPluginCommands = await Promise.all( |
| enabled.map(async (plugin): Promise<Command[]> => { |
| // Track loaded file paths to prevent duplicates within this plugin |
| const loadedPaths = new Set<string>() |
| const pluginCommands: Command[] = [] |
| |
| // Load commands from default commands directory |
| if (plugin.commandsPath) { |
| try { |
| const commands = await loadCommandsFromDirectory( |
| plugin.commandsPath, |
| plugin.name, |
| plugin.source, |
| plugin.manifest, |
| plugin.path, |
| { isSkillMode: false }, |
| loadedPaths, |
| ) |
| pluginCommands.push(...commands) |
| |
| if (commands.length > 0) { |
| logForDebugging( |
| `Loaded ${commands.length} commands from plugin ${plugin.name} default directory`, |
| ) |
| } |
| } catch (error) { |
| logForDebugging( |
| `Failed to load commands from plugin ${plugin.name} default directory: ${error}`, |
| { level: 'error' }, |
| ) |
| } |
| } |
| |
| // Load commands from additional paths specified in manifest |
| if (plugin.commandsPaths) { |
| logForDebugging( |
| `Plugin ${plugin.name} has commandsPaths: ${plugin.commandsPaths.join(', ')}`, |
| ) |
| // Process all commandsPaths in parallel. isDuplicatePath is synchronous |
| // (check-and-add), so concurrent access to loadedPaths is safe. |
| const pathResults = await Promise.all( |
| plugin.commandsPaths.map(async (commandPath): Promise<Command[]> => { |
| try { |
| const fs = getFsImplementation() |
| const stats = await fs.stat(commandPath) |
| logForDebugging( |
| `Checking commandPath ${commandPath} - isDirectory: ${stats.isDirectory()}, isFile: ${stats.isFile()}`, |
| ) |
| |
| if (stats.isDirectory()) { |
| // Load all .md files and skill directories from directory |
| const commands = await loadCommandsFromDirectory( |
| commandPath, |
| plugin.name, |
| plugin.source, |
| plugin.manifest, |
| plugin.path, |
| { isSkillMode: false }, |
| loadedPaths, |
| ) |
| |
| if (commands.length > 0) { |
| logForDebugging( |
| `Loaded ${commands.length} commands from plugin ${plugin.name} custom path: ${commandPath}`, |
| ) |
| } else { |
| logForDebugging( |
| `Warning: No commands found in plugin ${plugin.name} custom directory: ${commandPath}. Expected .md files or SKILL.md in subdirectories.`, |
| { level: 'warn' }, |
| ) |
| } |
| return commands |
| } else if (stats.isFile() && commandPath.endsWith('.md')) { |
| if (isDuplicatePath(fs, commandPath, loadedPaths)) { |
| return [] |
| } |
| |
| // Load single command file |
| const content = await fs.readFile(commandPath, { |
| encoding: 'utf-8', |
| }) |
| const { frontmatter, content: markdownContent } = |
| parseFrontmatter(content, commandPath) |
| |
| // Check if there's metadata for this command (object-mapping format) |
| let commandName: string | undefined |
| let metadataOverride: CommandMetadata | undefined |
| |
| if (plugin.commandsMetadata) { |
| // Find metadata by matching the command's absolute path to the metadata source |
| // Convert metadata.source (relative to plugin root) to absolute path for comparison |
| for (const [name, metadata] of Object.entries( |
| plugin.commandsMetadata, |
| )) { |
| if (metadata.source) { |
| const fullMetadataPath = join( |
| plugin.path, |
| metadata.source, |
| ) |
| if (commandPath === fullMetadataPath) { |
| commandName = `${plugin.name}:${name}` |
| metadataOverride = metadata |
| break |
| } |
| } |
| } |
| } |
| |
| // Fall back to filename-based naming if no metadata |
| if (!commandName) { |
| commandName = `${plugin.name}:${basename(commandPath).replace(/\.md$/, '')}` |
| } |
| |
| // Apply metadata overrides to frontmatter |
| const finalFrontmatter = metadataOverride |
| ? { |
| ...frontmatter, |
| ...(metadataOverride.description && { |
| description: metadataOverride.description, |
| }), |
| ...(metadataOverride.argumentHint && { |
| 'argument-hint': metadataOverride.argumentHint, |
| }), |
| ...(metadataOverride.model && { |
| model: metadataOverride.model, |
| }), |
| ...(metadataOverride.allowedTools && { |
| 'allowed-tools': |
| metadataOverride.allowedTools.join(','), |
| }), |
| } |
| : frontmatter |
| |
| const file: PluginMarkdownFile = { |
| filePath: commandPath, |
| baseDir: dirname(commandPath), |
| frontmatter: finalFrontmatter, |
| content: markdownContent, |
| } |
| |
| const command = createPluginCommand( |
| commandName, |
| file, |
| plugin.source, |
| plugin.manifest, |
| plugin.path, |
| false, |
| ) |
| |
| if (command) { |
| logForDebugging( |
| `Loaded command from plugin ${plugin.name} custom file: ${commandPath}${metadataOverride ? ' (with metadata override)' : ''}`, |
| ) |
| return [command] |
| } |
| } |
| return [] |
| } catch (error) { |
| logForDebugging( |
| `Failed to load commands from plugin ${plugin.name} custom path ${commandPath}: ${error}`, |
| { level: 'error' }, |
| ) |
| return [] |
| } |
| }), |
| ) |
| for (const commands of pathResults) { |
| pluginCommands.push(...commands) |
| } |
| } |
| |
| // Load commands with inline content (no source file) |
| // Note: Commands with source files were already loaded in the previous loop |
| // when iterating through commandsPaths. This loop handles metadata entries |
| // that specify inline content instead of file references. |
| if (plugin.commandsMetadata) { |
| for (const [name, metadata] of Object.entries( |
| plugin.commandsMetadata, |
| )) { |
| // Only process entries with inline content (no source) |
| if (metadata.content && !metadata.source) { |
| try { |
| // Parse inline content for frontmatter |
| const { frontmatter, content: markdownContent } = |
| parseFrontmatter( |
| metadata.content, |
| `<inline:${plugin.name}:${name}>`, |
| ) |
| |
| // Apply metadata overrides to frontmatter |
| const finalFrontmatter: FrontmatterData = { |
| ...frontmatter, |
| ...(metadata.description && { |
| description: metadata.description, |
| }), |
| ...(metadata.argumentHint && { |
| 'argument-hint': metadata.argumentHint, |
| }), |
| ...(metadata.model && { |
| model: metadata.model, |
| }), |
| ...(metadata.allowedTools && { |
| 'allowed-tools': metadata.allowedTools.join(','), |
| }), |
| } |
| |
| const commandName = `${plugin.name}:${name}` |
| const file: PluginMarkdownFile = { |
| filePath: `<inline:${commandName}>`, // Virtual path for inline content |
| baseDir: plugin.path, // Use plugin root as base directory |
| frontmatter: finalFrontmatter, |
| content: markdownContent, |
| } |
| |
| const command = createPluginCommand( |
| commandName, |
| file, |
| plugin.source, |
| plugin.manifest, |
| plugin.path, |
| false, |
| ) |
| |
| if (command) { |
| pluginCommands.push(command) |
| logForDebugging( |
| `Loaded inline content command from plugin ${plugin.name}: ${commandName}`, |
| ) |
| } |
| } catch (error) { |
| logForDebugging( |
| `Failed to load inline content command ${name} from plugin ${plugin.name}: ${error}`, |
| { level: 'error' }, |
| ) |
| } |
| } |
| } |
| } |
| return pluginCommands |
| }), |
| ) |
| |
| const allCommands = perPluginCommands.flat() |
| logForDebugging(`Total plugin commands loaded: ${allCommands.length}`) |
| return allCommands |
| }) |
| |
| export function clearPluginCommandCache(): void { |
| getPluginCommands.cache?.clear?.() |
| } |
| |
| /** |
| * Loads skills from plugin skills directories |
| * Skills are directories containing SKILL.md files |
| */ |
| async function loadSkillsFromDirectory( |
| skillsPath: string, |
| pluginName: string, |
| sourceName: string, |
| pluginManifest: PluginManifest, |
| pluginPath: string, |
| loadedPaths: Set<string>, |
| ): Promise<Command[]> { |
| const fs = getFsImplementation() |
| const skills: Command[] = [] |
| |
| // First, check if skillsPath itself contains SKILL.md (direct skill directory) |
| const directSkillPath = join(skillsPath, 'SKILL.md') |
| let directSkillContent: string | null = null |
| try { |
| directSkillContent = await fs.readFile(directSkillPath, { |
| encoding: 'utf-8', |
| }) |
| } catch (e: unknown) { |
| if (!isENOENT(e)) { |
| logForDebugging(`Failed to load skill from ${directSkillPath}: ${e}`, { |
| level: 'error', |
| }) |
| return skills |
| } |
| // ENOENT: no direct SKILL.md, fall through to scan subdirectories |
| } |
| |
| if (directSkillContent !== null) { |
| // This is a direct skill directory, load the skill from here |
| if (isDuplicatePath(fs, directSkillPath, loadedPaths)) { |
| return skills |
| } |
| try { |
| const { frontmatter, content: markdownContent } = parseFrontmatter( |
| directSkillContent, |
| directSkillPath, |
| ) |
| |
| const skillName = `${pluginName}:${basename(skillsPath)}` |
| |
| const file: PluginMarkdownFile = { |
| filePath: directSkillPath, |
| baseDir: dirname(directSkillPath), |
| frontmatter, |
| content: markdownContent, |
| } |
| |
| const skill = createPluginCommand( |
| skillName, |
| file, |
| sourceName, |
| pluginManifest, |
| pluginPath, |
| true, // isSkill |
| { isSkillMode: true }, // config |
| ) |
| |
| if (skill) { |
| skills.push(skill) |
| } |
| } catch (error) { |
| logForDebugging( |
| `Failed to load skill from ${directSkillPath}: ${error}`, |
| { |
| level: 'error', |
| }, |
| ) |
| } |
| return skills |
| } |
| |
| // Otherwise, scan for subdirectories containing SKILL.md files |
| let entries |
| try { |
| entries = await fs.readdir(skillsPath) |
| } catch (e: unknown) { |
| if (!isENOENT(e)) { |
| logForDebugging( |
| `Failed to load skills from directory ${skillsPath}: ${e}`, |
| { level: 'error' }, |
| ) |
| } |
| return skills |
| } |
| |
| await Promise.all( |
| entries.map(async entry => { |
| // Accept both directories and symlinks (symlinks may point to skill directories) |
| if (!entry.isDirectory() && !entry.isSymbolicLink()) { |
| return |
| } |
| |
| const skillDirPath = join(skillsPath, entry.name) |
| const skillFilePath = join(skillDirPath, 'SKILL.md') |
| |
| // Try to read SKILL.md directly; skip if it doesn't exist |
| let content: string |
| try { |
| content = await fs.readFile(skillFilePath, { encoding: 'utf-8' }) |
| } catch (e: unknown) { |
| if (!isENOENT(e)) { |
| logForDebugging(`Failed to load skill from ${skillFilePath}: ${e}`, { |
| level: 'error', |
| }) |
| } |
| return |
| } |
| |
| if (isDuplicatePath(fs, skillFilePath, loadedPaths)) { |
| return |
| } |
| |
| try { |
| const { frontmatter, content: markdownContent } = parseFrontmatter( |
| content, |
| skillFilePath, |
| ) |
| |
| const skillName = `${pluginName}:${entry.name}` |
| |
| const file: PluginMarkdownFile = { |
| filePath: skillFilePath, |
| baseDir: dirname(skillFilePath), |
| frontmatter, |
| content: markdownContent, |
| } |
| |
| const skill = createPluginCommand( |
| skillName, |
| file, |
| sourceName, |
| pluginManifest, |
| pluginPath, |
| true, // isSkill |
| { isSkillMode: true }, // config |
| ) |
| |
| if (skill) { |
| skills.push(skill) |
| } |
| } catch (error) { |
| logForDebugging( |
| `Failed to load skill from ${skillFilePath}: ${error}`, |
| { level: 'error' }, |
| ) |
| } |
| }), |
| ) |
| |
| return skills |
| } |
| |
| export const getPluginSkills = memoize(async (): Promise<Command[]> => { |
| // --bare: same gate as getPluginCommands above — honor explicit |
| // --plugin-dir, skip marketplace auto-load. |
| if (isBareMode() && getInlinePlugins().length === 0) { |
| return [] |
| } |
| // Only load skills from enabled plugins |
| const { enabled, errors } = await loadAllPluginsCacheOnly() |
| |
| if (errors.length > 0) { |
| logForDebugging( |
| `Plugin loading errors: ${errors.map(e => getPluginErrorMessage(e)).join(', ')}`, |
| ) |
| } |
| |
| logForDebugging( |
| `getPluginSkills: Processing ${enabled.length} enabled plugins`, |
| ) |
| |
| // Process plugins in parallel; each plugin has its own loadedPaths scope |
| const perPluginSkills = await Promise.all( |
| enabled.map(async (plugin): Promise<Command[]> => { |
| // Track loaded file paths to prevent duplicates within this plugin |
| const loadedPaths = new Set<string>() |
| const pluginSkills: Command[] = [] |
| |
| logForDebugging( |
| `Checking plugin ${plugin.name}: skillsPath=${plugin.skillsPath ? 'exists' : 'none'}, skillsPaths=${plugin.skillsPaths ? plugin.skillsPaths.length : 0} paths`, |
| ) |
| // Load skills from default skills directory |
| if (plugin.skillsPath) { |
| logForDebugging( |
| `Attempting to load skills from plugin ${plugin.name} default skillsPath: ${plugin.skillsPath}`, |
| ) |
| try { |
| const skills = await loadSkillsFromDirectory( |
| plugin.skillsPath, |
| plugin.name, |
| plugin.source, |
| plugin.manifest, |
| plugin.path, |
| loadedPaths, |
| ) |
| pluginSkills.push(...skills) |
| |
| logForDebugging( |
| `Loaded ${skills.length} skills from plugin ${plugin.name} default directory`, |
| ) |
| } catch (error) { |
| logForDebugging( |
| `Failed to load skills from plugin ${plugin.name} default directory: ${error}`, |
| { level: 'error' }, |
| ) |
| } |
| } |
| |
| // Load skills from additional paths specified in manifest |
| if (plugin.skillsPaths) { |
| logForDebugging( |
| `Attempting to load skills from plugin ${plugin.name} skillsPaths: ${plugin.skillsPaths.join(', ')}`, |
| ) |
| // Process all skillsPaths in parallel. isDuplicatePath is synchronous |
| // (check-and-add), so concurrent access to loadedPaths is safe. |
| const pathResults = await Promise.all( |
| plugin.skillsPaths.map(async (skillPath): Promise<Command[]> => { |
| try { |
| logForDebugging( |
| `Loading from skillPath: ${skillPath} for plugin ${plugin.name}`, |
| ) |
| const skills = await loadSkillsFromDirectory( |
| skillPath, |
| plugin.name, |
| plugin.source, |
| plugin.manifest, |
| plugin.path, |
| loadedPaths, |
| ) |
| |
| logForDebugging( |
| `Loaded ${skills.length} skills from plugin ${plugin.name} custom path: ${skillPath}`, |
| ) |
| return skills |
| } catch (error) { |
| logForDebugging( |
| `Failed to load skills from plugin ${plugin.name} custom path ${skillPath}: ${error}`, |
| { level: 'error' }, |
| ) |
| return [] |
| } |
| }), |
| ) |
| for (const skills of pathResults) { |
| pluginSkills.push(...skills) |
| } |
| } |
| return pluginSkills |
| }), |
| ) |
| |
| const allSkills = perPluginSkills.flat() |
| logForDebugging(`Total plugin skills loaded: ${allSkills.length}`) |
| return allSkills |
| }) |
| |
| export function clearPluginSkillsCache(): void { |
| getPluginSkills.cache?.clear?.() |
| } |
| |