source dump of claude code
at main 3302 lines 110 kB view raw
1/** 2 * Plugin Loader Module 3 * 4 * This module is responsible for discovering, loading, and validating Claude Code plugins 5 * from various sources including marketplaces and git repositories. 6 * 7 * NPM packages are also supported but must be referenced through marketplaces - the marketplace 8 * entry contains the NPM package information. 9 * 10 * Plugin Discovery Sources (in order of precedence): 11 * 1. Marketplace-based plugins (plugin@marketplace format in settings) 12 * 2. Session-only plugins (from --plugin-dir CLI flag or SDK plugins option) 13 * 14 * Plugin Directory Structure: 15 * ``` 16 * my-plugin/ 17 * ├── plugin.json # Optional manifest with metadata 18 * ├── commands/ # Custom slash commands 19 * │ ├── build.md 20 * │ └── deploy.md 21 * ├── agents/ # Custom AI agents 22 * │ └── test-runner.md 23 * └── hooks/ # Hook configurations 24 * └── hooks.json # Hook definitions 25 * ``` 26 * 27 * The loader handles: 28 * - Plugin manifest validation 29 * - Hooks configuration loading and variable resolution 30 * - Duplicate name detection 31 * - Enable/disable state management 32 * - Error collection and reporting 33 */ 34 35import { 36 copyFile, 37 readdir, 38 readFile, 39 readlink, 40 realpath, 41 rename, 42 rm, 43 rmdir, 44 stat, 45 symlink, 46} from 'fs/promises' 47import memoize from 'lodash-es/memoize.js' 48import { basename, dirname, join, relative, resolve, sep } from 'path' 49import { getInlinePlugins } from '../../bootstrap/state.js' 50import { 51 BUILTIN_MARKETPLACE_NAME, 52 getBuiltinPlugins, 53} from '../../plugins/builtinPlugins.js' 54import type { 55 LoadedPlugin, 56 PluginComponent, 57 PluginError, 58 PluginLoadResult, 59 PluginManifest, 60} from '../../types/plugin.js' 61import { logForDebugging } from '../debug.js' 62import { isEnvTruthy } from '../envUtils.js' 63import { 64 errorMessage, 65 getErrnoPath, 66 isENOENT, 67 isFsInaccessible, 68 toError, 69} from '../errors.js' 70import { execFileNoThrow, execFileNoThrowWithCwd } from '../execFileNoThrow.js' 71import { pathExists } from '../file.js' 72import { getFsImplementation } from '../fsOperations.js' 73import { gitExe } from '../git.js' 74import { lazySchema } from '../lazySchema.js' 75import { logError } from '../log.js' 76import { getSettings_DEPRECATED } from '../settings/settings.js' 77import { 78 clearPluginSettingsBase, 79 getPluginSettingsBase, 80 resetSettingsCache, 81 setPluginSettingsBase, 82} from '../settings/settingsCache.js' 83import type { HooksSettings } from '../settings/types.js' 84import { SettingsSchema } from '../settings/types.js' 85import { jsonParse, jsonStringify } from '../slowOperations.js' 86import { getAddDirEnabledPlugins } from './addDirPluginSettings.js' 87import { verifyAndDemote } from './dependencyResolver.js' 88import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js' 89import { checkGitAvailable } from './gitAvailability.js' 90import { getInMemoryInstalledPlugins } from './installedPluginsManager.js' 91import { getManagedPluginNames } from './managedPlugins.js' 92import { 93 formatSourceForDisplay, 94 getBlockedMarketplaces, 95 getStrictKnownMarketplaces, 96 isSourceAllowedByPolicy, 97 isSourceInBlocklist, 98} from './marketplaceHelpers.js' 99import { 100 getMarketplaceCacheOnly, 101 getPluginByIdCacheOnly, 102 loadKnownMarketplacesConfigSafe, 103} from './marketplaceManager.js' 104import { getPluginSeedDirs, getPluginsDirectory } from './pluginDirectories.js' 105import { parsePluginIdentifier } from './pluginIdentifier.js' 106import { validatePathWithinBase } from './pluginInstallationHelpers.js' 107import { calculatePluginVersion } from './pluginVersioning.js' 108import { 109 type CommandMetadata, 110 PluginHooksSchema, 111 PluginIdSchema, 112 PluginManifestSchema, 113 type PluginMarketplaceEntry, 114 type PluginSource, 115} from './schemas.js' 116import { 117 convertDirectoryToZipInPlace, 118 extractZipToDirectory, 119 getSessionPluginCachePath, 120 isPluginZipCacheEnabled, 121} from './zipCache.js' 122 123/** 124 * Get the path where plugin cache is stored 125 */ 126export function getPluginCachePath(): string { 127 return join(getPluginsDirectory(), 'cache') 128} 129 130/** 131 * Compute the versioned cache path under a specific base plugins directory. 132 * Used to probe both primary and seed caches. 133 * 134 * @param baseDir - Base plugins directory (e.g. getPluginsDirectory() or seed dir) 135 * @param pluginId - Plugin identifier in format "name@marketplace" 136 * @param version - Version string (semver, git SHA, etc.) 137 * @returns Absolute path to versioned plugin directory under baseDir 138 */ 139export function getVersionedCachePathIn( 140 baseDir: string, 141 pluginId: string, 142 version: string, 143): string { 144 const { name: pluginName, marketplace } = parsePluginIdentifier(pluginId) 145 const sanitizedMarketplace = (marketplace || 'unknown').replace( 146 /[^a-zA-Z0-9\-_]/g, 147 '-', 148 ) 149 const sanitizedPlugin = (pluginName || pluginId).replace( 150 /[^a-zA-Z0-9\-_]/g, 151 '-', 152 ) 153 // Sanitize version to prevent path traversal attacks 154 const sanitizedVersion = version.replace(/[^a-zA-Z0-9\-_.]/g, '-') 155 return join( 156 baseDir, 157 'cache', 158 sanitizedMarketplace, 159 sanitizedPlugin, 160 sanitizedVersion, 161 ) 162} 163 164/** 165 * Get versioned cache path for a plugin under the primary plugins directory. 166 * Format: ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/ 167 * 168 * @param pluginId - Plugin identifier in format "name@marketplace" 169 * @param version - Version string (semver, git SHA, etc.) 170 * @returns Absolute path to versioned plugin directory 171 */ 172export function getVersionedCachePath( 173 pluginId: string, 174 version: string, 175): string { 176 return getVersionedCachePathIn(getPluginsDirectory(), pluginId, version) 177} 178 179/** 180 * Get versioned ZIP cache path for a plugin. 181 * This is the zip cache variant of getVersionedCachePath. 182 */ 183export function getVersionedZipCachePath( 184 pluginId: string, 185 version: string, 186): string { 187 return `${getVersionedCachePath(pluginId, version)}.zip` 188} 189 190/** 191 * Probe seed directories for a populated cache at this plugin version. 192 * Seeds are checked in precedence order; first hit wins. Returns null if no 193 * seed is configured or none contains a populated directory at this version. 194 */ 195async function probeSeedCache( 196 pluginId: string, 197 version: string, 198): Promise<string | null> { 199 for (const seedDir of getPluginSeedDirs()) { 200 const seedPath = getVersionedCachePathIn(seedDir, pluginId, version) 201 try { 202 const entries = await readdir(seedPath) 203 if (entries.length > 0) return seedPath 204 } catch { 205 // Try next seed 206 } 207 } 208 return null 209} 210 211/** 212 * When the computed version is 'unknown', probe seed/cache/<m>/<p>/ for an 213 * actual version dir. Handles the first-boot chicken-and-egg where the 214 * version can only be known after cloning, but seed already has the clone. 215 * 216 * Per seed, only matches when exactly one version exists (typical BYOC case). 217 * Multiple versions within a single seed → ambiguous → try next seed. 218 * Seeds are checked in precedence order; first match wins. 219 */ 220export async function probeSeedCacheAnyVersion( 221 pluginId: string, 222): Promise<string | null> { 223 for (const seedDir of getPluginSeedDirs()) { 224 // The parent of the version dir — computed the same way as 225 // getVersionedCachePathIn, just without the version component. 226 const pluginDir = dirname(getVersionedCachePathIn(seedDir, pluginId, '_')) 227 try { 228 const versions = await readdir(pluginDir) 229 if (versions.length !== 1) continue 230 const versionDir = join(pluginDir, versions[0]!) 231 const entries = await readdir(versionDir) 232 if (entries.length > 0) return versionDir 233 } catch { 234 // Try next seed 235 } 236 } 237 return null 238} 239 240/** 241 * Get legacy (non-versioned) cache path for a plugin. 242 * Format: ~/.claude/plugins/cache/{plugin-name}/ 243 * 244 * Used for backward compatibility with existing installations. 245 * 246 * @param pluginName - Plugin name (without marketplace suffix) 247 * @returns Absolute path to legacy plugin directory 248 */ 249export function getLegacyCachePath(pluginName: string): string { 250 const cachePath = getPluginCachePath() 251 return join(cachePath, pluginName.replace(/[^a-zA-Z0-9\-_]/g, '-')) 252} 253 254/** 255 * Resolve plugin path with fallback to legacy location. 256 * 257 * Always: 258 * 1. Try versioned path first if version is provided 259 * 2. Fall back to legacy path for existing installations 260 * 3. Return versioned path for new installations 261 * 262 * @param pluginId - Plugin identifier in format "name@marketplace" 263 * @param version - Optional version string 264 * @returns Absolute path to plugin directory 265 */ 266export async function resolvePluginPath( 267 pluginId: string, 268 version?: string, 269): Promise<string> { 270 // Try versioned path first 271 if (version) { 272 const versionedPath = getVersionedCachePath(pluginId, version) 273 if (await pathExists(versionedPath)) { 274 return versionedPath 275 } 276 } 277 278 // Fall back to legacy path for existing installations 279 const pluginName = parsePluginIdentifier(pluginId).name || pluginId 280 const legacyPath = getLegacyCachePath(pluginName) 281 if (await pathExists(legacyPath)) { 282 return legacyPath 283 } 284 285 // Return versioned path for new installations 286 return version ? getVersionedCachePath(pluginId, version) : legacyPath 287} 288 289/** 290 * Recursively copy a directory. 291 * Exported for testing purposes. 292 */ 293export async function copyDir(src: string, dest: string): Promise<void> { 294 await getFsImplementation().mkdir(dest) 295 296 const entries = await readdir(src, { withFileTypes: true }) 297 298 for (const entry of entries) { 299 const srcPath = join(src, entry.name) 300 const destPath = join(dest, entry.name) 301 302 if (entry.isDirectory()) { 303 await copyDir(srcPath, destPath) 304 } else if (entry.isFile()) { 305 await copyFile(srcPath, destPath) 306 } else if (entry.isSymbolicLink()) { 307 const linkTarget = await readlink(srcPath) 308 309 // Resolve the symlink to get the actual target path 310 // This prevents circular symlinks when src and dest overlap (e.g., via symlink chains) 311 let resolvedTarget: string 312 try { 313 resolvedTarget = await realpath(srcPath) 314 } catch { 315 // Broken symlink - copy the raw link target as-is 316 await symlink(linkTarget, destPath) 317 continue 318 } 319 320 // Resolve the source directory to handle symlinked source dirs 321 let resolvedSrc: string 322 try { 323 resolvedSrc = await realpath(src) 324 } catch { 325 resolvedSrc = src 326 } 327 328 // Check if target is within the source tree (using proper path prefix matching) 329 const srcPrefix = resolvedSrc.endsWith(sep) 330 ? resolvedSrc 331 : resolvedSrc + sep 332 if ( 333 resolvedTarget.startsWith(srcPrefix) || 334 resolvedTarget === resolvedSrc 335 ) { 336 // Target is within source tree - create relative symlink that preserves 337 // the same structure in the destination 338 const targetRelativeToSrc = relative(resolvedSrc, resolvedTarget) 339 const destTargetPath = join(dest, targetRelativeToSrc) 340 const relativeLinkPath = relative(dirname(destPath), destTargetPath) 341 await symlink(relativeLinkPath, destPath) 342 } else { 343 // Target is outside source tree - use absolute resolved path 344 await symlink(resolvedTarget, destPath) 345 } 346 } 347 } 348} 349 350/** 351 * Copy plugin files to versioned cache directory. 352 * 353 * For local plugins: Uses entry.source from marketplace.json as the single source of truth. 354 * For remote plugins: Falls back to copying sourcePath (the downloaded content). 355 * 356 * @param sourcePath - Path to the plugin source (used as fallback for remote plugins) 357 * @param pluginId - Plugin identifier in format "name@marketplace" 358 * @param version - Version string for versioned path 359 * @param entry - Optional marketplace entry containing the source field 360 * @param marketplaceDir - Marketplace directory for resolving entry.source (undefined for remote plugins) 361 * @returns Path to the cached plugin directory 362 * @throws Error if the source directory is not found 363 * @throws Error if the destination directory is empty after copy 364 */ 365export async function copyPluginToVersionedCache( 366 sourcePath: string, 367 pluginId: string, 368 version: string, 369 entry?: PluginMarketplaceEntry, 370 marketplaceDir?: string, 371): Promise<string> { 372 // When zip cache is enabled, the canonical format is a ZIP file 373 const zipCacheMode = isPluginZipCacheEnabled() 374 const cachePath = getVersionedCachePath(pluginId, version) 375 const zipPath = getVersionedZipCachePath(pluginId, version) 376 377 // If cache already exists (directory or ZIP), return it 378 if (zipCacheMode) { 379 if (await pathExists(zipPath)) { 380 logForDebugging( 381 `Plugin ${pluginId} version ${version} already cached at ${zipPath}`, 382 ) 383 return zipPath 384 } 385 } else if (await pathExists(cachePath)) { 386 const entries = await readdir(cachePath) 387 if (entries.length > 0) { 388 logForDebugging( 389 `Plugin ${pluginId} version ${version} already cached at ${cachePath}`, 390 ) 391 return cachePath 392 } 393 // Directory exists but is empty, remove it so we can recreate with content 394 logForDebugging( 395 `Removing empty cache directory for ${pluginId} at ${cachePath}`, 396 ) 397 await rmdir(cachePath) 398 } 399 400 // Seed cache hit — return seed path in place (read-only, no copy). 401 // Callers handle both directory and .zip paths; this returns a directory. 402 const seedPath = await probeSeedCache(pluginId, version) 403 if (seedPath) { 404 logForDebugging( 405 `Using seed cache for ${pluginId}@${version} at ${seedPath}`, 406 ) 407 return seedPath 408 } 409 410 // Create parent directories 411 await getFsImplementation().mkdir(dirname(cachePath)) 412 413 // For local plugins: copy entry.source directory (the single source of truth) 414 // For remote plugins: marketplaceDir is undefined, fall back to copying sourcePath 415 if (entry && typeof entry.source === 'string' && marketplaceDir) { 416 const sourceDir = validatePathWithinBase(marketplaceDir, entry.source) 417 418 logForDebugging( 419 `Copying source directory ${entry.source} for plugin ${pluginId}`, 420 ) 421 try { 422 await copyDir(sourceDir, cachePath) 423 } catch (e: unknown) { 424 // Only remap ENOENT from the top-level sourceDir itself — nested ENOENTs 425 // from recursive copyDir (broken symlinks, raced deletes) should preserve 426 // their original path in the error. 427 if (isENOENT(e) && getErrnoPath(e) === sourceDir) { 428 throw new Error( 429 `Plugin source directory not found: ${sourceDir} (from entry.source: ${entry.source})`, 430 ) 431 } 432 throw e 433 } 434 } else { 435 // Fallback for remote plugins (already downloaded) or plugins without entry.source 436 logForDebugging( 437 `Copying plugin ${pluginId} to versioned cache (fallback to full copy)`, 438 ) 439 await copyDir(sourcePath, cachePath) 440 } 441 442 // Remove .git directory from cache if present 443 const gitPath = join(cachePath, '.git') 444 await rm(gitPath, { recursive: true, force: true }) 445 446 // Validate that cache has content - if empty, throw so fallback can be used 447 const cacheEntries = await readdir(cachePath) 448 if (cacheEntries.length === 0) { 449 throw new Error( 450 `Failed to copy plugin ${pluginId} to versioned cache: destination is empty after copy`, 451 ) 452 } 453 454 // Zip cache mode: convert directory to ZIP and remove the directory 455 if (zipCacheMode) { 456 await convertDirectoryToZipInPlace(cachePath, zipPath) 457 logForDebugging( 458 `Successfully cached plugin ${pluginId} as ZIP at ${zipPath}`, 459 ) 460 return zipPath 461 } 462 463 logForDebugging(`Successfully cached plugin ${pluginId} at ${cachePath}`) 464 return cachePath 465} 466 467/** 468 * Validate a git URL using Node.js URL parsing 469 */ 470function validateGitUrl(url: string): string { 471 try { 472 const parsed = new URL(url) 473 if (!['https:', 'http:', 'file:'].includes(parsed.protocol)) { 474 if (!/^git@[a-zA-Z0-9.-]+:/.test(url)) { 475 throw new Error( 476 `Invalid git URL protocol: ${parsed.protocol}. Only HTTPS, HTTP, file:// and SSH (git@) URLs are supported.`, 477 ) 478 } 479 } 480 return url 481 } catch { 482 if (/^git@[a-zA-Z0-9.-]+:/.test(url)) { 483 return url 484 } 485 throw new Error(`Invalid git URL: ${url}`) 486 } 487} 488 489/** 490 * Install a plugin from npm using a global cache (exported for testing) 491 */ 492export async function installFromNpm( 493 packageName: string, 494 targetPath: string, 495 options: { registry?: string; version?: string } = {}, 496): Promise<void> { 497 const npmCachePath = join(getPluginsDirectory(), 'npm-cache') 498 499 await getFsImplementation().mkdir(npmCachePath) 500 501 const packageSpec = options.version 502 ? `${packageName}@${options.version}` 503 : packageName 504 const packagePath = join(npmCachePath, 'node_modules', packageName) 505 const needsInstall = !(await pathExists(packagePath)) 506 507 if (needsInstall) { 508 logForDebugging(`Installing npm package ${packageSpec} to cache`) 509 const args = ['install', packageSpec, '--prefix', npmCachePath] 510 if (options.registry) { 511 args.push('--registry', options.registry) 512 } 513 const result = await execFileNoThrow('npm', args, { useCwd: false }) 514 515 if (result.code !== 0) { 516 throw new Error(`Failed to install npm package: ${result.stderr}`) 517 } 518 } 519 520 await copyDir(packagePath, targetPath) 521 logForDebugging( 522 `Copied npm package ${packageName} from cache to ${targetPath}`, 523 ) 524} 525 526/** 527 * Clone a git repository (exported for testing) 528 * 529 * @param gitUrl - The git URL to clone 530 * @param targetPath - Where to clone the repository 531 * @param ref - Optional branch or tag to checkout 532 * @param sha - Optional specific commit SHA to checkout 533 */ 534export async function gitClone( 535 gitUrl: string, 536 targetPath: string, 537 ref?: string, 538 sha?: string, 539): Promise<void> { 540 // Use --recurse-submodules to initialize submodules 541 // Always start with shallow clone for efficiency 542 const args = [ 543 'clone', 544 '--depth', 545 '1', 546 '--recurse-submodules', 547 '--shallow-submodules', 548 ] 549 550 // Add --branch flag for specific ref (works for both branches and tags) 551 if (ref) { 552 args.push('--branch', ref) 553 } 554 555 // If sha is specified, use --no-checkout since we'll checkout the SHA separately 556 if (sha) { 557 args.push('--no-checkout') 558 } 559 560 args.push(gitUrl, targetPath) 561 562 const cloneStarted = performance.now() 563 const cloneResult = await execFileNoThrow(gitExe(), args) 564 565 if (cloneResult.code !== 0) { 566 logPluginFetch( 567 'plugin_clone', 568 gitUrl, 569 'failure', 570 performance.now() - cloneStarted, 571 classifyFetchError(cloneResult.stderr), 572 ) 573 throw new Error(`Failed to clone repository: ${cloneResult.stderr}`) 574 } 575 576 // If sha is specified, fetch and checkout that specific commit 577 if (sha) { 578 // Try shallow fetch of the specific SHA first (most efficient) 579 const shallowFetchResult = await execFileNoThrowWithCwd( 580 gitExe(), 581 ['fetch', '--depth', '1', 'origin', sha], 582 { cwd: targetPath }, 583 ) 584 585 if (shallowFetchResult.code !== 0) { 586 // Some servers don't support fetching arbitrary SHAs 587 // Fall back to unshallow fetch to get full history 588 logForDebugging( 589 `Shallow fetch of SHA ${sha} failed, falling back to unshallow fetch`, 590 ) 591 const unshallowResult = await execFileNoThrowWithCwd( 592 gitExe(), 593 ['fetch', '--unshallow'], 594 { cwd: targetPath }, 595 ) 596 597 if (unshallowResult.code !== 0) { 598 logPluginFetch( 599 'plugin_clone', 600 gitUrl, 601 'failure', 602 performance.now() - cloneStarted, 603 classifyFetchError(unshallowResult.stderr), 604 ) 605 throw new Error( 606 `Failed to fetch commit ${sha}: ${unshallowResult.stderr}`, 607 ) 608 } 609 } 610 611 // Checkout the specific commit 612 const checkoutResult = await execFileNoThrowWithCwd( 613 gitExe(), 614 ['checkout', sha], 615 { cwd: targetPath }, 616 ) 617 618 if (checkoutResult.code !== 0) { 619 logPluginFetch( 620 'plugin_clone', 621 gitUrl, 622 'failure', 623 performance.now() - cloneStarted, 624 classifyFetchError(checkoutResult.stderr), 625 ) 626 throw new Error( 627 `Failed to checkout commit ${sha}: ${checkoutResult.stderr}`, 628 ) 629 } 630 } 631 632 // Fire success only after ALL network ops (clone + optional SHA fetch) 633 // complete — same telemetry-scope discipline as mcpb and marketplace_url. 634 logPluginFetch( 635 'plugin_clone', 636 gitUrl, 637 'success', 638 performance.now() - cloneStarted, 639 ) 640} 641 642/** 643 * Install a plugin from a git URL 644 */ 645async function installFromGit( 646 gitUrl: string, 647 targetPath: string, 648 ref?: string, 649 sha?: string, 650): Promise<void> { 651 const safeUrl = validateGitUrl(gitUrl) 652 await gitClone(safeUrl, targetPath, ref, sha) 653 const refMessage = ref ? ` (ref: ${ref})` : '' 654 logForDebugging( 655 `Cloned repository from ${safeUrl}${refMessage} to ${targetPath}`, 656 ) 657} 658 659/** 660 * Install a plugin from GitHub 661 */ 662async function installFromGitHub( 663 repo: string, 664 targetPath: string, 665 ref?: string, 666 sha?: string, 667): Promise<void> { 668 if (!/^[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_.]+$/.test(repo)) { 669 throw new Error( 670 `Invalid GitHub repository format: ${repo}. Expected format: owner/repo`, 671 ) 672 } 673 // Use HTTPS for CCR (no SSH keys), SSH for normal CLI 674 const gitUrl = isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) 675 ? `https://github.com/${repo}.git` 676 : `git@github.com:${repo}.git` 677 return installFromGit(gitUrl, targetPath, ref, sha) 678} 679 680/** 681 * Resolve a git-subdir `url` field to a clonable git URL. 682 * Accepts GitHub owner/repo shorthand (converted to ssh or https depending on 683 * CLAUDE_CODE_REMOTE) or any URL that passes validateGitUrl (https, http, 684 * file, git@ ssh). 685 */ 686function resolveGitSubdirUrl(url: string): string { 687 if (/^[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_.]+$/.test(url)) { 688 return isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) 689 ? `https://github.com/${url}.git` 690 : `git@github.com:${url}.git` 691 } 692 return validateGitUrl(url) 693} 694 695/** 696 * Install a plugin from a subdirectory of a git repository (exported for 697 * testing). 698 * 699 * Uses partial clone (--filter=tree:0) + sparse-checkout so only the tree 700 * objects along the path and the blobs under it are downloaded. For large 701 * monorepos this is dramatically cheaper than a full clone — the tree objects 702 * for a million-file repo can be hundreds of MB, all avoided here. 703 * 704 * Sequence: 705 * 1. clone --depth 1 --filter=tree:0 --no-checkout [--branch ref] 706 * 2. sparse-checkout set --cone -- <path> 707 * 3. If sha: fetch --depth 1 origin <sha> (fallback: --unshallow), then 708 * checkout <sha>. The partial-clone filter is stored in remote config so 709 * subsequent fetches respect it; --unshallow gets all commits but trees 710 * and blobs remain lazy. 711 * If no sha: checkout HEAD (points to ref if --branch was used). 712 * 4. Move <cloneDir>/<path> to targetPath and discard the clone. 713 * 714 * The clone is ephemeral — it goes into a sibling temp directory and is 715 * removed after the subdir is extracted. targetPath ends up containing only 716 * the plugin files with no .git directory. 717 */ 718export async function installFromGitSubdir( 719 url: string, 720 targetPath: string, 721 subdirPath: string, 722 ref?: string, 723 sha?: string, 724): Promise<string | undefined> { 725 if (!(await checkGitAvailable())) { 726 throw new Error( 727 'git-subdir plugin source requires git to be installed and on PATH. ' + 728 'Install git (version 2.25 or later for sparse-checkout cone mode) and try again.', 729 ) 730 } 731 732 const gitUrl = resolveGitSubdirUrl(url) 733 // Clone into a sibling temp dir (same filesystem → rename works, no EXDEV). 734 const cloneDir = `${targetPath}.clone` 735 736 const cloneArgs = [ 737 'clone', 738 '--depth', 739 '1', 740 '--filter=tree:0', 741 '--no-checkout', 742 ] 743 if (ref) { 744 cloneArgs.push('--branch', ref) 745 } 746 cloneArgs.push(gitUrl, cloneDir) 747 748 const cloneResult = await execFileNoThrow(gitExe(), cloneArgs) 749 if (cloneResult.code !== 0) { 750 throw new Error( 751 `Failed to clone repository for git-subdir source: ${cloneResult.stderr}`, 752 ) 753 } 754 755 try { 756 const sparseResult = await execFileNoThrowWithCwd( 757 gitExe(), 758 ['sparse-checkout', 'set', '--cone', '--', subdirPath], 759 { cwd: cloneDir }, 760 ) 761 if (sparseResult.code !== 0) { 762 throw new Error( 763 `git sparse-checkout set failed (git >= 2.25 required for cone mode): ${sparseResult.stderr}`, 764 ) 765 } 766 767 // Capture the resolved commit SHA before discarding the clone. The 768 // extracted subdir has no .git, so the caller can't rev-parse it later. 769 // If the source specified a full 40-char sha we already know it; otherwise 770 // read HEAD (which points to ref's tip after --branch, or the remote 771 // default branch if no ref was given). 772 let resolvedSha: string | undefined 773 774 if (sha) { 775 const fetchSha = await execFileNoThrowWithCwd( 776 gitExe(), 777 ['fetch', '--depth', '1', 'origin', sha], 778 { cwd: cloneDir }, 779 ) 780 if (fetchSha.code !== 0) { 781 logForDebugging( 782 `Shallow fetch of SHA ${sha} failed for git-subdir, falling back to unshallow fetch`, 783 ) 784 const unshallow = await execFileNoThrowWithCwd( 785 gitExe(), 786 ['fetch', '--unshallow'], 787 { cwd: cloneDir }, 788 ) 789 if (unshallow.code !== 0) { 790 throw new Error(`Failed to fetch commit ${sha}: ${unshallow.stderr}`) 791 } 792 } 793 const checkout = await execFileNoThrowWithCwd( 794 gitExe(), 795 ['checkout', sha], 796 { cwd: cloneDir }, 797 ) 798 if (checkout.code !== 0) { 799 throw new Error(`Failed to checkout commit ${sha}: ${checkout.stderr}`) 800 } 801 resolvedSha = sha 802 } else { 803 // checkout HEAD materializes the working tree (this is where blobs are 804 // lazy-fetched — the slow, network-bound step). It doesn't move HEAD; 805 // --branch at clone time already positioned it. rev-parse HEAD is a 806 // purely read-only ref lookup (no index lock), so it runs safely in 807 // parallel with checkout and we avoid waiting on the network for it. 808 const [checkout, revParse] = await Promise.all([ 809 execFileNoThrowWithCwd(gitExe(), ['checkout', 'HEAD'], { 810 cwd: cloneDir, 811 }), 812 execFileNoThrowWithCwd(gitExe(), ['rev-parse', 'HEAD'], { 813 cwd: cloneDir, 814 }), 815 ]) 816 if (checkout.code !== 0) { 817 throw new Error( 818 `git checkout after sparse-checkout failed: ${checkout.stderr}`, 819 ) 820 } 821 if (revParse.code === 0) { 822 resolvedSha = revParse.stdout.trim() 823 } 824 } 825 826 // Path traversal guard: resolve+verify the subdir stays inside cloneDir 827 // before moving it out. rename ENOENT is wrapped with a friendlier 828 // message that references the source path, not internal temp dirs. 829 const resolvedSubdir = validatePathWithinBase(cloneDir, subdirPath) 830 try { 831 await rename(resolvedSubdir, targetPath) 832 } catch (e: unknown) { 833 if (isENOENT(e)) { 834 throw new Error( 835 `Subdirectory '${subdirPath}' not found in repository ${gitUrl}${ref ? ` (ref: ${ref})` : ''}. ` + 836 'Check that the path is correct and exists at the specified ref/sha.', 837 ) 838 } 839 throw e 840 } 841 842 const refMsg = ref ? ` ref=${ref}` : '' 843 const shaMsg = resolvedSha ? ` sha=${resolvedSha}` : '' 844 logForDebugging( 845 `Extracted subdir ${subdirPath} from ${gitUrl}${refMsg}${shaMsg} to ${targetPath}`, 846 ) 847 return resolvedSha 848 } finally { 849 await rm(cloneDir, { recursive: true, force: true }) 850 } 851} 852 853/** 854 * Install a plugin from a local path 855 */ 856async function installFromLocal( 857 sourcePath: string, 858 targetPath: string, 859): Promise<void> { 860 if (!(await pathExists(sourcePath))) { 861 throw new Error(`Source path does not exist: ${sourcePath}`) 862 } 863 864 await copyDir(sourcePath, targetPath) 865 866 const gitPath = join(targetPath, '.git') 867 await rm(gitPath, { recursive: true, force: true }) 868} 869 870/** 871 * Generate a temporary cache name for a plugin 872 */ 873export function generateTemporaryCacheNameForPlugin( 874 source: PluginSource, 875): string { 876 const timestamp = Date.now() 877 const random = Math.random().toString(36).substring(2, 8) 878 879 let prefix: string 880 881 if (typeof source === 'string') { 882 prefix = 'local' 883 } else { 884 switch (source.source) { 885 case 'npm': 886 prefix = 'npm' 887 break 888 case 'pip': 889 prefix = 'pip' 890 break 891 case 'github': 892 prefix = 'github' 893 break 894 case 'url': 895 prefix = 'git' 896 break 897 case 'git-subdir': 898 prefix = 'subdir' 899 break 900 default: 901 prefix = 'unknown' 902 } 903 } 904 905 return `temp_${prefix}_${timestamp}_${random}` 906} 907 908/** 909 * Cache a plugin from an external source 910 */ 911export async function cachePlugin( 912 source: PluginSource, 913 options?: { 914 manifest?: PluginManifest 915 }, 916): Promise<{ path: string; manifest: PluginManifest; gitCommitSha?: string }> { 917 const cachePath = getPluginCachePath() 918 919 await getFsImplementation().mkdir(cachePath) 920 921 const tempName = generateTemporaryCacheNameForPlugin(source) 922 const tempPath = join(cachePath, tempName) 923 924 let shouldCleanup = false 925 let gitCommitSha: string | undefined 926 927 try { 928 logForDebugging( 929 `Caching plugin from source: ${jsonStringify(source)} to temporary path ${tempPath}`, 930 ) 931 932 shouldCleanup = true 933 934 if (typeof source === 'string') { 935 await installFromLocal(source, tempPath) 936 } else { 937 switch (source.source) { 938 case 'npm': 939 await installFromNpm(source.package, tempPath, { 940 registry: source.registry, 941 version: source.version, 942 }) 943 break 944 case 'github': 945 await installFromGitHub(source.repo, tempPath, source.ref, source.sha) 946 break 947 case 'url': 948 await installFromGit(source.url, tempPath, source.ref, source.sha) 949 break 950 case 'git-subdir': 951 gitCommitSha = await installFromGitSubdir( 952 source.url, 953 tempPath, 954 source.path, 955 source.ref, 956 source.sha, 957 ) 958 break 959 case 'pip': 960 throw new Error('Python package plugins are not yet supported') 961 default: 962 throw new Error(`Unsupported plugin source type`) 963 } 964 } 965 } catch (error) { 966 if (shouldCleanup && (await pathExists(tempPath))) { 967 logForDebugging(`Cleaning up failed installation at ${tempPath}`) 968 try { 969 await rm(tempPath, { recursive: true, force: true }) 970 } catch (cleanupError) { 971 logForDebugging(`Failed to clean up installation: ${cleanupError}`, { 972 level: 'error', 973 }) 974 } 975 } 976 throw error 977 } 978 979 const manifestPath = join(tempPath, '.claude-plugin', 'plugin.json') 980 const legacyManifestPath = join(tempPath, 'plugin.json') 981 let manifest: PluginManifest 982 983 if (await pathExists(manifestPath)) { 984 try { 985 const content = await readFile(manifestPath, { encoding: 'utf-8' }) 986 const parsed = jsonParse(content) 987 const result = PluginManifestSchema().safeParse(parsed) 988 989 if (result.success) { 990 manifest = result.data 991 } else { 992 // Manifest exists but is invalid - throw error 993 const errors = result.error.issues 994 .map(err => `${err.path.join('.')}: ${err.message}`) 995 .join(', ') 996 997 logForDebugging(`Invalid manifest at ${manifestPath}: ${errors}`, { 998 level: 'error', 999 }) 1000 1001 throw new Error( 1002 `Plugin has an invalid manifest file at ${manifestPath}. Validation errors: ${errors}`, 1003 ) 1004 } 1005 } catch (error) { 1006 // Check if this is a validation error we just threw 1007 if ( 1008 error instanceof Error && 1009 error.message.includes('invalid manifest file') 1010 ) { 1011 throw error 1012 } 1013 1014 // JSON parse error 1015 const errorMsg = errorMessage(error) 1016 logForDebugging( 1017 `Failed to parse manifest at ${manifestPath}: ${errorMsg}`, 1018 { 1019 level: 'error', 1020 }, 1021 ) 1022 1023 throw new Error( 1024 `Plugin has a corrupt manifest file at ${manifestPath}. JSON parse error: ${errorMsg}`, 1025 ) 1026 } 1027 } else if (await pathExists(legacyManifestPath)) { 1028 try { 1029 const content = await readFile(legacyManifestPath, { 1030 encoding: 'utf-8', 1031 }) 1032 const parsed = jsonParse(content) 1033 const result = PluginManifestSchema().safeParse(parsed) 1034 1035 if (result.success) { 1036 manifest = result.data 1037 } else { 1038 // Manifest exists but is invalid - throw error 1039 const errors = result.error.issues 1040 .map(err => `${err.path.join('.')}: ${err.message}`) 1041 .join(', ') 1042 1043 logForDebugging( 1044 `Invalid legacy manifest at ${legacyManifestPath}: ${errors}`, 1045 { level: 'error' }, 1046 ) 1047 1048 throw new Error( 1049 `Plugin has an invalid manifest file at ${legacyManifestPath}. Validation errors: ${errors}`, 1050 ) 1051 } 1052 } catch (error) { 1053 // Check if this is a validation error we just threw 1054 if ( 1055 error instanceof Error && 1056 error.message.includes('invalid manifest file') 1057 ) { 1058 throw error 1059 } 1060 1061 // JSON parse error 1062 const errorMsg = errorMessage(error) 1063 logForDebugging( 1064 `Failed to parse legacy manifest at ${legacyManifestPath}: ${errorMsg}`, 1065 { 1066 level: 'error', 1067 }, 1068 ) 1069 1070 throw new Error( 1071 `Plugin has a corrupt manifest file at ${legacyManifestPath}. JSON parse error: ${errorMsg}`, 1072 ) 1073 } 1074 } else { 1075 manifest = options?.manifest || { 1076 name: tempName, 1077 description: `Plugin cached from ${typeof source === 'string' ? source : source.source}`, 1078 } 1079 } 1080 1081 const finalName = manifest.name.replace(/[^a-zA-Z0-9-_]/g, '-') 1082 const finalPath = join(cachePath, finalName) 1083 1084 if (await pathExists(finalPath)) { 1085 logForDebugging(`Removing old cached version at ${finalPath}`) 1086 await rm(finalPath, { recursive: true, force: true }) 1087 } 1088 1089 await rename(tempPath, finalPath) 1090 1091 logForDebugging(`Successfully cached plugin ${manifest.name} to ${finalPath}`) 1092 1093 return { 1094 path: finalPath, 1095 manifest, 1096 ...(gitCommitSha && { gitCommitSha }), 1097 } 1098} 1099 1100/** 1101 * Loads and validates a plugin manifest from a JSON file. 1102 * 1103 * The manifest provides metadata about the plugin including name, version, 1104 * description, author, and other optional fields. If no manifest exists, 1105 * a minimal one is created to allow the plugin to function. 1106 * 1107 * Example plugin.json: 1108 * ```json 1109 * { 1110 * "name": "code-assistant", 1111 * "version": "1.2.0", 1112 * "description": "AI-powered code assistance tools", 1113 * "author": { 1114 * "name": "John Doe", 1115 * "email": "john@example.com" 1116 * }, 1117 * "keywords": ["coding", "ai", "assistant"], 1118 * "homepage": "https://example.com/code-assistant", 1119 * "hooks": "./custom-hooks.json", 1120 * "commands": ["./extra-commands/*.md"] 1121 * } 1122 * ``` 1123 */ 1124 1125/** 1126 * Loads and validates a plugin manifest from a JSON file. 1127 * 1128 * The manifest provides metadata about the plugin including name, version, 1129 * description, author, and other optional fields. If no manifest exists, 1130 * a minimal one is created to allow the plugin to function. 1131 * 1132 * Unknown keys in the manifest are silently stripped (PluginManifestSchema 1133 * uses zod's default strip behavior, not .strict()). Type mismatches and 1134 * other validation errors still fail. 1135 * 1136 * Behavior: 1137 * - Missing file: Creates default with provided name and source 1138 * - Invalid JSON: Throws error with parse details 1139 * - Schema validation failure: Throws error with validation details 1140 * 1141 * @param manifestPath - Full path to the plugin.json file 1142 * @param pluginName - Name to use in default manifest (e.g., "my-plugin") 1143 * @param source - Source description for default manifest (e.g., "git:repo" or ".claude-plugin/name") 1144 * @returns A valid PluginManifest object (either loaded or default) 1145 * @throws Error if manifest exists but is invalid (corrupt JSON or schema validation failure) 1146 */ 1147export async function loadPluginManifest( 1148 manifestPath: string, 1149 pluginName: string, 1150 source: string, 1151): Promise<PluginManifest> { 1152 // Check if manifest file exists 1153 // If not, create a minimal manifest to allow plugin to function 1154 if (!(await pathExists(manifestPath))) { 1155 // Return default manifest with provided name and source 1156 return { 1157 name: pluginName, 1158 description: `Plugin from ${source}`, 1159 } 1160 } 1161 1162 try { 1163 // Read and parse the manifest JSON file 1164 const content = await readFile(manifestPath, { encoding: 'utf-8' }) 1165 const parsedJson = jsonParse(content) 1166 1167 // Validate against the PluginManifest schema 1168 const result = PluginManifestSchema().safeParse(parsedJson) 1169 1170 if (result.success) { 1171 // Valid manifest - return the validated data 1172 return result.data 1173 } 1174 1175 // Schema validation failed but JSON was valid 1176 const errors = result.error.issues 1177 .map(err => 1178 err.path.length > 0 1179 ? `${err.path.join('.')}: ${err.message}` 1180 : err.message, 1181 ) 1182 .join(', ') 1183 1184 logForDebugging( 1185 `Plugin ${pluginName} has an invalid manifest file at ${manifestPath}. Validation errors: ${errors}`, 1186 { level: 'error' }, 1187 ) 1188 1189 throw new Error( 1190 `Plugin ${pluginName} has an invalid manifest file at ${manifestPath}.\n\nValidation errors: ${errors}`, 1191 ) 1192 } catch (error) { 1193 // Check if this is the error we just threw (validation error) 1194 if ( 1195 error instanceof Error && 1196 error.message.includes('invalid manifest file') 1197 ) { 1198 throw error 1199 } 1200 1201 // JSON parsing failed or file read error 1202 const errorMsg = errorMessage(error) 1203 1204 logForDebugging( 1205 `Plugin ${pluginName} has a corrupt manifest file at ${manifestPath}. Parse error: ${errorMsg}`, 1206 { level: 'error' }, 1207 ) 1208 1209 throw new Error( 1210 `Plugin ${pluginName} has a corrupt manifest file at ${manifestPath}.\n\nJSON parse error: ${errorMsg}`, 1211 ) 1212 } 1213} 1214 1215/** 1216 * Loads and validates plugin hooks configuration from a JSON file. 1217 * IMPORTANT: Only call this when the hooks file is expected to exist. 1218 * 1219 * @param hooksConfigPath - Full path to the hooks.json file 1220 * @param pluginName - Plugin name for error messages 1221 * @returns Validated HooksSettings 1222 * @throws Error if file doesn't exist or is invalid 1223 */ 1224async function loadPluginHooks( 1225 hooksConfigPath: string, 1226 pluginName: string, 1227): Promise<HooksSettings> { 1228 if (!(await pathExists(hooksConfigPath))) { 1229 throw new Error( 1230 `Hooks file not found at ${hooksConfigPath} for plugin ${pluginName}. If the manifest declares hooks, the file must exist.`, 1231 ) 1232 } 1233 1234 const content = await readFile(hooksConfigPath, { encoding: 'utf-8' }) 1235 const rawHooksConfig = jsonParse(content) 1236 1237 // The hooks.json file has a wrapper structure with description and hooks 1238 // Use PluginHooksSchema to validate and extract the hooks property 1239 const validatedPluginHooks = PluginHooksSchema().parse(rawHooksConfig) 1240 1241 return validatedPluginHooks.hooks as HooksSettings 1242} 1243 1244/** 1245 * Validate a list of plugin component relative paths by checking existence in parallel. 1246 * 1247 * This helper parallelizes the pathExists checks (the expensive async part) while 1248 * preserving deterministic error/log ordering by iterating results sequentially. 1249 * 1250 * Introduced to fix a perf regression from the sync→async fs migration: sequential 1251 * `for { await pathExists }` loops add ~1-5ms of event-loop overhead per iteration. 1252 * With many plugins × several component types, this compounds to hundreds of ms. 1253 * 1254 * @param relPaths - Relative paths from the manifest/marketplace entry to validate 1255 * @param pluginPath - Plugin root directory to resolve relative paths against 1256 * @param pluginName - Plugin name for error messages 1257 * @param source - Source identifier for PluginError records 1258 * @param component - Which component these paths belong to (for error records) 1259 * @param componentLabel - Human-readable label for log messages (e.g. "Agent", "Skill") 1260 * @param contextLabel - Where the path came from, for log messages 1261 * (e.g. "specified in manifest but", "from marketplace entry") 1262 * @param errors - Error array to push path-not-found errors into (mutated) 1263 * @returns Array of full paths that exist on disk, in original order 1264 */ 1265async function validatePluginPaths( 1266 relPaths: string[], 1267 pluginPath: string, 1268 pluginName: string, 1269 source: string, 1270 component: PluginComponent, 1271 componentLabel: string, 1272 contextLabel: string, 1273 errors: PluginError[], 1274): Promise<string[]> { 1275 // Parallelize the async pathExists checks 1276 const checks = await Promise.all( 1277 relPaths.map(async relPath => { 1278 const fullPath = join(pluginPath, relPath) 1279 return { relPath, fullPath, exists: await pathExists(fullPath) } 1280 }), 1281 ) 1282 // Process results in original order to keep error/log ordering deterministic 1283 const validPaths: string[] = [] 1284 for (const { relPath, fullPath, exists } of checks) { 1285 if (exists) { 1286 validPaths.push(fullPath) 1287 } else { 1288 logForDebugging( 1289 `${componentLabel} path ${relPath} ${contextLabel} not found at ${fullPath} for ${pluginName}`, 1290 { level: 'warn' }, 1291 ) 1292 logError( 1293 new Error( 1294 `Plugin component file not found: ${fullPath} for ${pluginName}`, 1295 ), 1296 ) 1297 errors.push({ 1298 type: 'path-not-found', 1299 source, 1300 plugin: pluginName, 1301 path: fullPath, 1302 component, 1303 }) 1304 } 1305 } 1306 return validPaths 1307} 1308 1309/** 1310 * Creates a LoadedPlugin object from a plugin directory path. 1311 * 1312 * This is the central function that assembles a complete plugin representation 1313 * by scanning the plugin directory structure and loading all components. 1314 * It handles both fully-featured plugins with manifests and minimal plugins 1315 * with just commands or agents directories. 1316 * 1317 * Directory structure it looks for: 1318 * ``` 1319 * plugin-directory/ 1320 * ├── plugin.json # Optional: Plugin manifest 1321 * ├── commands/ # Optional: Custom slash commands 1322 * │ ├── build.md # /build command 1323 * │ └── test.md # /test command 1324 * ├── agents/ # Optional: Custom AI agents 1325 * │ ├── reviewer.md # Code review agent 1326 * │ └── optimizer.md # Performance optimization agent 1327 * └── hooks/ # Optional: Hook configurations 1328 * └── hooks.json # Hook definitions 1329 * ``` 1330 * 1331 * Component detection: 1332 * - Manifest: Loaded from plugin.json if present, otherwise creates default 1333 * - Commands: Sets commandsPath if commands/ directory exists 1334 * - Agents: Sets agentsPath if agents/ directory exists 1335 * - Hooks: Loads from hooks/hooks.json if present 1336 * 1337 * The function is tolerant of missing components - a plugin can have 1338 * any combination of the above directories/files. Missing component files 1339 * are reported as errors but don't prevent plugin loading. 1340 * 1341 * @param pluginPath - Absolute path to the plugin directory 1342 * @param source - Source identifier (e.g., "git:repo", ".claude-plugin/my-plugin") 1343 * @param enabled - Initial enabled state (may be overridden by settings) 1344 * @param fallbackName - Name to use if manifest doesn't specify one 1345 * @param strict - When true, adds errors for duplicate hook files (default: true) 1346 * @returns Object containing the LoadedPlugin and any errors encountered 1347 */ 1348export async function createPluginFromPath( 1349 pluginPath: string, 1350 source: string, 1351 enabled: boolean, 1352 fallbackName: string, 1353 strict = true, 1354): Promise<{ plugin: LoadedPlugin; errors: PluginError[] }> { 1355 const errors: PluginError[] = [] 1356 1357 // Step 1: Load or create the plugin manifest 1358 // This provides metadata about the plugin (name, version, etc.) 1359 const manifestPath = join(pluginPath, '.claude-plugin', 'plugin.json') 1360 const manifest = await loadPluginManifest(manifestPath, fallbackName, source) 1361 1362 // Step 2: Create the base plugin object 1363 // Start with required fields from manifest and parameters 1364 const plugin: LoadedPlugin = { 1365 name: manifest.name, // Use name from manifest (or fallback) 1366 manifest, // Store full manifest for later use 1367 path: pluginPath, // Absolute path to plugin directory 1368 source, // Source identifier (e.g., "git:repo" or ".claude-plugin/name") 1369 repository: source, // For backward compatibility with Plugin Repository 1370 enabled, // Current enabled state 1371 } 1372 1373 // Step 3: Auto-detect optional directories in parallel 1374 const [ 1375 commandsDirExists, 1376 agentsDirExists, 1377 skillsDirExists, 1378 outputStylesDirExists, 1379 ] = await Promise.all([ 1380 !manifest.commands ? pathExists(join(pluginPath, 'commands')) : false, 1381 !manifest.agents ? pathExists(join(pluginPath, 'agents')) : false, 1382 !manifest.skills ? pathExists(join(pluginPath, 'skills')) : false, 1383 !manifest.outputStyles 1384 ? pathExists(join(pluginPath, 'output-styles')) 1385 : false, 1386 ]) 1387 1388 const commandsPath = join(pluginPath, 'commands') 1389 if (commandsDirExists) { 1390 plugin.commandsPath = commandsPath 1391 } 1392 1393 // Step 3a: Process additional command paths from manifest 1394 if (manifest.commands) { 1395 // Check if it's an object mapping (record of command name → metadata) 1396 const firstValue = Object.values(manifest.commands)[0] 1397 if ( 1398 typeof manifest.commands === 'object' && 1399 !Array.isArray(manifest.commands) && 1400 firstValue && 1401 typeof firstValue === 'object' && 1402 ('source' in firstValue || 'content' in firstValue) 1403 ) { 1404 // Object mapping format: { "about": { "source": "./README.md", ... } } 1405 const commandsMetadata: Record<string, CommandMetadata> = {} 1406 const validPaths: string[] = [] 1407 1408 // Parallelize pathExists checks; process results in order to keep 1409 // error/log ordering deterministic. 1410 const entries = Object.entries(manifest.commands) 1411 const checks = await Promise.all( 1412 entries.map(async ([commandName, metadata]) => { 1413 if (!metadata || typeof metadata !== 'object') { 1414 return { commandName, metadata, kind: 'skip' as const } 1415 } 1416 if (metadata.source) { 1417 const fullPath = join(pluginPath, metadata.source) 1418 return { 1419 commandName, 1420 metadata, 1421 kind: 'source' as const, 1422 fullPath, 1423 exists: await pathExists(fullPath), 1424 } 1425 } 1426 if (metadata.content) { 1427 return { commandName, metadata, kind: 'content' as const } 1428 } 1429 return { commandName, metadata, kind: 'skip' as const } 1430 }), 1431 ) 1432 for (const check of checks) { 1433 if (check.kind === 'skip') continue 1434 if (check.kind === 'content') { 1435 // For inline content commands, add metadata without path 1436 commandsMetadata[check.commandName] = check.metadata 1437 continue 1438 } 1439 // kind === 'source' 1440 if (check.exists) { 1441 validPaths.push(check.fullPath) 1442 commandsMetadata[check.commandName] = check.metadata 1443 } else { 1444 logForDebugging( 1445 `Command ${check.commandName} path ${check.metadata.source} specified in manifest but not found at ${check.fullPath} for ${manifest.name}`, 1446 { level: 'warn' }, 1447 ) 1448 logError( 1449 new Error( 1450 `Plugin component file not found: ${check.fullPath} for ${manifest.name}`, 1451 ), 1452 ) 1453 errors.push({ 1454 type: 'path-not-found', 1455 source, 1456 plugin: manifest.name, 1457 path: check.fullPath, 1458 component: 'commands', 1459 }) 1460 } 1461 } 1462 1463 // Set commandsPaths if there are file-based commands 1464 if (validPaths.length > 0) { 1465 plugin.commandsPaths = validPaths 1466 } 1467 // Set commandsMetadata if there are any commands (file-based or inline) 1468 if (Object.keys(commandsMetadata).length > 0) { 1469 plugin.commandsMetadata = commandsMetadata 1470 } 1471 } else { 1472 // Path or array of paths format 1473 const commandPaths = Array.isArray(manifest.commands) 1474 ? manifest.commands 1475 : [manifest.commands] 1476 1477 // Parallelize pathExists checks; process results in order. 1478 const checks = await Promise.all( 1479 commandPaths.map(async cmdPath => { 1480 if (typeof cmdPath !== 'string') { 1481 return { cmdPath, kind: 'invalid' as const } 1482 } 1483 const fullPath = join(pluginPath, cmdPath) 1484 return { 1485 cmdPath, 1486 kind: 'path' as const, 1487 fullPath, 1488 exists: await pathExists(fullPath), 1489 } 1490 }), 1491 ) 1492 const validPaths: string[] = [] 1493 for (const check of checks) { 1494 if (check.kind === 'invalid') { 1495 logForDebugging( 1496 `Unexpected command format in manifest for ${manifest.name}`, 1497 { level: 'error' }, 1498 ) 1499 continue 1500 } 1501 if (check.exists) { 1502 validPaths.push(check.fullPath) 1503 } else { 1504 logForDebugging( 1505 `Command path ${check.cmdPath} specified in manifest but not found at ${check.fullPath} for ${manifest.name}`, 1506 { level: 'warn' }, 1507 ) 1508 logError( 1509 new Error( 1510 `Plugin component file not found: ${check.fullPath} for ${manifest.name}`, 1511 ), 1512 ) 1513 errors.push({ 1514 type: 'path-not-found', 1515 source, 1516 plugin: manifest.name, 1517 path: check.fullPath, 1518 component: 'commands', 1519 }) 1520 } 1521 } 1522 1523 if (validPaths.length > 0) { 1524 plugin.commandsPaths = validPaths 1525 } 1526 } 1527 } 1528 1529 // Step 4: Register agents directory if detected 1530 const agentsPath = join(pluginPath, 'agents') 1531 if (agentsDirExists) { 1532 plugin.agentsPath = agentsPath 1533 } 1534 1535 // Step 4a: Process additional agent paths from manifest 1536 if (manifest.agents) { 1537 const agentPaths = Array.isArray(manifest.agents) 1538 ? manifest.agents 1539 : [manifest.agents] 1540 1541 const validPaths = await validatePluginPaths( 1542 agentPaths, 1543 pluginPath, 1544 manifest.name, 1545 source, 1546 'agents', 1547 'Agent', 1548 'specified in manifest but', 1549 errors, 1550 ) 1551 1552 if (validPaths.length > 0) { 1553 plugin.agentsPaths = validPaths 1554 } 1555 } 1556 1557 // Step 4b: Register skills directory if detected 1558 const skillsPath = join(pluginPath, 'skills') 1559 if (skillsDirExists) { 1560 plugin.skillsPath = skillsPath 1561 } 1562 1563 // Step 4c: Process additional skill paths from manifest 1564 if (manifest.skills) { 1565 const skillPaths = Array.isArray(manifest.skills) 1566 ? manifest.skills 1567 : [manifest.skills] 1568 1569 const validPaths = await validatePluginPaths( 1570 skillPaths, 1571 pluginPath, 1572 manifest.name, 1573 source, 1574 'skills', 1575 'Skill', 1576 'specified in manifest but', 1577 errors, 1578 ) 1579 1580 if (validPaths.length > 0) { 1581 plugin.skillsPaths = validPaths 1582 } 1583 } 1584 1585 // Step 4d: Register output-styles directory if detected 1586 const outputStylesPath = join(pluginPath, 'output-styles') 1587 if (outputStylesDirExists) { 1588 plugin.outputStylesPath = outputStylesPath 1589 } 1590 1591 // Step 4e: Process additional output style paths from manifest 1592 if (manifest.outputStyles) { 1593 const outputStylePaths = Array.isArray(manifest.outputStyles) 1594 ? manifest.outputStyles 1595 : [manifest.outputStyles] 1596 1597 const validPaths = await validatePluginPaths( 1598 outputStylePaths, 1599 pluginPath, 1600 manifest.name, 1601 source, 1602 'output-styles', 1603 'Output style', 1604 'specified in manifest but', 1605 errors, 1606 ) 1607 1608 if (validPaths.length > 0) { 1609 plugin.outputStylesPaths = validPaths 1610 } 1611 } 1612 1613 // Step 5: Load hooks configuration 1614 let mergedHooks: HooksSettings | undefined 1615 const loadedHookPaths = new Set<string>() // Track loaded hook files 1616 1617 // Load from standard hooks/hooks.json if it exists 1618 const standardHooksPath = join(pluginPath, 'hooks', 'hooks.json') 1619 if (await pathExists(standardHooksPath)) { 1620 try { 1621 mergedHooks = await loadPluginHooks(standardHooksPath, manifest.name) 1622 // Track the normalized path to prevent duplicate loading 1623 try { 1624 loadedHookPaths.add(await realpath(standardHooksPath)) 1625 } catch { 1626 // If realpathSync fails, use original path 1627 loadedHookPaths.add(standardHooksPath) 1628 } 1629 logForDebugging( 1630 `Loaded hooks from standard location for plugin ${manifest.name}: ${standardHooksPath}`, 1631 ) 1632 } catch (error) { 1633 const errorMsg = errorMessage(error) 1634 logForDebugging( 1635 `Failed to load hooks for ${manifest.name}: ${errorMsg}`, 1636 { 1637 level: 'error', 1638 }, 1639 ) 1640 logError(toError(error)) 1641 errors.push({ 1642 type: 'hook-load-failed', 1643 source, 1644 plugin: manifest.name, 1645 hookPath: standardHooksPath, 1646 reason: errorMsg, 1647 }) 1648 } 1649 } 1650 1651 // Load and merge hooks from manifest.hooks if specified 1652 if (manifest.hooks) { 1653 const manifestHooksArray = Array.isArray(manifest.hooks) 1654 ? manifest.hooks 1655 : [manifest.hooks] 1656 1657 for (const hookSpec of manifestHooksArray) { 1658 if (typeof hookSpec === 'string') { 1659 // Path to additional hooks file 1660 const hookFilePath = join(pluginPath, hookSpec) 1661 if (!(await pathExists(hookFilePath))) { 1662 logForDebugging( 1663 `Hooks file ${hookSpec} specified in manifest but not found at ${hookFilePath} for ${manifest.name}`, 1664 { level: 'error' }, 1665 ) 1666 logError( 1667 new Error( 1668 `Plugin component file not found: ${hookFilePath} for ${manifest.name}`, 1669 ), 1670 ) 1671 errors.push({ 1672 type: 'path-not-found', 1673 source, 1674 plugin: manifest.name, 1675 path: hookFilePath, 1676 component: 'hooks', 1677 }) 1678 continue 1679 } 1680 1681 // Check if this path resolves to an already-loaded hooks file 1682 let normalizedPath: string 1683 try { 1684 normalizedPath = await realpath(hookFilePath) 1685 } catch { 1686 // If realpathSync fails, use original path 1687 normalizedPath = hookFilePath 1688 } 1689 1690 if (loadedHookPaths.has(normalizedPath)) { 1691 logForDebugging( 1692 `Skipping duplicate hooks file for plugin ${manifest.name}: ${hookSpec} ` + 1693 `(resolves to already-loaded file: ${normalizedPath})`, 1694 ) 1695 if (strict) { 1696 const errorMsg = `Duplicate hooks file detected: ${hookSpec} resolves to already-loaded file ${normalizedPath}. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.` 1697 logError(new Error(errorMsg)) 1698 errors.push({ 1699 type: 'hook-load-failed', 1700 source, 1701 plugin: manifest.name, 1702 hookPath: hookFilePath, 1703 reason: errorMsg, 1704 }) 1705 } 1706 continue 1707 } 1708 1709 try { 1710 const additionalHooks = await loadPluginHooks( 1711 hookFilePath, 1712 manifest.name, 1713 ) 1714 try { 1715 mergedHooks = mergeHooksSettings(mergedHooks, additionalHooks) 1716 loadedHookPaths.add(normalizedPath) 1717 logForDebugging( 1718 `Loaded and merged hooks from manifest for plugin ${manifest.name}: ${hookSpec}`, 1719 ) 1720 } catch (mergeError) { 1721 const mergeErrorMsg = errorMessage(mergeError) 1722 logForDebugging( 1723 `Failed to merge hooks from ${hookSpec} for ${manifest.name}: ${mergeErrorMsg}`, 1724 { level: 'error' }, 1725 ) 1726 logError(toError(mergeError)) 1727 errors.push({ 1728 type: 'hook-load-failed', 1729 source, 1730 plugin: manifest.name, 1731 hookPath: hookFilePath, 1732 reason: `Failed to merge: ${mergeErrorMsg}`, 1733 }) 1734 } 1735 } catch (error) { 1736 const errorMsg = errorMessage(error) 1737 logForDebugging( 1738 `Failed to load hooks from ${hookSpec} for ${manifest.name}: ${errorMsg}`, 1739 { level: 'error' }, 1740 ) 1741 logError(toError(error)) 1742 errors.push({ 1743 type: 'hook-load-failed', 1744 source, 1745 plugin: manifest.name, 1746 hookPath: hookFilePath, 1747 reason: errorMsg, 1748 }) 1749 } 1750 } else if (typeof hookSpec === 'object') { 1751 // Inline hooks 1752 mergedHooks = mergeHooksSettings(mergedHooks, hookSpec as HooksSettings) 1753 } 1754 } 1755 } 1756 1757 if (mergedHooks) { 1758 plugin.hooksConfig = mergedHooks 1759 } 1760 1761 // Step 6: Load plugin settings 1762 // Settings can come from settings.json in the plugin directory or from manifest.settings 1763 // Only allowlisted keys are kept (currently: agent) 1764 const pluginSettings = await loadPluginSettings(pluginPath, manifest) 1765 if (pluginSettings) { 1766 plugin.settings = pluginSettings 1767 } 1768 1769 return { plugin, errors } 1770} 1771 1772/** 1773 * Schema derived from SettingsSchema that only keeps keys plugins are allowed to set. 1774 * Uses .strip() so unknown keys are silently removed during parsing. 1775 */ 1776const PluginSettingsSchema = lazySchema(() => 1777 SettingsSchema() 1778 .pick({ 1779 agent: true, 1780 }) 1781 .strip(), 1782) 1783 1784/** 1785 * Parse raw settings through PluginSettingsSchema, returning only allowlisted keys. 1786 * Returns undefined if parsing fails or all keys are filtered out. 1787 */ 1788function parsePluginSettings( 1789 raw: Record<string, unknown>, 1790): Record<string, unknown> | undefined { 1791 const result = PluginSettingsSchema().safeParse(raw) 1792 if (!result.success) { 1793 return undefined 1794 } 1795 const data = result.data 1796 if (Object.keys(data).length === 0) { 1797 return undefined 1798 } 1799 return data 1800} 1801 1802/** 1803 * Load plugin settings from settings.json file or manifest.settings. 1804 * settings.json takes priority over manifest.settings when both exist. 1805 * Only allowlisted keys are included in the result. 1806 */ 1807async function loadPluginSettings( 1808 pluginPath: string, 1809 manifest: PluginManifest, 1810): Promise<Record<string, unknown> | undefined> { 1811 // Try loading settings.json from the plugin directory 1812 const settingsJsonPath = join(pluginPath, 'settings.json') 1813 try { 1814 const content = await readFile(settingsJsonPath, { encoding: 'utf-8' }) 1815 const parsed = jsonParse(content) 1816 if (isRecord(parsed)) { 1817 const filtered = parsePluginSettings(parsed) 1818 if (filtered) { 1819 logForDebugging( 1820 `Loaded settings from settings.json for plugin ${manifest.name}`, 1821 ) 1822 return filtered 1823 } 1824 } 1825 } catch (e: unknown) { 1826 // Missing/inaccessible is expected - settings.json is optional 1827 if (!isFsInaccessible(e)) { 1828 logForDebugging( 1829 `Failed to parse settings.json for plugin ${manifest.name}: ${e}`, 1830 { level: 'warn' }, 1831 ) 1832 } 1833 } 1834 1835 // Fall back to manifest.settings 1836 if (manifest.settings) { 1837 const filtered = parsePluginSettings( 1838 manifest.settings as Record<string, unknown>, 1839 ) 1840 if (filtered) { 1841 logForDebugging( 1842 `Loaded settings from manifest for plugin ${manifest.name}`, 1843 ) 1844 return filtered 1845 } 1846 } 1847 1848 return undefined 1849} 1850 1851/** 1852 * Merge two HooksSettings objects 1853 */ 1854function mergeHooksSettings( 1855 base: HooksSettings | undefined, 1856 additional: HooksSettings, 1857): HooksSettings { 1858 if (!base) { 1859 return additional 1860 } 1861 1862 const merged = { ...base } 1863 1864 for (const [event, matchers] of Object.entries(additional)) { 1865 if (!merged[event as keyof HooksSettings]) { 1866 merged[event as keyof HooksSettings] = matchers 1867 } else { 1868 // Merge matchers for this event 1869 merged[event as keyof HooksSettings] = [ 1870 ...(merged[event as keyof HooksSettings] || []), 1871 ...matchers, 1872 ] 1873 } 1874 } 1875 1876 return merged 1877} 1878 1879/** 1880 * Shared discovery/policy/merge pipeline for both load modes. 1881 * 1882 * Resolves enabledPlugins → marketplace entries, runs enterprise policy 1883 * checks, pre-loads catalogs, then dispatches each entry to the full or 1884 * cache-only per-entry loader. The ONLY difference between loadAllPlugins 1885 * and loadAllPluginsCacheOnly is which loader runs — discovery and policy 1886 * are identical. 1887 */ 1888async function loadPluginsFromMarketplaces({ 1889 cacheOnly, 1890}: { 1891 cacheOnly: boolean 1892}): Promise<{ 1893 plugins: LoadedPlugin[] 1894 errors: PluginError[] 1895}> { 1896 const settings = getSettings_DEPRECATED() 1897 // Merge --add-dir plugins at lowest priority; standard settings win on conflict 1898 const enabledPlugins = { 1899 ...getAddDirEnabledPlugins(), 1900 ...(settings.enabledPlugins || {}), 1901 } 1902 const plugins: LoadedPlugin[] = [] 1903 const errors: PluginError[] = [] 1904 1905 // Filter to plugin@marketplace format and validate 1906 const marketplacePluginEntries = Object.entries(enabledPlugins).filter( 1907 ([key, value]) => { 1908 // Check if it's in plugin@marketplace format (includes both enabled and disabled) 1909 const isValidFormat = PluginIdSchema().safeParse(key).success 1910 if (!isValidFormat || value === undefined) return false 1911 // Skip built-in plugins — handled separately by getBuiltinPlugins() 1912 const { marketplace } = parsePluginIdentifier(key) 1913 return marketplace !== BUILTIN_MARKETPLACE_NAME 1914 }, 1915 ) 1916 1917 // Load known marketplaces config to look up sources for policy checking. 1918 // Use the Safe variant so a corrupted config file doesn't crash all plugin 1919 // loading — this is a read-only path, so returning {} degrades gracefully. 1920 const knownMarketplaces = await loadKnownMarketplacesConfigSafe() 1921 1922 // Fail-closed guard for enterprise policy: if a policy IS configured and we 1923 // cannot resolve a marketplace's source (config returned {} due to corruption, 1924 // or entry missing), we must NOT silently skip the policy check and load the 1925 // plugin anyway. Before Safe, a corrupted config crashed everything (loud, 1926 // fail-closed). With Safe + no guard, the policy check short-circuits on 1927 // undefined marketplaceConfig and the fallback path (getPluginByIdCacheOnly) 1928 // loads the plugin unchecked — a silent fail-open. This guard restores 1929 // fail-closed: unknown source + active policy → block. 1930 // 1931 // Allowlist: any value (including []) is active — empty allowlist = deny all. 1932 // Blocklist: empty [] is a semantic no-op — only non-empty counts as active. 1933 const strictAllowlist = getStrictKnownMarketplaces() 1934 const blocklist = getBlockedMarketplaces() 1935 const hasEnterprisePolicy = 1936 strictAllowlist !== null || (blocklist !== null && blocklist.length > 0) 1937 1938 // Pre-load marketplace catalogs once per marketplace rather than re-reading 1939 // known_marketplaces.json + marketplace.json for every plugin. This is the 1940 // hot path — with N plugins across M marketplaces, the old per-plugin 1941 // getPluginByIdCacheOnly() did 2N config reads + N catalog reads; this does M. 1942 const uniqueMarketplaces = new Set( 1943 marketplacePluginEntries 1944 .map(([pluginId]) => parsePluginIdentifier(pluginId).marketplace) 1945 .filter((m): m is string => !!m), 1946 ) 1947 const marketplaceCatalogs = new Map< 1948 string, 1949 Awaited<ReturnType<typeof getMarketplaceCacheOnly>> 1950 >() 1951 await Promise.all( 1952 [...uniqueMarketplaces].map(async name => { 1953 marketplaceCatalogs.set(name, await getMarketplaceCacheOnly(name)) 1954 }), 1955 ) 1956 1957 // Look up installed versions once so the first-pass ZIP cache check 1958 // can hit even when the marketplace entry omits `version`. 1959 const installedPluginsData = getInMemoryInstalledPlugins() 1960 1961 // Load all marketplace plugins in parallel for faster startup 1962 const results = await Promise.allSettled( 1963 marketplacePluginEntries.map(async ([pluginId, enabledValue]) => { 1964 const { name: pluginName, marketplace: marketplaceName } = 1965 parsePluginIdentifier(pluginId) 1966 1967 // Check if marketplace source is allowed by enterprise policy 1968 const marketplaceConfig = knownMarketplaces[marketplaceName!] 1969 1970 // Fail-closed: if enterprise policy is active and we can't look up the 1971 // marketplace source (config corrupted/empty, or entry missing), block 1972 // rather than silently skip the policy check. See hasEnterprisePolicy 1973 // comment above for the fail-open hazard this guards against. 1974 // 1975 // This also fires for the "stale enabledPlugins entry with no registered 1976 // marketplace" case, which is a UX trade-off: the user gets a policy 1977 // error instead of plugin-not-found. Accepted because the fallback path 1978 // (getPluginByIdCacheOnly) does a raw cast of known_marketplaces.json 1979 // with NO schema validation — if one entry is malformed enough to fail 1980 // our validation but readable enough for the raw cast, it would load 1981 // unchecked. Unverifiable source + active policy → block, always. 1982 if (!marketplaceConfig && hasEnterprisePolicy) { 1983 // We can't know whether the unverifiable source would actually be in 1984 // the blocklist or not in the allowlist — so pick the error variant 1985 // that matches whichever policy IS configured. If an allowlist exists, 1986 // "not in allowed list" is the right framing; if only a blocklist 1987 // exists, "blocked by blocklist" is less misleading than showing an 1988 // empty allowed-sources list. 1989 errors.push({ 1990 type: 'marketplace-blocked-by-policy', 1991 source: pluginId, 1992 plugin: pluginName, 1993 marketplace: marketplaceName!, 1994 blockedByBlocklist: strictAllowlist === null, 1995 allowedSources: (strictAllowlist ?? []).map(s => 1996 formatSourceForDisplay(s), 1997 ), 1998 }) 1999 return null 2000 } 2001 2002 if ( 2003 marketplaceConfig && 2004 !isSourceAllowedByPolicy(marketplaceConfig.source) 2005 ) { 2006 // Check if explicitly blocked vs not in allowlist for better error context 2007 const isBlocked = isSourceInBlocklist(marketplaceConfig.source) 2008 const allowlist = getStrictKnownMarketplaces() || [] 2009 errors.push({ 2010 type: 'marketplace-blocked-by-policy', 2011 source: pluginId, 2012 plugin: pluginName, 2013 marketplace: marketplaceName!, 2014 blockedByBlocklist: isBlocked, 2015 allowedSources: isBlocked 2016 ? [] 2017 : allowlist.map(s => formatSourceForDisplay(s)), 2018 }) 2019 return null 2020 } 2021 2022 // Look up plugin entry from pre-loaded marketplace catalog (no per-plugin I/O). 2023 // Fall back to getPluginByIdCacheOnly if the catalog couldn't be pre-loaded. 2024 let result: Awaited<ReturnType<typeof getPluginByIdCacheOnly>> = null 2025 const marketplace = marketplaceCatalogs.get(marketplaceName!) 2026 if (marketplace && marketplaceConfig) { 2027 const entry = marketplace.plugins.find(p => p.name === pluginName) 2028 if (entry) { 2029 result = { 2030 entry, 2031 marketplaceInstallLocation: marketplaceConfig.installLocation, 2032 } 2033 } 2034 } else { 2035 result = await getPluginByIdCacheOnly(pluginId) 2036 } 2037 2038 if (!result) { 2039 errors.push({ 2040 type: 'plugin-not-found', 2041 source: pluginId, 2042 pluginId: pluginName!, 2043 marketplace: marketplaceName!, 2044 }) 2045 return null 2046 } 2047 2048 // installed_plugins.json records what's actually cached on disk 2049 // (version for the full loader's first-pass probe, installPath for 2050 // the cache-only loader's direct read). 2051 const installEntry = installedPluginsData.plugins[pluginId]?.[0] 2052 return cacheOnly 2053 ? loadPluginFromMarketplaceEntryCacheOnly( 2054 result.entry, 2055 result.marketplaceInstallLocation, 2056 pluginId, 2057 enabledValue === true, 2058 errors, 2059 installEntry?.installPath, 2060 ) 2061 : loadPluginFromMarketplaceEntry( 2062 result.entry, 2063 result.marketplaceInstallLocation, 2064 pluginId, 2065 enabledValue === true, 2066 errors, 2067 installEntry?.version, 2068 ) 2069 }), 2070 ) 2071 2072 for (const [i, result] of results.entries()) { 2073 if (result.status === 'fulfilled' && result.value) { 2074 plugins.push(result.value) 2075 } else if (result.status === 'rejected') { 2076 const err = toError(result.reason) 2077 logError(err) 2078 const pluginId = marketplacePluginEntries[i]![0] 2079 errors.push({ 2080 type: 'generic-error', 2081 source: pluginId, 2082 plugin: pluginId.split('@')[0], 2083 error: err.message, 2084 }) 2085 } 2086 } 2087 2088 return { plugins, errors } 2089} 2090 2091/** 2092 * Cache-only variant of loadPluginFromMarketplaceEntry. 2093 * 2094 * Skips network (cachePlugin) and disk-copy (copyPluginToVersionedCache). 2095 * Reads directly from the recorded installPath; if missing, emits 2096 * 'plugin-cache-miss'. Still extracts ZIP-cached plugins (local, fast). 2097 */ 2098async function loadPluginFromMarketplaceEntryCacheOnly( 2099 entry: PluginMarketplaceEntry, 2100 marketplaceInstallLocation: string, 2101 pluginId: string, 2102 enabled: boolean, 2103 errorsOut: PluginError[], 2104 installPath: string | undefined, 2105): Promise<LoadedPlugin | null> { 2106 let pluginPath: string 2107 2108 if (typeof entry.source === 'string') { 2109 // Local relative path — read from the marketplace source dir directly. 2110 // Skip copyPluginToVersionedCache; startup doesn't need a fresh copy. 2111 let marketplaceDir: string 2112 try { 2113 marketplaceDir = (await stat(marketplaceInstallLocation)).isDirectory() 2114 ? marketplaceInstallLocation 2115 : join(marketplaceInstallLocation, '..') 2116 } catch { 2117 errorsOut.push({ 2118 type: 'plugin-cache-miss', 2119 source: pluginId, 2120 plugin: entry.name, 2121 installPath: marketplaceInstallLocation, 2122 }) 2123 return null 2124 } 2125 pluginPath = join(marketplaceDir, entry.source) 2126 // finishLoadingPluginFromPath reads pluginPath — its error handling 2127 // surfaces ENOENT as a load failure, no need to pre-check here. 2128 } else { 2129 // External source (npm/github/url/git-subdir) — use recorded installPath. 2130 if (!installPath || !(await pathExists(installPath))) { 2131 errorsOut.push({ 2132 type: 'plugin-cache-miss', 2133 source: pluginId, 2134 plugin: entry.name, 2135 installPath: installPath ?? '(not recorded)', 2136 }) 2137 return null 2138 } 2139 pluginPath = installPath 2140 } 2141 2142 // Zip cache extraction — must still happen in cacheOnly mode (invariant 4) 2143 if (isPluginZipCacheEnabled() && pluginPath.endsWith('.zip')) { 2144 const sessionDir = await getSessionPluginCachePath() 2145 const extractDir = join( 2146 sessionDir, 2147 pluginId.replace(/[^a-zA-Z0-9@\-_]/g, '-'), 2148 ) 2149 try { 2150 await extractZipToDirectory(pluginPath, extractDir) 2151 pluginPath = extractDir 2152 } catch (error) { 2153 logForDebugging(`Failed to extract plugin ZIP ${pluginPath}: ${error}`, { 2154 level: 'error', 2155 }) 2156 errorsOut.push({ 2157 type: 'plugin-cache-miss', 2158 source: pluginId, 2159 plugin: entry.name, 2160 installPath: pluginPath, 2161 }) 2162 return null 2163 } 2164 } 2165 2166 // Delegate to the shared tail — identical to the full loader from here 2167 return finishLoadingPluginFromPath( 2168 entry, 2169 pluginId, 2170 enabled, 2171 errorsOut, 2172 pluginPath, 2173 ) 2174} 2175 2176/** 2177 * Load a plugin from a marketplace entry based on its source configuration. 2178 * 2179 * Handles different source types: 2180 * - Relative path: Loads from marketplace repo directory 2181 * - npm/github/url: Caches then loads from cache 2182 * 2183 * @param installedVersion - Version from installed_plugins.json, used as a 2184 * first-pass hint for the versioned cache lookup when the marketplace entry 2185 * omits `version`. Avoids re-cloning external plugins just to discover the 2186 * version we already recorded at install time. 2187 * 2188 * Returns both the loaded plugin and any errors encountered during loading. 2189 * Errors include missing component files and hook load failures. 2190 */ 2191async function loadPluginFromMarketplaceEntry( 2192 entry: PluginMarketplaceEntry, 2193 marketplaceInstallLocation: string, 2194 pluginId: string, 2195 enabled: boolean, 2196 errorsOut: PluginError[], 2197 installedVersion?: string, 2198): Promise<LoadedPlugin | null> { 2199 logForDebugging( 2200 `Loading plugin ${entry.name} from source: ${jsonStringify(entry.source)}`, 2201 ) 2202 let pluginPath: string 2203 2204 if (typeof entry.source === 'string') { 2205 // Relative path - resolve relative to marketplace install location 2206 const marketplaceDir = ( 2207 await stat(marketplaceInstallLocation) 2208 ).isDirectory() 2209 ? marketplaceInstallLocation 2210 : join(marketplaceInstallLocation, '..') 2211 const sourcePluginPath = join(marketplaceDir, entry.source) 2212 2213 if (!(await pathExists(sourcePluginPath))) { 2214 const error = new Error(`Plugin path not found: ${sourcePluginPath}`) 2215 logForDebugging(`Plugin path not found: ${sourcePluginPath}`, { 2216 level: 'error', 2217 }) 2218 logError(error) 2219 errorsOut.push({ 2220 type: 'generic-error', 2221 source: pluginId, 2222 error: `Plugin directory not found at path: ${sourcePluginPath}. Check that the marketplace entry has the correct path.`, 2223 }) 2224 return null 2225 } 2226 2227 // Always copy local plugins to versioned cache 2228 try { 2229 // Try to load manifest from plugin directory to check for version field first 2230 const manifestPath = join( 2231 sourcePluginPath, 2232 '.claude-plugin', 2233 'plugin.json', 2234 ) 2235 let pluginManifest: PluginManifest | undefined 2236 try { 2237 pluginManifest = await loadPluginManifest( 2238 manifestPath, 2239 entry.name, 2240 entry.source, 2241 ) 2242 } catch { 2243 // Manifest loading failed - will fall back to provided version or git SHA 2244 } 2245 2246 // Calculate version with fallback order: 2247 // 1. Plugin manifest version, 2. Marketplace entry version, 3. Git SHA, 4. 'unknown' 2248 const version = await calculatePluginVersion( 2249 pluginId, 2250 entry.source, 2251 pluginManifest, 2252 marketplaceDir, 2253 entry.version, // Marketplace entry version as fallback 2254 ) 2255 2256 // Copy to versioned cache 2257 pluginPath = await copyPluginToVersionedCache( 2258 sourcePluginPath, 2259 pluginId, 2260 version, 2261 entry, 2262 marketplaceDir, 2263 ) 2264 2265 logForDebugging( 2266 `Resolved local plugin ${entry.name} to versioned cache: ${pluginPath}`, 2267 ) 2268 } catch (error) { 2269 // If copy fails, fall back to loading from marketplace directly 2270 const errorMsg = errorMessage(error) 2271 logForDebugging( 2272 `Failed to copy plugin ${entry.name} to versioned cache: ${errorMsg}. Using marketplace path.`, 2273 { level: 'warn' }, 2274 ) 2275 pluginPath = sourcePluginPath 2276 } 2277 } else { 2278 // External source (npm, github, url, pip) - always use versioned cache 2279 try { 2280 // Calculate version with fallback order: 2281 // 1. No manifest yet, 2. installed_plugins.json version, 2282 // 3. Marketplace entry version, 4. source.sha (pinned commits — the 2283 // exact value the post-clone call at cached.gitCommitSha would see), 2284 // 5. 'unknown' → ref-tracked, falls through to clone by design. 2285 const version = await calculatePluginVersion( 2286 pluginId, 2287 entry.source, 2288 undefined, 2289 undefined, 2290 installedVersion ?? entry.version, 2291 'sha' in entry.source ? entry.source.sha : undefined, 2292 ) 2293 2294 const versionedPath = getVersionedCachePath(pluginId, version) 2295 2296 // Check for cached version — ZIP file (zip cache mode) or directory 2297 const zipPath = getVersionedZipCachePath(pluginId, version) 2298 if (isPluginZipCacheEnabled() && (await pathExists(zipPath))) { 2299 logForDebugging( 2300 `Using versioned cached plugin ZIP ${entry.name} from ${zipPath}`, 2301 ) 2302 pluginPath = zipPath 2303 } else if (await pathExists(versionedPath)) { 2304 logForDebugging( 2305 `Using versioned cached plugin ${entry.name} from ${versionedPath}`, 2306 ) 2307 pluginPath = versionedPath 2308 } else { 2309 // Seed cache probe (CCR pre-baked images, read-only). Seed content is 2310 // frozen at image build time — no freshness concern, 'whatever's there' 2311 // is what the image builder put there. Primary cache is NOT probed 2312 // here; ref-tracked sources fall through to clone (the re-clone IS 2313 // the freshness mechanism). If the clone fails, the plugin is simply 2314 // disabled for this session — errorsOut.push below surfaces it. 2315 const seedPath = 2316 (await probeSeedCache(pluginId, version)) ?? 2317 (version === 'unknown' 2318 ? await probeSeedCacheAnyVersion(pluginId) 2319 : null) 2320 if (seedPath) { 2321 pluginPath = seedPath 2322 logForDebugging( 2323 `Using seed cache for external plugin ${entry.name} at ${seedPath}`, 2324 ) 2325 } else { 2326 // Download to temp location, then copy to versioned cache 2327 const cached = await cachePlugin(entry.source, { 2328 manifest: { name: entry.name }, 2329 }) 2330 2331 // If the pre-clone version was deterministic (source.sha / 2332 // entry.version / installedVersion), REUSE it. The post-clone 2333 // recomputation with cached.manifest can return a DIFFERENT value 2334 // — manifest.version (step 1) outranks gitCommitSha (step 3) — 2335 // which would cache at e.g. "2.0.0/" while every warm start 2336 // probes "{sha12}-{hash}/". Mismatched keys = re-clone forever. 2337 // Recomputation is only needed when pre-clone was 'unknown' 2338 // (ref-tracked, no hints) — the clone is the ONLY way to learn. 2339 const actualVersion = 2340 version !== 'unknown' 2341 ? version 2342 : await calculatePluginVersion( 2343 pluginId, 2344 entry.source, 2345 cached.manifest, 2346 cached.path, 2347 installedVersion ?? entry.version, 2348 cached.gitCommitSha, 2349 ) 2350 2351 // Copy to versioned cache 2352 // For external sources, marketplaceDir is not applicable (already downloaded) 2353 pluginPath = await copyPluginToVersionedCache( 2354 cached.path, 2355 pluginId, 2356 actualVersion, 2357 entry, 2358 undefined, 2359 ) 2360 2361 // Clean up temp path 2362 if (cached.path !== pluginPath) { 2363 await rm(cached.path, { recursive: true, force: true }) 2364 } 2365 } 2366 } 2367 } catch (error) { 2368 const errorMsg = errorMessage(error) 2369 logForDebugging(`Failed to cache plugin ${entry.name}: ${errorMsg}`, { 2370 level: 'error', 2371 }) 2372 logError(toError(error)) 2373 errorsOut.push({ 2374 type: 'generic-error', 2375 source: pluginId, 2376 error: `Failed to download/cache plugin ${entry.name}: ${errorMsg}`, 2377 }) 2378 return null 2379 } 2380 } 2381 2382 // Zip cache mode: extract ZIP to session temp dir before loading 2383 if (isPluginZipCacheEnabled() && pluginPath.endsWith('.zip')) { 2384 const sessionDir = await getSessionPluginCachePath() 2385 const extractDir = join( 2386 sessionDir, 2387 pluginId.replace(/[^a-zA-Z0-9@\-_]/g, '-'), 2388 ) 2389 try { 2390 await extractZipToDirectory(pluginPath, extractDir) 2391 logForDebugging(`Extracted plugin ZIP to session dir: ${extractDir}`) 2392 pluginPath = extractDir 2393 } catch (error) { 2394 // Corrupt ZIP: delete it so next install attempt re-creates it 2395 logForDebugging( 2396 `Failed to extract plugin ZIP ${pluginPath}, deleting corrupt file: ${error}`, 2397 ) 2398 await rm(pluginPath, { force: true }).catch(() => {}) 2399 throw error 2400 } 2401 } 2402 2403 return finishLoadingPluginFromPath( 2404 entry, 2405 pluginId, 2406 enabled, 2407 errorsOut, 2408 pluginPath, 2409 ) 2410} 2411 2412/** 2413 * Shared tail of both loadPluginFromMarketplaceEntry variants. 2414 * 2415 * Once pluginPath is resolved (via clone, cache, or installPath lookup), 2416 * the rest of the load — manifest probe, createPluginFromPath, marketplace 2417 * entry supplementation — is identical. Extracted so the cache-only path 2418 * doesn't duplicate ~500 lines. 2419 */ 2420async function finishLoadingPluginFromPath( 2421 entry: PluginMarketplaceEntry, 2422 pluginId: string, 2423 enabled: boolean, 2424 errorsOut: PluginError[], 2425 pluginPath: string, 2426): Promise<LoadedPlugin | null> { 2427 const errors: PluginError[] = [] 2428 2429 // Check if plugin.json exists to determine if we should use marketplace manifest 2430 const manifestPath = join(pluginPath, '.claude-plugin', 'plugin.json') 2431 const hasManifest = await pathExists(manifestPath) 2432 2433 const { plugin, errors: pluginErrors } = await createPluginFromPath( 2434 pluginPath, 2435 pluginId, 2436 enabled, 2437 entry.name, 2438 entry.strict ?? true, // Respect marketplace entry's strict setting 2439 ) 2440 errors.push(...pluginErrors) 2441 2442 // Set sha from source if available (for github and url source types) 2443 if ( 2444 typeof entry.source === 'object' && 2445 'sha' in entry.source && 2446 entry.source.sha 2447 ) { 2448 plugin.sha = entry.source.sha 2449 } 2450 2451 // If there's no plugin.json, use marketplace entry as manifest (regardless of strict mode) 2452 if (!hasManifest) { 2453 plugin.manifest = { 2454 ...entry, 2455 id: undefined, 2456 source: undefined, 2457 strict: undefined, 2458 } as PluginManifest 2459 plugin.name = plugin.manifest.name 2460 2461 // Process commands from marketplace entry 2462 if (entry.commands) { 2463 // Check if it's an object mapping 2464 const firstValue = Object.values(entry.commands)[0] 2465 if ( 2466 typeof entry.commands === 'object' && 2467 !Array.isArray(entry.commands) && 2468 firstValue && 2469 typeof firstValue === 'object' && 2470 ('source' in firstValue || 'content' in firstValue) 2471 ) { 2472 // Object mapping format 2473 const commandsMetadata: Record<string, CommandMetadata> = {} 2474 const validPaths: string[] = [] 2475 2476 // Parallelize pathExists checks; process results in order. 2477 const entries = Object.entries(entry.commands) 2478 const checks = await Promise.all( 2479 entries.map(async ([commandName, metadata]) => { 2480 if (!metadata || typeof metadata !== 'object' || !metadata.source) { 2481 return { commandName, metadata, skip: true as const } 2482 } 2483 const fullPath = join(pluginPath, metadata.source) 2484 return { 2485 commandName, 2486 metadata, 2487 skip: false as const, 2488 fullPath, 2489 exists: await pathExists(fullPath), 2490 } 2491 }), 2492 ) 2493 for (const check of checks) { 2494 if (check.skip) continue 2495 if (check.exists) { 2496 validPaths.push(check.fullPath) 2497 commandsMetadata[check.commandName] = check.metadata 2498 } else { 2499 logForDebugging( 2500 `Command ${check.commandName} path ${check.metadata.source} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, 2501 { level: 'warn' }, 2502 ) 2503 logError( 2504 new Error( 2505 `Plugin component file not found: ${check.fullPath} for ${entry.name}`, 2506 ), 2507 ) 2508 errors.push({ 2509 type: 'path-not-found', 2510 source: pluginId, 2511 plugin: entry.name, 2512 path: check.fullPath, 2513 component: 'commands', 2514 }) 2515 } 2516 } 2517 2518 if (validPaths.length > 0) { 2519 plugin.commandsPaths = validPaths 2520 plugin.commandsMetadata = commandsMetadata 2521 } 2522 } else { 2523 // Path or array of paths format 2524 const commandPaths = Array.isArray(entry.commands) 2525 ? entry.commands 2526 : [entry.commands] 2527 2528 // Parallelize pathExists checks; process results in order. 2529 const checks = await Promise.all( 2530 commandPaths.map(async cmdPath => { 2531 if (typeof cmdPath !== 'string') { 2532 return { cmdPath, kind: 'invalid' as const } 2533 } 2534 const fullPath = join(pluginPath, cmdPath) 2535 return { 2536 cmdPath, 2537 kind: 'path' as const, 2538 fullPath, 2539 exists: await pathExists(fullPath), 2540 } 2541 }), 2542 ) 2543 const validPaths: string[] = [] 2544 for (const check of checks) { 2545 if (check.kind === 'invalid') { 2546 logForDebugging( 2547 `Unexpected command format in marketplace entry for ${entry.name}`, 2548 { level: 'error' }, 2549 ) 2550 continue 2551 } 2552 if (check.exists) { 2553 validPaths.push(check.fullPath) 2554 } else { 2555 logForDebugging( 2556 `Command path ${check.cmdPath} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, 2557 { level: 'warn' }, 2558 ) 2559 logError( 2560 new Error( 2561 `Plugin component file not found: ${check.fullPath} for ${entry.name}`, 2562 ), 2563 ) 2564 errors.push({ 2565 type: 'path-not-found', 2566 source: pluginId, 2567 plugin: entry.name, 2568 path: check.fullPath, 2569 component: 'commands', 2570 }) 2571 } 2572 } 2573 2574 if (validPaths.length > 0) { 2575 plugin.commandsPaths = validPaths 2576 } 2577 } 2578 } 2579 2580 // Process agents from marketplace entry 2581 if (entry.agents) { 2582 const agentPaths = Array.isArray(entry.agents) 2583 ? entry.agents 2584 : [entry.agents] 2585 2586 const validPaths = await validatePluginPaths( 2587 agentPaths, 2588 pluginPath, 2589 entry.name, 2590 pluginId, 2591 'agents', 2592 'Agent', 2593 'from marketplace entry', 2594 errors, 2595 ) 2596 2597 if (validPaths.length > 0) { 2598 plugin.agentsPaths = validPaths 2599 } 2600 } 2601 2602 // Process skills from marketplace entry 2603 if (entry.skills) { 2604 logForDebugging( 2605 `Processing ${Array.isArray(entry.skills) ? entry.skills.length : 1} skill paths for plugin ${entry.name}`, 2606 ) 2607 const skillPaths = Array.isArray(entry.skills) 2608 ? entry.skills 2609 : [entry.skills] 2610 2611 // Parallelize pathExists checks; process results in order. 2612 // Note: previously this loop called pathExists() TWICE per iteration 2613 // (once in a debug log template, once in the if) — now called once. 2614 const checks = await Promise.all( 2615 skillPaths.map(async skillPath => { 2616 const fullPath = join(pluginPath, skillPath) 2617 return { skillPath, fullPath, exists: await pathExists(fullPath) } 2618 }), 2619 ) 2620 const validPaths: string[] = [] 2621 for (const { skillPath, fullPath, exists } of checks) { 2622 logForDebugging( 2623 `Checking skill path: ${skillPath} -> ${fullPath} (exists: ${exists})`, 2624 ) 2625 if (exists) { 2626 validPaths.push(fullPath) 2627 } else { 2628 logForDebugging( 2629 `Skill path ${skillPath} from marketplace entry not found at ${fullPath} for ${entry.name}`, 2630 { level: 'warn' }, 2631 ) 2632 logError( 2633 new Error( 2634 `Plugin component file not found: ${fullPath} for ${entry.name}`, 2635 ), 2636 ) 2637 errors.push({ 2638 type: 'path-not-found', 2639 source: pluginId, 2640 plugin: entry.name, 2641 path: fullPath, 2642 component: 'skills', 2643 }) 2644 } 2645 } 2646 2647 logForDebugging( 2648 `Found ${validPaths.length} valid skill paths for plugin ${entry.name}, setting skillsPaths`, 2649 ) 2650 if (validPaths.length > 0) { 2651 plugin.skillsPaths = validPaths 2652 } 2653 } else { 2654 logForDebugging(`Plugin ${entry.name} has no entry.skills defined`) 2655 } 2656 2657 // Process output styles from marketplace entry 2658 if (entry.outputStyles) { 2659 const outputStylePaths = Array.isArray(entry.outputStyles) 2660 ? entry.outputStyles 2661 : [entry.outputStyles] 2662 2663 const validPaths = await validatePluginPaths( 2664 outputStylePaths, 2665 pluginPath, 2666 entry.name, 2667 pluginId, 2668 'output-styles', 2669 'Output style', 2670 'from marketplace entry', 2671 errors, 2672 ) 2673 2674 if (validPaths.length > 0) { 2675 plugin.outputStylesPaths = validPaths 2676 } 2677 } 2678 2679 // Process inline hooks from marketplace entry 2680 if (entry.hooks) { 2681 plugin.hooksConfig = entry.hooks as HooksSettings 2682 } 2683 } else if ( 2684 !entry.strict && 2685 hasManifest && 2686 (entry.commands || 2687 entry.agents || 2688 entry.skills || 2689 entry.hooks || 2690 entry.outputStyles) 2691 ) { 2692 // In non-strict mode with plugin.json, marketplace entries for commands/agents/skills/hooks/outputStyles are conflicts 2693 const error = new Error( 2694 `Plugin ${entry.name} has both plugin.json and marketplace manifest entries for commands/agents/skills/hooks/outputStyles. This is a conflict.`, 2695 ) 2696 logForDebugging( 2697 `Plugin ${entry.name} has both plugin.json and marketplace manifest entries for commands/agents/skills/hooks/outputStyles. This is a conflict.`, 2698 { level: 'error' }, 2699 ) 2700 logError(error) 2701 errorsOut.push({ 2702 type: 'generic-error', 2703 source: pluginId, 2704 error: `Plugin ${entry.name} has conflicting manifests: both plugin.json and marketplace entry specify components. Set strict: true in marketplace entry or remove component specs from one location.`, 2705 }) 2706 return null 2707 } else if (hasManifest) { 2708 // Has plugin.json - marketplace can supplement commands/agents/skills/hooks/outputStyles 2709 2710 // Supplement commands from marketplace entry 2711 if (entry.commands) { 2712 // Check if it's an object mapping 2713 const firstValue = Object.values(entry.commands)[0] 2714 if ( 2715 typeof entry.commands === 'object' && 2716 !Array.isArray(entry.commands) && 2717 firstValue && 2718 typeof firstValue === 'object' && 2719 ('source' in firstValue || 'content' in firstValue) 2720 ) { 2721 // Object mapping format - merge metadata 2722 const commandsMetadata: Record<string, CommandMetadata> = { 2723 ...(plugin.commandsMetadata || {}), 2724 } 2725 const validPaths: string[] = [] 2726 2727 // Parallelize pathExists checks; process results in order. 2728 const entries = Object.entries(entry.commands) 2729 const checks = await Promise.all( 2730 entries.map(async ([commandName, metadata]) => { 2731 if (!metadata || typeof metadata !== 'object' || !metadata.source) { 2732 return { commandName, metadata, skip: true as const } 2733 } 2734 const fullPath = join(pluginPath, metadata.source) 2735 return { 2736 commandName, 2737 metadata, 2738 skip: false as const, 2739 fullPath, 2740 exists: await pathExists(fullPath), 2741 } 2742 }), 2743 ) 2744 for (const check of checks) { 2745 if (check.skip) continue 2746 if (check.exists) { 2747 validPaths.push(check.fullPath) 2748 commandsMetadata[check.commandName] = check.metadata 2749 } else { 2750 logForDebugging( 2751 `Command ${check.commandName} path ${check.metadata.source} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, 2752 { level: 'warn' }, 2753 ) 2754 logError( 2755 new Error( 2756 `Plugin component file not found: ${check.fullPath} for ${entry.name}`, 2757 ), 2758 ) 2759 errors.push({ 2760 type: 'path-not-found', 2761 source: pluginId, 2762 plugin: entry.name, 2763 path: check.fullPath, 2764 component: 'commands', 2765 }) 2766 } 2767 } 2768 2769 if (validPaths.length > 0) { 2770 plugin.commandsPaths = [ 2771 ...(plugin.commandsPaths || []), 2772 ...validPaths, 2773 ] 2774 plugin.commandsMetadata = commandsMetadata 2775 } 2776 } else { 2777 // Path or array of paths format 2778 const commandPaths = Array.isArray(entry.commands) 2779 ? entry.commands 2780 : [entry.commands] 2781 2782 // Parallelize pathExists checks; process results in order. 2783 const checks = await Promise.all( 2784 commandPaths.map(async cmdPath => { 2785 if (typeof cmdPath !== 'string') { 2786 return { cmdPath, kind: 'invalid' as const } 2787 } 2788 const fullPath = join(pluginPath, cmdPath) 2789 return { 2790 cmdPath, 2791 kind: 'path' as const, 2792 fullPath, 2793 exists: await pathExists(fullPath), 2794 } 2795 }), 2796 ) 2797 const validPaths: string[] = [] 2798 for (const check of checks) { 2799 if (check.kind === 'invalid') { 2800 logForDebugging( 2801 `Unexpected command format in marketplace entry for ${entry.name}`, 2802 { level: 'error' }, 2803 ) 2804 continue 2805 } 2806 if (check.exists) { 2807 validPaths.push(check.fullPath) 2808 } else { 2809 logForDebugging( 2810 `Command path ${check.cmdPath} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, 2811 { level: 'warn' }, 2812 ) 2813 logError( 2814 new Error( 2815 `Plugin component file not found: ${check.fullPath} for ${entry.name}`, 2816 ), 2817 ) 2818 errors.push({ 2819 type: 'path-not-found', 2820 source: pluginId, 2821 plugin: entry.name, 2822 path: check.fullPath, 2823 component: 'commands', 2824 }) 2825 } 2826 } 2827 2828 if (validPaths.length > 0) { 2829 plugin.commandsPaths = [ 2830 ...(plugin.commandsPaths || []), 2831 ...validPaths, 2832 ] 2833 } 2834 } 2835 } 2836 2837 // Supplement agents from marketplace entry 2838 if (entry.agents) { 2839 const agentPaths = Array.isArray(entry.agents) 2840 ? entry.agents 2841 : [entry.agents] 2842 2843 const validPaths = await validatePluginPaths( 2844 agentPaths, 2845 pluginPath, 2846 entry.name, 2847 pluginId, 2848 'agents', 2849 'Agent', 2850 'from marketplace entry', 2851 errors, 2852 ) 2853 2854 if (validPaths.length > 0) { 2855 plugin.agentsPaths = [...(plugin.agentsPaths || []), ...validPaths] 2856 } 2857 } 2858 2859 // Supplement skills from marketplace entry 2860 if (entry.skills) { 2861 const skillPaths = Array.isArray(entry.skills) 2862 ? entry.skills 2863 : [entry.skills] 2864 2865 const validPaths = await validatePluginPaths( 2866 skillPaths, 2867 pluginPath, 2868 entry.name, 2869 pluginId, 2870 'skills', 2871 'Skill', 2872 'from marketplace entry', 2873 errors, 2874 ) 2875 2876 if (validPaths.length > 0) { 2877 plugin.skillsPaths = [...(plugin.skillsPaths || []), ...validPaths] 2878 } 2879 } 2880 2881 // Supplement output styles from marketplace entry 2882 if (entry.outputStyles) { 2883 const outputStylePaths = Array.isArray(entry.outputStyles) 2884 ? entry.outputStyles 2885 : [entry.outputStyles] 2886 2887 const validPaths = await validatePluginPaths( 2888 outputStylePaths, 2889 pluginPath, 2890 entry.name, 2891 pluginId, 2892 'output-styles', 2893 'Output style', 2894 'from marketplace entry', 2895 errors, 2896 ) 2897 2898 if (validPaths.length > 0) { 2899 plugin.outputStylesPaths = [ 2900 ...(plugin.outputStylesPaths || []), 2901 ...validPaths, 2902 ] 2903 } 2904 } 2905 2906 // Supplement hooks from marketplace entry 2907 if (entry.hooks) { 2908 plugin.hooksConfig = { 2909 ...(plugin.hooksConfig || {}), 2910 ...(entry.hooks as HooksSettings), 2911 } 2912 } 2913 } 2914 2915 errorsOut.push(...errors) 2916 return plugin 2917} 2918 2919/** 2920 * Load session-only plugins from --plugin-dir CLI flag. 2921 * 2922 * These plugins are loaded directly without going through the marketplace system. 2923 * They appear with source='plugin-name@inline' and are always enabled for the current session. 2924 * 2925 * @param sessionPluginPaths - Array of plugin directory paths from CLI 2926 * @returns LoadedPlugin objects and any errors encountered 2927 */ 2928async function loadSessionOnlyPlugins( 2929 sessionPluginPaths: Array<string>, 2930): Promise<{ plugins: LoadedPlugin[]; errors: PluginError[] }> { 2931 if (sessionPluginPaths.length === 0) { 2932 return { plugins: [], errors: [] } 2933 } 2934 2935 const plugins: LoadedPlugin[] = [] 2936 const errors: PluginError[] = [] 2937 2938 for (const [index, pluginPath] of sessionPluginPaths.entries()) { 2939 try { 2940 const resolvedPath = resolve(pluginPath) 2941 2942 if (!(await pathExists(resolvedPath))) { 2943 logForDebugging( 2944 `Plugin path does not exist: ${resolvedPath}, skipping`, 2945 { level: 'warn' }, 2946 ) 2947 errors.push({ 2948 type: 'path-not-found', 2949 source: `inline[${index}]`, 2950 path: resolvedPath, 2951 component: 'commands', 2952 }) 2953 continue 2954 } 2955 2956 const dirName = basename(resolvedPath) 2957 const { plugin, errors: pluginErrors } = await createPluginFromPath( 2958 resolvedPath, 2959 `${dirName}@inline`, // temporary, will be updated after we know the real name 2960 true, // always enabled 2961 dirName, 2962 ) 2963 2964 // Update source to use the actual plugin name from manifest 2965 plugin.source = `${plugin.name}@inline` 2966 plugin.repository = `${plugin.name}@inline` 2967 2968 plugins.push(plugin) 2969 errors.push(...pluginErrors) 2970 2971 logForDebugging(`Loaded inline plugin from path: ${plugin.name}`) 2972 } catch (error) { 2973 const errorMsg = errorMessage(error) 2974 logForDebugging( 2975 `Failed to load session plugin from ${pluginPath}: ${errorMsg}`, 2976 { level: 'warn' }, 2977 ) 2978 errors.push({ 2979 type: 'generic-error', 2980 source: `inline[${index}]`, 2981 error: `Failed to load plugin: ${errorMsg}`, 2982 }) 2983 } 2984 } 2985 2986 if (plugins.length > 0) { 2987 logForDebugging( 2988 `Loaded ${plugins.length} session-only plugins from --plugin-dir`, 2989 ) 2990 } 2991 2992 return { plugins, errors } 2993} 2994 2995/** 2996 * Merge plugins from session (--plugin-dir), marketplace (installed), and 2997 * builtin sources. Session plugins override marketplace plugins with the 2998 * same name — the user explicitly pointed at a directory for this session. 2999 * 3000 * Exception: marketplace plugins locked by managed settings (policySettings) 3001 * cannot be overridden. Enterprise admin intent beats local dev convenience. 3002 * When a session plugin collides with a managed one, the session copy is 3003 * dropped and an error is returned for surfacing. 3004 * 3005 * Without this dedup, both versions sat in the array and marketplace won 3006 * on first-match, making --plugin-dir useless for iterating on an 3007 * installed plugin. 3008 */ 3009export function mergePluginSources(sources: { 3010 session: LoadedPlugin[] 3011 marketplace: LoadedPlugin[] 3012 builtin: LoadedPlugin[] 3013 managedNames?: Set<string> | null 3014}): { plugins: LoadedPlugin[]; errors: PluginError[] } { 3015 const errors: PluginError[] = [] 3016 const managed = sources.managedNames 3017 3018 // Managed settings win over --plugin-dir. Drop session plugins whose 3019 // name appears in policySettings.enabledPlugins (whether force-enabled 3020 // OR force-disabled — both are admin intent that --plugin-dir must not 3021 // bypass). Surface an error so the user knows why their dev copy was 3022 // ignored. 3023 // 3024 // NOTE: managedNames contains the pluginId prefix (entry.name), which is 3025 // expected to equal manifest.name by convention (schema description at 3026 // schemas.ts PluginMarketplaceEntry.name). If a marketplace publishes a 3027 // plugin where entry.name ≠ manifest.name, this guard will silently miss — 3028 // but that's a marketplace misconfiguration that breaks other things too 3029 // (e.g., ManagePlugins constructs pluginIds from manifest.name). 3030 const sessionPlugins = sources.session.filter(p => { 3031 if (managed?.has(p.name)) { 3032 logForDebugging( 3033 `Plugin "${p.name}" from --plugin-dir is blocked by managed settings`, 3034 { level: 'warn' }, 3035 ) 3036 errors.push({ 3037 type: 'generic-error', 3038 source: p.source, 3039 plugin: p.name, 3040 error: `--plugin-dir copy of "${p.name}" ignored: plugin is locked by managed settings`, 3041 }) 3042 return false 3043 } 3044 return true 3045 }) 3046 3047 const sessionNames = new Set(sessionPlugins.map(p => p.name)) 3048 const marketplacePlugins = sources.marketplace.filter(p => { 3049 if (sessionNames.has(p.name)) { 3050 logForDebugging( 3051 `Plugin "${p.name}" from --plugin-dir overrides installed version`, 3052 ) 3053 return false 3054 } 3055 return true 3056 }) 3057 // Session first, then non-overridden marketplace, then builtin. 3058 // Downstream first-match consumers see session plugins before 3059 // installed ones for any that slipped past the name filter. 3060 return { 3061 plugins: [...sessionPlugins, ...marketplacePlugins, ...sources.builtin], 3062 errors, 3063 } 3064} 3065 3066/** 3067 * Main plugin loading function that discovers and loads all plugins. 3068 * 3069 * This function is memoized to avoid repeated filesystem scanning and is 3070 * the primary entry point for the plugin system. It discovers plugins from 3071 * multiple sources and returns categorized results. 3072 * 3073 * Loading order and precedence (see mergePluginSources): 3074 * 1. Session-only plugins (from --plugin-dir CLI flag) — override 3075 * installed plugins with the same name, UNLESS that plugin is 3076 * locked by managed settings (policySettings, either force-enabled 3077 * or force-disabled) 3078 * 2. Marketplace-based plugins (plugin@marketplace format from settings) 3079 * 3. Built-in plugins shipped with the CLI 3080 * 3081 * Name collision: session plugin wins over installed. The user explicitly 3082 * pointed at a directory for this session — that intent beats whatever 3083 * is installed. Exception: managed settings (enterprise policy) win over 3084 * --plugin-dir. Admin intent beats local dev convenience. 3085 * 3086 * Error collection: 3087 * - Non-fatal errors are collected and returned 3088 * - System continues loading other plugins on errors 3089 * - Errors include source information for debugging 3090 * 3091 * @returns Promise resolving to categorized plugin results: 3092 * - enabled: Array of enabled LoadedPlugin objects 3093 * - disabled: Array of disabled LoadedPlugin objects 3094 * - errors: Array of loading errors with source information 3095 */ 3096export const loadAllPlugins = memoize(async (): Promise<PluginLoadResult> => { 3097 const result = await assemblePluginLoadResult(() => 3098 loadPluginsFromMarketplaces({ cacheOnly: false }), 3099 ) 3100 // A fresh full-load result is strictly valid for cache-only callers 3101 // (both variants share assemblePluginLoadResult). Warm the separate 3102 // memoize so refreshActivePlugins()'s downstream getPluginCommands() / 3103 // getAgentDefinitionsWithOverrides() — which now call 3104 // loadAllPluginsCacheOnly — see just-cloned plugins instead of reading 3105 // an installed_plugins.json that nothing writes mid-session. 3106 loadAllPluginsCacheOnly.cache?.set(undefined, Promise.resolve(result)) 3107 return result 3108}) 3109 3110/** 3111 * Cache-only variant of loadAllPlugins. 3112 * 3113 * Same merge/dependency/settings logic, but the marketplace loader never 3114 * hits the network (no cachePlugin, no copyPluginToVersionedCache). Reads 3115 * from installed_plugins.json's installPath. Plugins not on disk emit 3116 * 'plugin-cache-miss' and are skipped. 3117 * 3118 * Use this in startup consumers (getCommands, loadPluginAgents, MCP/LSP 3119 * config) so interactive startup never blocks on git clones for ref-tracked 3120 * plugins. Use loadAllPlugins() in explicit refresh paths (/plugins, 3121 * refresh.ts, headlessPluginInstall) where fresh source is the intent. 3122 * 3123 * CLAUDE_CODE_SYNC_PLUGIN_INSTALL=1 delegates to the full loader — that 3124 * mode explicitly opts into blocking install before first query, and 3125 * main.tsx's getClaudeCodeMcpConfigs()/getInitialSettings().agent run 3126 * BEFORE runHeadless() can warm this cache. First-run CCR/headless has 3127 * no installed_plugins.json, so cache-only would miss plugin MCP servers 3128 * and plugin settings (the agent key). The interactive startup win is 3129 * preserved since interactive mode doesn't set SYNC_PLUGIN_INSTALL. 3130 * 3131 * Separate memoize cache from loadAllPlugins — a cache-only result must 3132 * never satisfy a caller that wants fresh source. The reverse IS valid: 3133 * loadAllPlugins warms this cache on completion so refresh paths that run 3134 * the full loader don't get plugin-cache-miss from their downstream 3135 * cache-only consumers. 3136 */ 3137export const loadAllPluginsCacheOnly = memoize( 3138 async (): Promise<PluginLoadResult> => { 3139 if (isEnvTruthy(process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL)) { 3140 return loadAllPlugins() 3141 } 3142 return assemblePluginLoadResult(() => 3143 loadPluginsFromMarketplaces({ cacheOnly: true }), 3144 ) 3145 }, 3146) 3147 3148/** 3149 * Shared body of loadAllPlugins and loadAllPluginsCacheOnly. 3150 * 3151 * The only difference between the two is which marketplace loader runs — 3152 * session plugins, builtins, merge, verifyAndDemote, and cachePluginSettings 3153 * are identical (invariants 1-3). 3154 */ 3155async function assemblePluginLoadResult( 3156 marketplaceLoader: () => Promise<{ 3157 plugins: LoadedPlugin[] 3158 errors: PluginError[] 3159 }>, 3160): Promise<PluginLoadResult> { 3161 // Load marketplace plugins and session-only plugins in parallel. 3162 // getInlinePlugins() is a synchronous state read with no dependency on 3163 // marketplace loading, so these two sources can be fetched concurrently. 3164 const inlinePlugins = getInlinePlugins() 3165 const [marketplaceResult, sessionResult] = await Promise.all([ 3166 marketplaceLoader(), 3167 inlinePlugins.length > 0 3168 ? loadSessionOnlyPlugins(inlinePlugins) 3169 : Promise.resolve({ plugins: [], errors: [] }), 3170 ]) 3171 // 3. Load built-in plugins that ship with the CLI 3172 const builtinResult = getBuiltinPlugins() 3173 3174 // Session plugins (--plugin-dir) override installed ones by name, 3175 // UNLESS the installed plugin is locked by managed settings 3176 // (policySettings). See mergePluginSources() for details. 3177 const { plugins: allPlugins, errors: mergeErrors } = mergePluginSources({ 3178 session: sessionResult.plugins, 3179 marketplace: marketplaceResult.plugins, 3180 builtin: [...builtinResult.enabled, ...builtinResult.disabled], 3181 managedNames: getManagedPluginNames(), 3182 }) 3183 const allErrors = [ 3184 ...marketplaceResult.errors, 3185 ...sessionResult.errors, 3186 ...mergeErrors, 3187 ] 3188 3189 // Verify dependencies. Runs AFTER the parallel load — deps are presence 3190 // checks, not load-order, so no topological sort needed. Demotion is 3191 // session-local: does NOT write settings (user fixes intent via /doctor). 3192 const { demoted, errors: depErrors } = verifyAndDemote(allPlugins) 3193 for (const p of allPlugins) { 3194 if (demoted.has(p.source)) p.enabled = false 3195 } 3196 allErrors.push(...depErrors) 3197 3198 const enabledPlugins = allPlugins.filter(p => p.enabled) 3199 logForDebugging( 3200 `Found ${allPlugins.length} plugins (${enabledPlugins.length} enabled, ${allPlugins.length - enabledPlugins.length} disabled)`, 3201 ) 3202 3203 // 3. Cache plugin settings for synchronous access by the settings cascade 3204 cachePluginSettings(enabledPlugins) 3205 3206 return { 3207 enabled: enabledPlugins, 3208 disabled: allPlugins.filter(p => !p.enabled), 3209 errors: allErrors, 3210 } 3211} 3212 3213/** 3214 * Clears the memoized plugin cache. 3215 * 3216 * Call this when plugins are installed, removed, or settings change 3217 * to force a fresh scan on the next loadAllPlugins call. 3218 * 3219 * Use cases: 3220 * - After installing/uninstalling plugins 3221 * - After modifying .claude-plugin/ directory (for export) 3222 * - After changing enabledPlugins settings 3223 * - When debugging plugin loading issues 3224 */ 3225export function clearPluginCache(reason?: string): void { 3226 if (reason) { 3227 logForDebugging( 3228 `clearPluginCache: invalidating loadAllPlugins cache (${reason})`, 3229 ) 3230 } 3231 loadAllPlugins.cache?.clear?.() 3232 loadAllPluginsCacheOnly.cache?.clear?.() 3233 // If a plugin previously contributed settings, the session settings cache 3234 // holds a merged result that includes them. cachePluginSettings() on reload 3235 // won't bust the cache when the new base is empty (the startup perf win), 3236 // so bust it here to drop stale plugin overrides. When the base is already 3237 // undefined (startup, or no prior plugin settings) this is a no-op. 3238 if (getPluginSettingsBase() !== undefined) { 3239 resetSettingsCache() 3240 } 3241 clearPluginSettingsBase() 3242 // TODO: Clear installed plugins cache when installedPluginsManager is implemented 3243} 3244 3245/** 3246 * Merge settings from all enabled plugins into a single record. 3247 * Later plugins override earlier ones for the same key. 3248 * Only allowlisted keys are included (filtering happens at load time). 3249 */ 3250function mergePluginSettings( 3251 plugins: LoadedPlugin[], 3252): Record<string, unknown> | undefined { 3253 let merged: Record<string, unknown> | undefined 3254 3255 for (const plugin of plugins) { 3256 if (!plugin.settings) { 3257 continue 3258 } 3259 3260 if (!merged) { 3261 merged = {} 3262 } 3263 3264 for (const [key, value] of Object.entries(plugin.settings)) { 3265 if (key in merged) { 3266 logForDebugging( 3267 `Plugin "${plugin.name}" overrides setting "${key}" (previously set by another plugin)`, 3268 ) 3269 } 3270 merged[key] = value 3271 } 3272 } 3273 3274 return merged 3275} 3276 3277/** 3278 * Store merged plugin settings in the synchronous cache. 3279 * Called after loadAllPlugins resolves. 3280 */ 3281export function cachePluginSettings(plugins: LoadedPlugin[]): void { 3282 const settings = mergePluginSettings(plugins) 3283 setPluginSettingsBase(settings) 3284 // Only bust the session settings cache if there are actually plugin settings 3285 // to merge. In the common case (no plugins, or plugins without settings) the 3286 // base layer is empty and loadSettingsFromDisk would produce the same result 3287 // anyway — resetting here would waste ~17ms on startup re-reading and 3288 // re-validating every settings file on the next getSettingsWithErrors() call. 3289 if (settings && Object.keys(settings).length > 0) { 3290 resetSettingsCache() 3291 logForDebugging( 3292 `Cached plugin settings with keys: ${Object.keys(settings).join(', ')}`, 3293 ) 3294 } 3295} 3296 3297/** 3298 * Type predicate: check if a value is a non-null, non-array object (i.e., a record). 3299 */ 3300function isRecord(value: unknown): value is Record<string, unknown> { 3301 return typeof value === 'object' && value !== null && !Array.isArray(value) 3302}