/ utils / plugins / pluginLoader.ts
pluginLoader.ts
   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  
  35  import {
  36    copyFile,
  37    readdir,
  38    readFile,
  39    readlink,
  40    realpath,
  41    rename,
  42    rm,
  43    rmdir,
  44    stat,
  45    symlink,
  46  } from 'fs/promises'
  47  import memoize from 'lodash-es/memoize.js'
  48  import { basename, dirname, join, relative, resolve, sep } from 'path'
  49  import { getInlinePlugins } from '../../bootstrap/state.js'
  50  import {
  51    BUILTIN_MARKETPLACE_NAME,
  52    getBuiltinPlugins,
  53  } from '../../plugins/builtinPlugins.js'
  54  import type {
  55    LoadedPlugin,
  56    PluginComponent,
  57    PluginError,
  58    PluginLoadResult,
  59    PluginManifest,
  60  } from '../../types/plugin.js'
  61  import { logForDebugging } from '../debug.js'
  62  import { isEnvTruthy } from '../envUtils.js'
  63  import {
  64    errorMessage,
  65    getErrnoPath,
  66    isENOENT,
  67    isFsInaccessible,
  68    toError,
  69  } from '../errors.js'
  70  import { execFileNoThrow, execFileNoThrowWithCwd } from '../execFileNoThrow.js'
  71  import { pathExists } from '../file.js'
  72  import { getFsImplementation } from '../fsOperations.js'
  73  import { gitExe } from '../git.js'
  74  import { lazySchema } from '../lazySchema.js'
  75  import { logError } from '../log.js'
  76  import { getSettings_DEPRECATED } from '../settings/settings.js'
  77  import {
  78    clearPluginSettingsBase,
  79    getPluginSettingsBase,
  80    resetSettingsCache,
  81    setPluginSettingsBase,
  82  } from '../settings/settingsCache.js'
  83  import type { HooksSettings } from '../settings/types.js'
  84  import { SettingsSchema } from '../settings/types.js'
  85  import { jsonParse, jsonStringify } from '../slowOperations.js'
  86  import { getAddDirEnabledPlugins } from './addDirPluginSettings.js'
  87  import { verifyAndDemote } from './dependencyResolver.js'
  88  import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
  89  import { checkGitAvailable } from './gitAvailability.js'
  90  import { getInMemoryInstalledPlugins } from './installedPluginsManager.js'
  91  import { getManagedPluginNames } from './managedPlugins.js'
  92  import {
  93    formatSourceForDisplay,
  94    getBlockedMarketplaces,
  95    getStrictKnownMarketplaces,
  96    isSourceAllowedByPolicy,
  97    isSourceInBlocklist,
  98  } from './marketplaceHelpers.js'
  99  import {
 100    getMarketplaceCacheOnly,
 101    getPluginByIdCacheOnly,
 102    loadKnownMarketplacesConfigSafe,
 103  } from './marketplaceManager.js'
 104  import { getPluginSeedDirs, getPluginsDirectory } from './pluginDirectories.js'
 105  import { parsePluginIdentifier } from './pluginIdentifier.js'
 106  import { validatePathWithinBase } from './pluginInstallationHelpers.js'
 107  import { calculatePluginVersion } from './pluginVersioning.js'
 108  import {
 109    type CommandMetadata,
 110    PluginHooksSchema,
 111    PluginIdSchema,
 112    PluginManifestSchema,
 113    type PluginMarketplaceEntry,
 114    type PluginSource,
 115  } from './schemas.js'
 116  import {
 117    convertDirectoryToZipInPlace,
 118    extractZipToDirectory,
 119    getSessionPluginCachePath,
 120    isPluginZipCacheEnabled,
 121  } from './zipCache.js'
 122  
 123  /**
 124   * Get the path where plugin cache is stored
 125   */
 126  export 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   */
 139  export 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   */
 172  export 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   */
 183  export 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   */
 195  async 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   */
 220  export 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   */
 249  export 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   */
 266  export 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   */
 293  export 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   */
 365  export 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   */
 470  function 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   */
 492  export 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   */
 534  export 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   */
 645  async 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   */
 662  async 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   */
 686  function 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   */
 718  export 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   */
 856  async 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   */
 873  export 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   */
 911  export 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   */
1147  export 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   */
1224  async 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   */
1265  async 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   */
1348  export 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   */
1776  const 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   */
1788  function 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   */
1807  async 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   */
1854  function 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   */
1888  async 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   */
2098  async 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   */
2191  async 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   */
2420  async 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   */
2928  async 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   */
3009  export 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   */
3096  export 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   */
3137  export 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   */
3155  async 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   */
3225  export 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   */
3250  function 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   */
3281  export 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   */
3300  function isRecord(value: unknown): value is Record<string, unknown> {
3301    return typeof value === 'object' && value !== null && !Array.isArray(value)
3302  }