source dump of claude code
at main 946 lines 30 kB view raw
1import memoize from 'lodash-es/memoize.js' 2import { basename, dirname, join } from 'path' 3import { getInlinePlugins, getSessionId } from '../../bootstrap/state.js' 4import type { Command } from '../../types/command.js' 5import { getPluginErrorMessage } from '../../types/plugin.js' 6import { 7 parseArgumentNames, 8 substituteArguments, 9} from '../argumentSubstitution.js' 10import { logForDebugging } from '../debug.js' 11import { EFFORT_LEVELS, parseEffortValue } from '../effort.js' 12import { isBareMode } from '../envUtils.js' 13import { isENOENT } from '../errors.js' 14import { 15 coerceDescriptionToString, 16 type FrontmatterData, 17 parseBooleanFrontmatter, 18 parseFrontmatter, 19 parseShellFrontmatter, 20} from '../frontmatterParser.js' 21import { getFsImplementation, isDuplicatePath } from '../fsOperations.js' 22import { 23 extractDescriptionFromMarkdown, 24 parseSlashCommandToolsFromFrontmatter, 25} from '../markdownConfigLoader.js' 26import { parseUserSpecifiedModel } from '../model/model.js' 27import { executeShellCommandsInPrompt } from '../promptShellExecution.js' 28import { loadAllPluginsCacheOnly } from './pluginLoader.js' 29import { 30 loadPluginOptions, 31 substitutePluginVariables, 32 substituteUserConfigInContent, 33} from './pluginOptionsStorage.js' 34import type { CommandMetadata, PluginManifest } from './schemas.js' 35import { walkPluginMarkdown } from './walkPluginMarkdown.js' 36 37// Similar to MarkdownFile but for plugin sources 38type PluginMarkdownFile = { 39 filePath: string 40 baseDir: string 41 frontmatter: FrontmatterData 42 content: string 43} 44 45// Configuration for loading commands or skills 46type 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 */ 53function 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 */ 60function 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 */ 102async 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 */ 135function 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 169async 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 */ 218function 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 414export 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 679export 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 */ 687async 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 840export 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 944export function clearPluginSkillsCache(): void { 945 getPluginSkills.cache?.clear?.() 946}