/ utils / plugins / installedPluginsManager.ts
installedPluginsManager.ts
   1  /**
   2   * Manages plugin installation metadata stored in installed_plugins.json
   3   *
   4   * This module separates plugin installation state (global) from enabled/disabled
   5   * state (per-repository). The installed_plugins.json file tracks:
   6   * - Which plugins are installed globally
   7   * - Installation metadata (version, timestamps, paths)
   8   *
   9   * The enabled/disabled state remains in .claude/settings.json for per-repo control.
  10   *
  11   * Rationale: Installation is global (a plugin is either on disk or not), while
  12   * enabled/disabled state is per-repository (different projects may want different
  13   * plugins active).
  14   */
  15  
  16  import { dirname, join } from 'path'
  17  import { logForDebugging } from '../debug.js'
  18  import { errorMessage, isENOENT, toError } from '../errors.js'
  19  import { getFsImplementation } from '../fsOperations.js'
  20  import { logError } from '../log.js'
  21  import {
  22    jsonParse,
  23    jsonStringify,
  24    writeFileSync_DEPRECATED,
  25  } from '../slowOperations.js'
  26  import { getPluginsDirectory } from './pluginDirectories.js'
  27  import {
  28    type InstalledPlugin,
  29    InstalledPluginsFileSchemaV1,
  30    InstalledPluginsFileSchemaV2,
  31    type InstalledPluginsFileV1,
  32    type InstalledPluginsFileV2,
  33    type PluginInstallationEntry,
  34    type PluginScope,
  35  } from './schemas.js'
  36  
  37  // Type alias for V2 plugins map
  38  type InstalledPluginsMapV2 = Record<string, PluginInstallationEntry[]>
  39  
  40  // Type for persistable scopes (excludes 'flag' which is session-only)
  41  export type PersistableScope = Exclude<PluginScope, never> // All scopes are persistable in the schema
  42  
  43  import { getOriginalCwd } from '../../bootstrap/state.js'
  44  import { getCwd } from '../cwd.js'
  45  import { getHeadForDir } from '../git/gitFilesystem.js'
  46  import type { EditableSettingSource } from '../settings/constants.js'
  47  import {
  48    getSettings_DEPRECATED,
  49    getSettingsForSource,
  50  } from '../settings/settings.js'
  51  import { getPluginById } from './marketplaceManager.js'
  52  import {
  53    parsePluginIdentifier,
  54    settingSourceToScope,
  55  } from './pluginIdentifier.js'
  56  import { getPluginCachePath, getVersionedCachePath } from './pluginLoader.js'
  57  
  58  // Migration state to prevent running migration multiple times per session
  59  let migrationCompleted = false
  60  
  61  /**
  62   * Memoized cache of installed plugins data (V2 format)
  63   * Cleared by clearInstalledPluginsCache() when file is modified.
  64   * Prevents repeated filesystem reads within a single CLI session.
  65   */
  66  let installedPluginsCacheV2: InstalledPluginsFileV2 | null = null
  67  
  68  /**
  69   * Session-level snapshot of installed plugins at startup.
  70   * This is what the running session uses - it's NOT updated by background operations.
  71   * Background updates modify the disk file only.
  72   */
  73  let inMemoryInstalledPlugins: InstalledPluginsFileV2 | null = null
  74  
  75  /**
  76   * Get the path to the installed_plugins.json file
  77   */
  78  export function getInstalledPluginsFilePath(): string {
  79    return join(getPluginsDirectory(), 'installed_plugins.json')
  80  }
  81  
  82  /**
  83   * Get the path to the legacy installed_plugins_v2.json file.
  84   * Used only during migration to consolidate into single file.
  85   */
  86  export function getInstalledPluginsV2FilePath(): string {
  87    return join(getPluginsDirectory(), 'installed_plugins_v2.json')
  88  }
  89  
  90  /**
  91   * Clear the installed plugins cache
  92   * Call this when the file is modified to force a reload
  93   *
  94   * Note: This also clears the in-memory session state (inMemoryInstalledPlugins).
  95   * In most cases, this is only called during initialization or testing.
  96   * For background updates, use updateInstallationPathOnDisk() which preserves
  97   * the in-memory state.
  98   */
  99  export function clearInstalledPluginsCache(): void {
 100    installedPluginsCacheV2 = null
 101    inMemoryInstalledPlugins = null
 102    logForDebugging('Cleared installed plugins cache')
 103  }
 104  
 105  /**
 106   * Migrate to single plugin file format.
 107   *
 108   * This consolidates the V1/V2 dual-file system into a single file:
 109   * 1. If installed_plugins_v2.json exists: copy to installed_plugins.json (version=2), delete V2 file
 110   * 2. If only installed_plugins.json exists with version=1: convert to version=2 in-place
 111   * 3. Clean up legacy non-versioned cache directories
 112   *
 113   * This migration runs once per session at startup.
 114   */
 115  export function migrateToSinglePluginFile(): void {
 116    if (migrationCompleted) {
 117      return
 118    }
 119  
 120    const fs = getFsImplementation()
 121    const mainFilePath = getInstalledPluginsFilePath()
 122    const v2FilePath = getInstalledPluginsV2FilePath()
 123  
 124    try {
 125      // Case 1: Try renaming v2→main directly; ENOENT = v2 doesn't exist
 126      try {
 127        fs.renameSync(v2FilePath, mainFilePath)
 128        logForDebugging(
 129          `Renamed installed_plugins_v2.json to installed_plugins.json`,
 130        )
 131        // Clean up legacy cache directories
 132        const v2Data = loadInstalledPluginsV2()
 133        cleanupLegacyCache(v2Data)
 134        migrationCompleted = true
 135        return
 136      } catch (e) {
 137        if (!isENOENT(e)) throw e
 138      }
 139  
 140      // Case 2: v2 absent — try reading main; ENOENT = neither exists (case 3)
 141      let mainContent: string
 142      try {
 143        mainContent = fs.readFileSync(mainFilePath, { encoding: 'utf-8' })
 144      } catch (e) {
 145        if (!isENOENT(e)) throw e
 146        // Case 3: No file exists - nothing to migrate
 147        migrationCompleted = true
 148        return
 149      }
 150  
 151      const mainData = jsonParse(mainContent)
 152      const version = typeof mainData?.version === 'number' ? mainData.version : 1
 153  
 154      if (version === 1) {
 155        // Convert V1 to V2 format in-place
 156        const v1Data = InstalledPluginsFileSchemaV1().parse(mainData)
 157        const v2Data = migrateV1ToV2(v1Data)
 158  
 159        writeFileSync_DEPRECATED(mainFilePath, jsonStringify(v2Data, null, 2), {
 160          encoding: 'utf-8',
 161          flush: true,
 162        })
 163        logForDebugging(
 164          `Converted installed_plugins.json from V1 to V2 format (${Object.keys(v1Data.plugins).length} plugins)`,
 165        )
 166  
 167        // Clean up legacy cache directories
 168        cleanupLegacyCache(v2Data)
 169      }
 170      // If version=2, already in correct format, no action needed
 171  
 172      migrationCompleted = true
 173    } catch (error) {
 174      const errorMsg = errorMessage(error)
 175      logForDebugging(`Failed to migrate plugin files: ${errorMsg}`, {
 176        level: 'error',
 177      })
 178      logError(toError(error))
 179      // Mark as completed to avoid retrying failed migration
 180      migrationCompleted = true
 181    }
 182  }
 183  
 184  /**
 185   * Clean up legacy non-versioned cache directories.
 186   *
 187   * Legacy cache structure: ~/.claude/plugins/cache/{plugin-name}/
 188   * Versioned cache structure: ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/
 189   *
 190   * This function removes legacy directories that are not referenced by any installation.
 191   */
 192  function cleanupLegacyCache(v2Data: InstalledPluginsFileV2): void {
 193    const fs = getFsImplementation()
 194    const cachePath = getPluginCachePath()
 195    try {
 196      // Collect all install paths that are referenced
 197      const referencedPaths = new Set<string>()
 198      for (const installations of Object.values(v2Data.plugins)) {
 199        for (const entry of installations) {
 200          referencedPaths.add(entry.installPath)
 201        }
 202      }
 203  
 204      // List top-level directories in cache
 205      const entries = fs.readdirSync(cachePath)
 206  
 207      for (const dirent of entries) {
 208        if (!dirent.isDirectory()) {
 209          continue
 210        }
 211  
 212        const entry = dirent.name
 213        const entryPath = join(cachePath, entry)
 214  
 215        // Check if this is a versioned cache (marketplace dir with plugin/version subdirs)
 216        // or a legacy cache (flat plugin directory)
 217        const subEntries = fs.readdirSync(entryPath)
 218        const hasVersionedStructure = subEntries.some(subDirent => {
 219          if (!subDirent.isDirectory()) return false
 220          const subPath = join(entryPath, subDirent.name)
 221          // Check if subdir contains version directories (semver-like or hash)
 222          const versionEntries = fs.readdirSync(subPath)
 223          return versionEntries.some(vDirent => vDirent.isDirectory())
 224        })
 225  
 226        if (hasVersionedStructure) {
 227          // This is a marketplace directory with versioned structure - skip
 228          continue
 229        }
 230  
 231        // This is a legacy flat cache directory
 232        // Check if it's referenced by any installation
 233        if (!referencedPaths.has(entryPath)) {
 234          // Not referenced - safe to delete
 235          fs.rmSync(entryPath, { recursive: true, force: true })
 236          logForDebugging(`Cleaned up legacy cache directory: ${entry}`)
 237        }
 238      }
 239    } catch (error) {
 240      const errorMsg = errorMessage(error)
 241      logForDebugging(`Failed to clean up legacy cache: ${errorMsg}`, {
 242        level: 'warn',
 243      })
 244    }
 245  }
 246  
 247  /**
 248   * Reset migration state (for testing)
 249   */
 250  export function resetMigrationState(): void {
 251    migrationCompleted = false
 252  }
 253  
 254  /**
 255   * Read raw file data from installed_plugins.json
 256   * Returns null if file doesn't exist.
 257   * Throws error if file exists but can't be parsed.
 258   */
 259  function readInstalledPluginsFileRaw(): {
 260    version: number
 261    data: unknown
 262  } | null {
 263    const fs = getFsImplementation()
 264    const filePath = getInstalledPluginsFilePath()
 265  
 266    let fileContent: string
 267    try {
 268      fileContent = fs.readFileSync(filePath, { encoding: 'utf-8' })
 269    } catch (e) {
 270      if (isENOENT(e)) {
 271        return null
 272      }
 273      throw e
 274    }
 275    const data = jsonParse(fileContent)
 276    const version = typeof data?.version === 'number' ? data.version : 1
 277    return { version, data }
 278  }
 279  
 280  /**
 281   * Migrate V1 data to V2 format.
 282   * All V1 plugins are migrated to 'user' scope since V1 had no scope concept.
 283   */
 284  function migrateV1ToV2(v1Data: InstalledPluginsFileV1): InstalledPluginsFileV2 {
 285    const v2Plugins: InstalledPluginsMapV2 = {}
 286  
 287    for (const [pluginId, plugin] of Object.entries(v1Data.plugins)) {
 288      // V2 format uses versioned cache path: ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}
 289      // Compute it from pluginId and version instead of using the V1 installPath
 290      const versionedCachePath = getVersionedCachePath(pluginId, plugin.version)
 291  
 292      v2Plugins[pluginId] = [
 293        {
 294          scope: 'user', // Default all existing installs to user scope
 295          installPath: versionedCachePath,
 296          version: plugin.version,
 297          installedAt: plugin.installedAt,
 298          lastUpdated: plugin.lastUpdated,
 299          gitCommitSha: plugin.gitCommitSha,
 300        },
 301      ]
 302    }
 303  
 304    return { version: 2, plugins: v2Plugins }
 305  }
 306  
 307  /**
 308   * Load installed plugins in V2 format.
 309   *
 310   * Reads from installed_plugins.json. If file has version=1,
 311   * converts to V2 format in memory.
 312   *
 313   * @returns V2 format data with array-per-plugin structure
 314   */
 315  export function loadInstalledPluginsV2(): InstalledPluginsFileV2 {
 316    // Return cached V2 data if available
 317    if (installedPluginsCacheV2 !== null) {
 318      return installedPluginsCacheV2
 319    }
 320  
 321    const filePath = getInstalledPluginsFilePath()
 322  
 323    try {
 324      const rawData = readInstalledPluginsFileRaw()
 325  
 326      if (rawData) {
 327        if (rawData.version === 2) {
 328          // V2 format - validate and return
 329          const validated = InstalledPluginsFileSchemaV2().parse(rawData.data)
 330          installedPluginsCacheV2 = validated
 331          logForDebugging(
 332            `Loaded ${Object.keys(validated.plugins).length} installed plugins from ${filePath}`,
 333          )
 334          return validated
 335        }
 336  
 337        // V1 format - convert to V2
 338        const v1Validated = InstalledPluginsFileSchemaV1().parse(rawData.data)
 339        const v2Data = migrateV1ToV2(v1Validated)
 340        installedPluginsCacheV2 = v2Data
 341        logForDebugging(
 342          `Loaded and converted ${Object.keys(v1Validated.plugins).length} plugins from V1 format`,
 343        )
 344        return v2Data
 345      }
 346  
 347      // File doesn't exist - return empty V2
 348      logForDebugging(
 349        `installed_plugins.json doesn't exist, returning empty V2 object`,
 350      )
 351      installedPluginsCacheV2 = { version: 2, plugins: {} }
 352      return installedPluginsCacheV2
 353    } catch (error) {
 354      const errorMsg = errorMessage(error)
 355      logForDebugging(
 356        `Failed to load installed_plugins.json: ${errorMsg}. Starting with empty state.`,
 357        { level: 'error' },
 358      )
 359      logError(toError(error))
 360  
 361      installedPluginsCacheV2 = { version: 2, plugins: {} }
 362      return installedPluginsCacheV2
 363    }
 364  }
 365  
 366  /**
 367   * Save installed plugins in V2 format to installed_plugins.json.
 368   * This is the single source of truth after V1/V2 consolidation.
 369   */
 370  function saveInstalledPluginsV2(data: InstalledPluginsFileV2): void {
 371    const fs = getFsImplementation()
 372    const filePath = getInstalledPluginsFilePath()
 373  
 374    try {
 375      fs.mkdirSync(getPluginsDirectory())
 376  
 377      const jsonContent = jsonStringify(data, null, 2)
 378      writeFileSync_DEPRECATED(filePath, jsonContent, {
 379        encoding: 'utf-8',
 380        flush: true,
 381      })
 382  
 383      // Update cache
 384      installedPluginsCacheV2 = data
 385  
 386      logForDebugging(
 387        `Saved ${Object.keys(data.plugins).length} installed plugins to ${filePath}`,
 388      )
 389    } catch (error) {
 390      const _errorMsg = errorMessage(error)
 391      logError(toError(error))
 392      throw error
 393    }
 394  }
 395  
 396  /**
 397   * Add or update a plugin installation entry at a specific scope.
 398   * Used for V2 format where each plugin has an array of installations.
 399   *
 400   * @param pluginId - Plugin ID in "plugin@marketplace" format
 401   * @param scope - Installation scope (managed/user/project/local)
 402   * @param installPath - Path to versioned plugin directory
 403   * @param metadata - Additional installation metadata
 404   * @param projectPath - Project path (required for project/local scopes)
 405   */
 406  export function addPluginInstallation(
 407    pluginId: string,
 408    scope: PersistableScope,
 409    installPath: string,
 410    metadata: Partial<PluginInstallationEntry>,
 411    projectPath?: string,
 412  ): void {
 413    const data = loadInstalledPluginsFromDisk()
 414  
 415    // Get or create array for this plugin
 416    const installations = data.plugins[pluginId] || []
 417  
 418    // Find existing entry for this scope+projectPath
 419    const existingIndex = installations.findIndex(
 420      entry => entry.scope === scope && entry.projectPath === projectPath,
 421    )
 422  
 423    const newEntry: PluginInstallationEntry = {
 424      scope,
 425      installPath,
 426      version: metadata.version,
 427      installedAt: metadata.installedAt || new Date().toISOString(),
 428      lastUpdated: new Date().toISOString(),
 429      gitCommitSha: metadata.gitCommitSha,
 430      ...(projectPath && { projectPath }),
 431    }
 432  
 433    if (existingIndex >= 0) {
 434      installations[existingIndex] = newEntry
 435      logForDebugging(`Updated installation for ${pluginId} at scope ${scope}`)
 436    } else {
 437      installations.push(newEntry)
 438      logForDebugging(`Added installation for ${pluginId} at scope ${scope}`)
 439    }
 440  
 441    data.plugins[pluginId] = installations
 442    saveInstalledPluginsV2(data)
 443  }
 444  
 445  /**
 446   * Remove a plugin installation entry from a specific scope.
 447   *
 448   * @param pluginId - Plugin ID in "plugin@marketplace" format
 449   * @param scope - Installation scope to remove
 450   * @param projectPath - Project path (for project/local scopes)
 451   */
 452  export function removePluginInstallation(
 453    pluginId: string,
 454    scope: PersistableScope,
 455    projectPath?: string,
 456  ): void {
 457    const data = loadInstalledPluginsFromDisk()
 458    const installations = data.plugins[pluginId]
 459  
 460    if (!installations) {
 461      return
 462    }
 463  
 464    data.plugins[pluginId] = installations.filter(
 465      entry => !(entry.scope === scope && entry.projectPath === projectPath),
 466    )
 467  
 468    // Remove plugin entirely if no installations left
 469    if (data.plugins[pluginId].length === 0) {
 470      delete data.plugins[pluginId]
 471    }
 472  
 473    saveInstalledPluginsV2(data)
 474    logForDebugging(`Removed installation for ${pluginId} at scope ${scope}`)
 475  }
 476  
 477  // =============================================================================
 478  // In-Memory vs Disk State Management (for non-in-place updates)
 479  // =============================================================================
 480  
 481  /**
 482   * Get the in-memory installed plugins (session state).
 483   * This snapshot is loaded at startup and used for the entire session.
 484   * It is NOT updated by background operations.
 485   *
 486   * @returns V2 format data representing the session's view of installed plugins
 487   */
 488  export function getInMemoryInstalledPlugins(): InstalledPluginsFileV2 {
 489    if (inMemoryInstalledPlugins === null) {
 490      inMemoryInstalledPlugins = loadInstalledPluginsV2()
 491    }
 492    return inMemoryInstalledPlugins
 493  }
 494  
 495  /**
 496   * Load installed plugins directly from disk, bypassing all caches.
 497   * Used by background updater to check for changes without affecting
 498   * the running session's view.
 499   *
 500   * @returns V2 format data read fresh from disk
 501   */
 502  export function loadInstalledPluginsFromDisk(): InstalledPluginsFileV2 {
 503    try {
 504      // Read from main file
 505      const rawData = readInstalledPluginsFileRaw()
 506  
 507      if (rawData) {
 508        if (rawData.version === 2) {
 509          return InstalledPluginsFileSchemaV2().parse(rawData.data)
 510        }
 511        // V1 format - convert to V2
 512        const v1Data = InstalledPluginsFileSchemaV1().parse(rawData.data)
 513        return migrateV1ToV2(v1Data)
 514      }
 515  
 516      return { version: 2, plugins: {} }
 517    } catch (error) {
 518      const errorMsg = errorMessage(error)
 519      logForDebugging(`Failed to load installed plugins from disk: ${errorMsg}`, {
 520        level: 'error',
 521      })
 522      return { version: 2, plugins: {} }
 523    }
 524  }
 525  
 526  /**
 527   * Update a plugin's install path on disk only, without modifying in-memory state.
 528   * Used by background updater to record new version on disk while session
 529   * continues using the old version.
 530   *
 531   * @param pluginId - Plugin ID in "plugin@marketplace" format
 532   * @param scope - Installation scope
 533   * @param projectPath - Project path (for project/local scopes)
 534   * @param newPath - New install path (to new version directory)
 535   * @param newVersion - New version string
 536   */
 537  export function updateInstallationPathOnDisk(
 538    pluginId: string,
 539    scope: PersistableScope,
 540    projectPath: string | undefined,
 541    newPath: string,
 542    newVersion: string,
 543    gitCommitSha?: string,
 544  ): void {
 545    const diskData = loadInstalledPluginsFromDisk()
 546    const installations = diskData.plugins[pluginId]
 547  
 548    if (!installations) {
 549      logForDebugging(
 550        `Cannot update ${pluginId} on disk: plugin not found in installed plugins`,
 551      )
 552      return
 553    }
 554  
 555    const entry = installations.find(
 556      e => e.scope === scope && e.projectPath === projectPath,
 557    )
 558  
 559    if (entry) {
 560      entry.installPath = newPath
 561      entry.version = newVersion
 562      entry.lastUpdated = new Date().toISOString()
 563      if (gitCommitSha !== undefined) {
 564        entry.gitCommitSha = gitCommitSha
 565      }
 566  
 567      const filePath = getInstalledPluginsFilePath()
 568  
 569      // Write to single file (V2 format with version=2)
 570      writeFileSync_DEPRECATED(filePath, jsonStringify(diskData, null, 2), {
 571        encoding: 'utf-8',
 572        flush: true,
 573      })
 574  
 575      // Clear cache since disk changed, but do NOT update inMemoryInstalledPlugins
 576      installedPluginsCacheV2 = null
 577  
 578      logForDebugging(
 579        `Updated ${pluginId} on disk to version ${newVersion} at ${newPath}`,
 580      )
 581    } else {
 582      logForDebugging(
 583        `Cannot update ${pluginId} on disk: no installation for scope ${scope}`,
 584      )
 585    }
 586    // Note: inMemoryInstalledPlugins is NOT updated
 587  }
 588  
 589  /**
 590   * Check if there are pending updates (disk differs from memory).
 591   * This happens when background updater has downloaded new versions.
 592   *
 593   * @returns true if any plugin has a different install path on disk vs memory
 594   */
 595  export function hasPendingUpdates(): boolean {
 596    const memoryState = getInMemoryInstalledPlugins()
 597    const diskState = loadInstalledPluginsFromDisk()
 598  
 599    for (const [pluginId, diskInstallations] of Object.entries(
 600      diskState.plugins,
 601    )) {
 602      const memoryInstallations = memoryState.plugins[pluginId]
 603      if (!memoryInstallations) continue
 604  
 605      for (const diskEntry of diskInstallations) {
 606        const memoryEntry = memoryInstallations.find(
 607          m =>
 608            m.scope === diskEntry.scope &&
 609            m.projectPath === diskEntry.projectPath,
 610        )
 611        if (memoryEntry && memoryEntry.installPath !== diskEntry.installPath) {
 612          return true // Disk has different version than memory
 613        }
 614      }
 615    }
 616  
 617    return false
 618  }
 619  
 620  /**
 621   * Get the count of pending updates (installations where disk differs from memory).
 622   *
 623   * @returns Number of installations with pending updates
 624   */
 625  export function getPendingUpdateCount(): number {
 626    let count = 0
 627    const memoryState = getInMemoryInstalledPlugins()
 628    const diskState = loadInstalledPluginsFromDisk()
 629  
 630    for (const [pluginId, diskInstallations] of Object.entries(
 631      diskState.plugins,
 632    )) {
 633      const memoryInstallations = memoryState.plugins[pluginId]
 634      if (!memoryInstallations) continue
 635  
 636      for (const diskEntry of diskInstallations) {
 637        const memoryEntry = memoryInstallations.find(
 638          m =>
 639            m.scope === diskEntry.scope &&
 640            m.projectPath === diskEntry.projectPath,
 641        )
 642        if (memoryEntry && memoryEntry.installPath !== diskEntry.installPath) {
 643          count++
 644        }
 645      }
 646    }
 647  
 648    return count
 649  }
 650  
 651  /**
 652   * Get details about pending updates for display.
 653   *
 654   * @returns Array of objects with pluginId, scope, oldVersion, newVersion
 655   */
 656  export function getPendingUpdatesDetails(): Array<{
 657    pluginId: string
 658    scope: string
 659    oldVersion: string
 660    newVersion: string
 661  }> {
 662    const updates: Array<{
 663      pluginId: string
 664      scope: string
 665      oldVersion: string
 666      newVersion: string
 667    }> = []
 668  
 669    const memoryState = getInMemoryInstalledPlugins()
 670    const diskState = loadInstalledPluginsFromDisk()
 671  
 672    for (const [pluginId, diskInstallations] of Object.entries(
 673      diskState.plugins,
 674    )) {
 675      const memoryInstallations = memoryState.plugins[pluginId]
 676      if (!memoryInstallations) continue
 677  
 678      for (const diskEntry of diskInstallations) {
 679        const memoryEntry = memoryInstallations.find(
 680          m =>
 681            m.scope === diskEntry.scope &&
 682            m.projectPath === diskEntry.projectPath,
 683        )
 684        if (memoryEntry && memoryEntry.installPath !== diskEntry.installPath) {
 685          updates.push({
 686            pluginId,
 687            scope: diskEntry.scope,
 688            oldVersion: memoryEntry.version || 'unknown',
 689            newVersion: diskEntry.version || 'unknown',
 690          })
 691        }
 692      }
 693    }
 694  
 695    return updates
 696  }
 697  
 698  /**
 699   * Reset the in-memory session state.
 700   * This should only be called at startup or for testing.
 701   */
 702  export function resetInMemoryState(): void {
 703    inMemoryInstalledPlugins = null
 704  }
 705  
 706  /**
 707   * Initialize the versioned plugins system.
 708   * This triggers V1→V2 migration and initializes the in-memory session state.
 709   *
 710   * This should be called early during startup in all modes (REPL and headless).
 711   *
 712   * @returns Promise that resolves when initialization is complete
 713   */
 714  export async function initializeVersionedPlugins(): Promise<void> {
 715    // Step 1: Migrate to single file format (consolidates V1/V2 files, cleans up legacy cache)
 716    migrateToSinglePluginFile()
 717  
 718    // Step 2: Sync enabledPlugins from settings.json to installed_plugins.json
 719    // This must complete before CLI exits (especially in headless mode)
 720    try {
 721      await migrateFromEnabledPlugins()
 722    } catch (error) {
 723      logError(error)
 724    }
 725  
 726    // Step 3: Initialize in-memory session state
 727    // Calling getInMemoryInstalledPlugins triggers:
 728    // 1. Loading from disk
 729    // 2. Caching in inMemoryInstalledPlugins for session state
 730    const data = getInMemoryInstalledPlugins()
 731    logForDebugging(
 732      `Initialized versioned plugins system with ${Object.keys(data.plugins).length} plugins`,
 733    )
 734  }
 735  
 736  /**
 737   * Remove all plugin entries belonging to a specific marketplace from installed_plugins.json.
 738   *
 739   * Loads V2 data once, finds all plugin IDs matching the `@{marketplaceName}` suffix,
 740   * collects their install paths, removes the entries, and saves once.
 741   *
 742   * @param marketplaceName - The marketplace name (matched against `@{name}` suffix)
 743   * @returns orphanedPaths (for markPluginVersionOrphaned) and removedPluginIds
 744   *   (for deletePluginOptions) from the removed entries
 745   */
 746  export function removeAllPluginsForMarketplace(marketplaceName: string): {
 747    orphanedPaths: string[]
 748    removedPluginIds: string[]
 749  } {
 750    if (!marketplaceName) {
 751      return { orphanedPaths: [], removedPluginIds: [] }
 752    }
 753  
 754    const data = loadInstalledPluginsFromDisk()
 755    const suffix = `@${marketplaceName}`
 756    const orphanedPaths = new Set<string>()
 757    const removedPluginIds: string[] = []
 758  
 759    for (const pluginId of Object.keys(data.plugins)) {
 760      if (!pluginId.endsWith(suffix)) {
 761        continue
 762      }
 763  
 764      for (const entry of data.plugins[pluginId] ?? []) {
 765        if (entry.installPath) {
 766          orphanedPaths.add(entry.installPath)
 767        }
 768      }
 769  
 770      delete data.plugins[pluginId]
 771      removedPluginIds.push(pluginId)
 772      logForDebugging(
 773        `Removed installed plugin for marketplace removal: ${pluginId}`,
 774      )
 775    }
 776  
 777    if (removedPluginIds.length > 0) {
 778      saveInstalledPluginsV2(data)
 779    }
 780  
 781    return { orphanedPaths: Array.from(orphanedPaths), removedPluginIds }
 782  }
 783  
 784  /**
 785   * Predicate: is this installation relevant to the current project context?
 786   *
 787   * V2 installed_plugins.json may contain project-scoped entries from OTHER
 788   * projects (a single user-level file tracks all scopes). Callers asking
 789   * "is this plugin installed" almost always mean "installed in a way that's
 790   * active here" — not "installed anywhere on this machine". See #29608:
 791   * DiscoverPlugins.tsx was hiding plugins that were only installed in an
 792   * unrelated project.
 793   *
 794   * - user/managed scopes: always relevant (global)
 795   * - project/local scopes: only if projectPath matches the current project
 796   *
 797   * getOriginalCwd() (not getCwd()) because "current project" is where Claude
 798   * Code was launched from, not wherever the working directory has drifted to.
 799   */
 800  export function isInstallationRelevantToCurrentProject(
 801    inst: PluginInstallationEntry,
 802  ): boolean {
 803    return (
 804      inst.scope === 'user' ||
 805      inst.scope === 'managed' ||
 806      inst.projectPath === getOriginalCwd()
 807    )
 808  }
 809  
 810  /**
 811   * Check if a plugin is installed in a way relevant to the current project.
 812   *
 813   * @param pluginId - Plugin ID in "plugin@marketplace" format
 814   * @returns True if the plugin has a user/managed-scoped installation, OR a
 815   *   project/local-scoped installation whose projectPath matches the current
 816   *   project. Returns false for plugins only installed in other projects.
 817   */
 818  export function isPluginInstalled(pluginId: string): boolean {
 819    const v2Data = loadInstalledPluginsV2()
 820    const installations = v2Data.plugins[pluginId]
 821    if (!installations || installations.length === 0) {
 822      return false
 823    }
 824    if (!installations.some(isInstallationRelevantToCurrentProject)) {
 825      return false
 826    }
 827    // Plugins are loaded from settings.enabledPlugins
 828    // If settings.enabledPlugins and installed_plugins.json diverge
 829    // (via settings.json clobber), return false
 830    return getSettings_DEPRECATED().enabledPlugins?.[pluginId] !== undefined
 831  }
 832  
 833  /**
 834   * True only if the plugin has a USER or MANAGED scope installation.
 835   *
 836   * Use this in UI flows that decide whether to offer installation at all.
 837   * A user/managed-scope install means the plugin is available everywhere —
 838   * there's nothing the user can add. A project/local-scope install means the
 839   * user might still want to install at user scope to make it global.
 840   *
 841   * gh-29997 / gh-29240 / gh-29392: the browse UI was blocking on
 842   * isPluginInstalled() which returns true for project-scope installs,
 843   * preventing users from adding a user-scope entry for the same plugin.
 844   * The backend (installPluginOp → addInstalledPlugin) already supports
 845   * multiple scope entries per plugin — only the UI gate was wrong.
 846   *
 847   * @param pluginId - Plugin ID in "plugin@marketplace" format
 848   */
 849  export function isPluginGloballyInstalled(pluginId: string): boolean {
 850    const v2Data = loadInstalledPluginsV2()
 851    const installations = v2Data.plugins[pluginId]
 852    if (!installations || installations.length === 0) {
 853      return false
 854    }
 855    const hasGlobalEntry = installations.some(
 856      entry => entry.scope === 'user' || entry.scope === 'managed',
 857    )
 858    if (!hasGlobalEntry) return false
 859    // Same settings divergence guard as isPluginInstalled — if enabledPlugins
 860    // was clobbered, treat as not-installed so the user can re-enable.
 861    return getSettings_DEPRECATED().enabledPlugins?.[pluginId] !== undefined
 862  }
 863  
 864  /**
 865   * Add or update a plugin's installation metadata
 866   *
 867   * Implements double-write: updates both V1 and V2 files.
 868   *
 869   * @param pluginId - Plugin ID in "plugin@marketplace" format
 870   * @param metadata - Installation metadata
 871   * @param scope - Installation scope (defaults to 'user' for backward compatibility)
 872   * @param projectPath - Project path (for project/local scopes)
 873   */
 874  export function addInstalledPlugin(
 875    pluginId: string,
 876    metadata: InstalledPlugin,
 877    scope: PersistableScope = 'user',
 878    projectPath?: string,
 879  ): void {
 880    const v2Data = loadInstalledPluginsFromDisk()
 881    const v2Entry: PluginInstallationEntry = {
 882      scope,
 883      installPath: metadata.installPath,
 884      version: metadata.version,
 885      installedAt: metadata.installedAt,
 886      lastUpdated: metadata.lastUpdated,
 887      gitCommitSha: metadata.gitCommitSha,
 888      ...(projectPath && { projectPath }),
 889    }
 890  
 891    // Get or create array for this plugin (preserves other scope installations)
 892    const installations = v2Data.plugins[pluginId] || []
 893  
 894    // Find existing entry for this scope+projectPath
 895    const existingIndex = installations.findIndex(
 896      entry => entry.scope === scope && entry.projectPath === projectPath,
 897    )
 898  
 899    const isUpdate = existingIndex >= 0
 900    if (isUpdate) {
 901      installations[existingIndex] = v2Entry
 902    } else {
 903      installations.push(v2Entry)
 904    }
 905  
 906    v2Data.plugins[pluginId] = installations
 907    saveInstalledPluginsV2(v2Data)
 908  
 909    logForDebugging(
 910      `${isUpdate ? 'Updated' : 'Added'} installed plugin: ${pluginId} (scope: ${scope})`,
 911    )
 912  }
 913  
 914  /**
 915   * Remove a plugin from the installed plugins registry
 916   * This should be called when a plugin is uninstalled.
 917   *
 918   * Note: This function only updates the registry file. To fully uninstall,
 919   * call deletePluginCache() afterward to remove the physical files.
 920   *
 921   * @param pluginId - Plugin ID in "plugin@marketplace" format
 922   * @returns The removed plugin metadata, or undefined if it wasn't installed
 923   */
 924  export function removeInstalledPlugin(
 925    pluginId: string,
 926  ): InstalledPlugin | undefined {
 927    const v2Data = loadInstalledPluginsFromDisk()
 928    const installations = v2Data.plugins[pluginId]
 929  
 930    if (!installations || installations.length === 0) {
 931      return undefined
 932    }
 933  
 934    // Extract V1-compatible metadata from first installation for return value
 935    const firstInstall = installations[0]
 936    const metadata: InstalledPlugin | undefined = firstInstall
 937      ? {
 938          version: firstInstall.version || 'unknown',
 939          installedAt: firstInstall.installedAt || new Date().toISOString(),
 940          lastUpdated: firstInstall.lastUpdated,
 941          installPath: firstInstall.installPath,
 942          gitCommitSha: firstInstall.gitCommitSha,
 943        }
 944      : undefined
 945  
 946    delete v2Data.plugins[pluginId]
 947    saveInstalledPluginsV2(v2Data)
 948  
 949    logForDebugging(`Removed installed plugin: ${pluginId}`)
 950  
 951    return metadata
 952  }
 953  
 954  /**
 955   * Delete a plugin's cache directory
 956   * This physically removes the plugin files from disk
 957   *
 958   * @param installPath - Absolute path to the plugin's cache directory
 959   */
 960  /**
 961   * Export getGitCommitSha for use by pluginInstallationHelpers
 962   */
 963  export { getGitCommitSha }
 964  
 965  export function deletePluginCache(installPath: string): void {
 966    const fs = getFsImplementation()
 967  
 968    try {
 969      fs.rmSync(installPath, { recursive: true, force: true })
 970      logForDebugging(`Deleted plugin cache at ${installPath}`)
 971  
 972      // Clean up empty parent plugin directory (cache/{marketplace}/{plugin})
 973      // Versioned paths have structure: cache/{marketplace}/{plugin}/{version}
 974      const cachePath = getPluginCachePath()
 975      if (installPath.includes('/cache/') && installPath.startsWith(cachePath)) {
 976        const pluginDir = dirname(installPath) // e.g., cache/{marketplace}/{plugin}
 977        if (pluginDir !== cachePath && pluginDir.startsWith(cachePath)) {
 978          try {
 979            const contents = fs.readdirSync(pluginDir)
 980            if (contents.length === 0) {
 981              fs.rmdirSync(pluginDir)
 982              logForDebugging(`Deleted empty plugin directory at ${pluginDir}`)
 983            }
 984          } catch {
 985            // Parent dir doesn't exist or isn't readable — skip cleanup
 986          }
 987        }
 988      }
 989    } catch (error) {
 990      const errorMsg = errorMessage(error)
 991      logError(toError(error))
 992      throw new Error(
 993        `Failed to delete plugin cache at ${installPath}: ${errorMsg}`,
 994      )
 995    }
 996  }
 997  
 998  /**
 999   * Get the git commit SHA from a git repository directory
1000   * Returns undefined if not a git repo or if operation fails
1001   */
1002  async function getGitCommitSha(dirPath: string): Promise<string | undefined> {
1003    const sha = await getHeadForDir(dirPath)
1004    return sha ?? undefined
1005  }
1006  
1007  /**
1008   * Try to read version from plugin manifest
1009   */
1010  function getPluginVersionFromManifest(
1011    pluginCachePath: string,
1012    pluginId: string,
1013  ): string {
1014    const fs = getFsImplementation()
1015    const manifestPath = join(pluginCachePath, '.claude-plugin', 'plugin.json')
1016  
1017    try {
1018      const manifestContent = fs.readFileSync(manifestPath, { encoding: 'utf-8' })
1019      const manifest = jsonParse(manifestContent)
1020      return manifest.version || 'unknown'
1021    } catch {
1022      logForDebugging(`Could not read version from manifest for ${pluginId}`)
1023      return 'unknown'
1024    }
1025  }
1026  
1027  /**
1028   * Sync installed_plugins.json with enabledPlugins from settings
1029   *
1030   * Checks the schema version and only updates if:
1031   * - File doesn't exist (version 0 → current)
1032   * - Schema version is outdated (old version → current)
1033   * - New plugins appear in enabledPlugins
1034   *
1035   * This version-based approach makes it easy to add new fields in the future:
1036   * 1. Increment CURRENT_SCHEMA_VERSION
1037   * 2. Add migration logic for the new version
1038   * 3. File is automatically updated on next startup
1039   *
1040   * For each plugin in enabledPlugins that's not in installed_plugins.json:
1041   * - Queries marketplace to get actual install path
1042   * - Extracts version from manifest if available
1043   * - Captures git commit SHA for git-based plugins
1044   *
1045   * Being present in enabledPlugins (whether true or false) indicates the plugin
1046   * has been installed. The enabled/disabled state remains in settings.json.
1047   */
1048  export async function migrateFromEnabledPlugins(): Promise<void> {
1049    // Use merged settings for shouldSkipSync check
1050    const settings = getSettings_DEPRECATED()
1051    const enabledPlugins = settings.enabledPlugins || {}
1052  
1053    // No plugins in settings = nothing to sync
1054    if (Object.keys(enabledPlugins).length === 0) {
1055      return
1056    }
1057  
1058    // Check if main file exists and has V2 format
1059    const rawFileData = readInstalledPluginsFileRaw()
1060    const fileExists = rawFileData !== null
1061    const isV2Format = fileExists && rawFileData?.version === 2
1062  
1063    // If file exists with V2 format, check if we can skip the expensive migration
1064    if (isV2Format && rawFileData) {
1065      // Check if all plugins from settings already exist
1066      // (The expensive getPluginById/getGitCommitSha only runs for missing plugins)
1067      const existingData = InstalledPluginsFileSchemaV2().safeParse(
1068        rawFileData.data,
1069      )
1070  
1071      if (existingData?.success) {
1072        const plugins = existingData.data.plugins
1073        const allPluginsExist = Object.keys(enabledPlugins)
1074          .filter(id => id.includes('@'))
1075          .every(id => {
1076            const installations = plugins[id]
1077            return installations && installations.length > 0
1078          })
1079  
1080        if (allPluginsExist) {
1081          logForDebugging('All plugins already exist, skipping migration')
1082          return
1083        }
1084      }
1085    }
1086  
1087    logForDebugging(
1088      fileExists
1089        ? 'Syncing installed_plugins.json with enabledPlugins from all settings.json files'
1090        : 'Creating installed_plugins.json from settings.json files',
1091    )
1092  
1093    const now = new Date().toISOString()
1094    const projectPath = getCwd()
1095  
1096    // Step 1: Build a map of pluginId -> scope from all settings.json files
1097    // Settings.json is the source of truth for scope
1098    const pluginScopeFromSettings = new Map<
1099      string,
1100      {
1101        scope: 'user' | 'project' | 'local'
1102        projectPath: string | undefined
1103      }
1104    >()
1105  
1106    // Iterate through each editable settings source (order matters: user first)
1107    const settingSources: EditableSettingSource[] = [
1108      'userSettings',
1109      'projectSettings',
1110      'localSettings',
1111    ]
1112  
1113    for (const source of settingSources) {
1114      const sourceSettings = getSettingsForSource(source)
1115      const sourceEnabledPlugins = sourceSettings?.enabledPlugins || {}
1116  
1117      for (const pluginId of Object.keys(sourceEnabledPlugins)) {
1118        // Skip non-standard plugin IDs
1119        if (!pluginId.includes('@')) continue
1120  
1121        // Settings.json is source of truth - always update scope
1122        // Use the most specific scope (last one wins: local > project > user)
1123        const scope = settingSourceToScope(source)
1124        pluginScopeFromSettings.set(pluginId, {
1125          scope,
1126          projectPath: scope === 'user' ? undefined : projectPath,
1127        })
1128      }
1129    }
1130  
1131    // Step 2: Start with existing data (or start empty if no file exists)
1132    let v2Plugins: InstalledPluginsMapV2 = {}
1133  
1134    if (fileExists) {
1135      // File exists - load existing data
1136      const existingData = loadInstalledPluginsV2()
1137      v2Plugins = { ...existingData.plugins }
1138    }
1139  
1140    // Step 3: Update V2 scopes based on settings.json (settings is source of truth)
1141    let updatedCount = 0
1142    let addedCount = 0
1143  
1144    for (const [pluginId, scopeInfo] of pluginScopeFromSettings) {
1145      const existingInstallations = v2Plugins[pluginId]
1146  
1147      if (existingInstallations && existingInstallations.length > 0) {
1148        // Plugin exists in V2 - update scope if different (settings is source of truth)
1149        const existingEntry = existingInstallations[0]
1150        if (
1151          existingEntry &&
1152          (existingEntry.scope !== scopeInfo.scope ||
1153            existingEntry.projectPath !== scopeInfo.projectPath)
1154        ) {
1155          existingEntry.scope = scopeInfo.scope
1156          if (scopeInfo.projectPath) {
1157            existingEntry.projectPath = scopeInfo.projectPath
1158          } else {
1159            delete existingEntry.projectPath
1160          }
1161          existingEntry.lastUpdated = now
1162          updatedCount++
1163          logForDebugging(
1164            `Updated ${pluginId} scope to ${scopeInfo.scope} (settings.json is source of truth)`,
1165          )
1166        }
1167      } else {
1168        // Plugin not in V2 - try to add it by looking up in marketplace
1169        const { name: pluginName, marketplace } = parsePluginIdentifier(pluginId)
1170  
1171        if (!pluginName || !marketplace) {
1172          continue
1173        }
1174  
1175        try {
1176          logForDebugging(
1177            `Looking up plugin ${pluginId} in marketplace ${marketplace}`,
1178          )
1179          const pluginInfo = await getPluginById(pluginId)
1180          if (!pluginInfo) {
1181            logForDebugging(
1182              `Plugin ${pluginId} not found in any marketplace, skipping`,
1183            )
1184            continue
1185          }
1186  
1187          const { entry, marketplaceInstallLocation } = pluginInfo
1188  
1189          let installPath: string
1190          let version = 'unknown'
1191          let gitCommitSha: string | undefined = undefined
1192  
1193          if (typeof entry.source === 'string') {
1194            installPath = join(marketplaceInstallLocation, entry.source)
1195            version = getPluginVersionFromManifest(installPath, pluginId)
1196            gitCommitSha = await getGitCommitSha(installPath)
1197          } else {
1198            const cachePath = getPluginCachePath()
1199            const sanitizedName = pluginName.replace(/[^a-zA-Z0-9-_]/g, '-')
1200            const pluginCachePath = join(cachePath, sanitizedName)
1201  
1202            // Read the cache directory directly — readdir is the first real
1203            // operation, not a pre-check. Its ENOENT tells us the cache
1204            // doesn't exist; its result gates the manifest read below.
1205            // Not a TOCTOU — downstream operations handle ENOENT gracefully,
1206            // so a race (dir removed between readdir and read) degrades to
1207            // version='unknown', not a crash.
1208            let dirEntries: string[]
1209            try {
1210              dirEntries = (
1211                await getFsImplementation().readdir(pluginCachePath)
1212              ).map(e => (typeof e === 'string' ? e : e.name))
1213            } catch (e) {
1214              if (!isENOENT(e)) throw e
1215              logForDebugging(
1216                `External plugin ${pluginId} not in cache, skipping`,
1217              )
1218              continue
1219            }
1220  
1221            installPath = pluginCachePath
1222  
1223            // Only read manifest if the .claude-plugin dir is present
1224            if (dirEntries.includes('.claude-plugin')) {
1225              version = getPluginVersionFromManifest(pluginCachePath, pluginId)
1226            }
1227  
1228            gitCommitSha = await getGitCommitSha(pluginCachePath)
1229          }
1230  
1231          if (version === 'unknown' && entry.version) {
1232            version = entry.version
1233          }
1234          if (version === 'unknown' && gitCommitSha) {
1235            version = gitCommitSha.substring(0, 12)
1236          }
1237  
1238          v2Plugins[pluginId] = [
1239            {
1240              scope: scopeInfo.scope,
1241              installPath: getVersionedCachePath(pluginId, version),
1242              version,
1243              installedAt: now,
1244              lastUpdated: now,
1245              gitCommitSha,
1246              ...(scopeInfo.projectPath && {
1247                projectPath: scopeInfo.projectPath,
1248              }),
1249            },
1250          ]
1251  
1252          addedCount++
1253          logForDebugging(`Added ${pluginId} with scope ${scopeInfo.scope}`)
1254        } catch (error) {
1255          logForDebugging(`Failed to add plugin ${pluginId}: ${error}`)
1256        }
1257      }
1258    }
1259  
1260    // Step 4: Save to single file (V2 format)
1261    if (!fileExists || updatedCount > 0 || addedCount > 0) {
1262      const v2Data: InstalledPluginsFileV2 = { version: 2, plugins: v2Plugins }
1263      saveInstalledPluginsV2(v2Data)
1264      logForDebugging(
1265        `Sync completed: ${addedCount} added, ${updatedCount} updated in installed_plugins.json`,
1266      )
1267    }
1268  }