source dump of claude code
at main 1268 lines 41 kB view raw
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 16import { dirname, join } from 'path' 17import { logForDebugging } from '../debug.js' 18import { errorMessage, isENOENT, toError } from '../errors.js' 19import { getFsImplementation } from '../fsOperations.js' 20import { logError } from '../log.js' 21import { 22 jsonParse, 23 jsonStringify, 24 writeFileSync_DEPRECATED, 25} from '../slowOperations.js' 26import { getPluginsDirectory } from './pluginDirectories.js' 27import { 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 38type InstalledPluginsMapV2 = Record<string, PluginInstallationEntry[]> 39 40// Type for persistable scopes (excludes 'flag' which is session-only) 41export type PersistableScope = Exclude<PluginScope, never> // All scopes are persistable in the schema 42 43import { getOriginalCwd } from '../../bootstrap/state.js' 44import { getCwd } from '../cwd.js' 45import { getHeadForDir } from '../git/gitFilesystem.js' 46import type { EditableSettingSource } from '../settings/constants.js' 47import { 48 getSettings_DEPRECATED, 49 getSettingsForSource, 50} from '../settings/settings.js' 51import { getPluginById } from './marketplaceManager.js' 52import { 53 parsePluginIdentifier, 54 settingSourceToScope, 55} from './pluginIdentifier.js' 56import { getPluginCachePath, getVersionedCachePath } from './pluginLoader.js' 57 58// Migration state to prevent running migration multiple times per session 59let 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 */ 66let 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 */ 73let inMemoryInstalledPlugins: InstalledPluginsFileV2 | null = null 74 75/** 76 * Get the path to the installed_plugins.json file 77 */ 78export 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 */ 86export 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 */ 99export 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 */ 115export 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 */ 192function 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 */ 250export 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 */ 259function 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 */ 284function 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 */ 315export 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 */ 370function 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 */ 406export 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 */ 452export 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 */ 488export 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 */ 502export 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 */ 537export 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 */ 595export 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 */ 625export 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 */ 656export 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 */ 702export 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 */ 714export 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 */ 746export 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 */ 800export 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 */ 818export 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 */ 849export 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 */ 874export 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 */ 924export 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 */ 963export { getGitCommitSha } 964 965export 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 */ 1002async 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 */ 1010function 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 */ 1048export 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}