source dump of claude code
at main 1088 lines 36 kB view raw
1/** 2 * Core plugin operations (install, uninstall, enable, disable, update) 3 * 4 * This module provides pure library functions that can be used by both: 5 * - CLI commands (`claude plugin install/uninstall/enable/disable/update`) 6 * - Interactive UI (ManagePlugins.tsx) 7 * 8 * Functions in this module: 9 * - Do NOT call process.exit() 10 * - Do NOT write to console 11 * - Return result objects indicating success/failure with messages 12 * - Can throw errors for unexpected failures 13 */ 14import { dirname, join } from 'path' 15import { getOriginalCwd } from '../../bootstrap/state.js' 16import { isBuiltinPluginId } from '../../plugins/builtinPlugins.js' 17import type { LoadedPlugin, PluginManifest } from '../../types/plugin.js' 18import { isENOENT, toError } from '../../utils/errors.js' 19import { getFsImplementation } from '../../utils/fsOperations.js' 20import { logError } from '../../utils/log.js' 21import { 22 clearAllCaches, 23 markPluginVersionOrphaned, 24} from '../../utils/plugins/cacheUtils.js' 25import { 26 findReverseDependents, 27 formatReverseDependentsSuffix, 28} from '../../utils/plugins/dependencyResolver.js' 29import { 30 loadInstalledPluginsFromDisk, 31 loadInstalledPluginsV2, 32 removePluginInstallation, 33 updateInstallationPathOnDisk, 34} from '../../utils/plugins/installedPluginsManager.js' 35import { 36 getMarketplace, 37 getPluginById, 38 loadKnownMarketplacesConfig, 39} from '../../utils/plugins/marketplaceManager.js' 40import { deletePluginDataDir } from '../../utils/plugins/pluginDirectories.js' 41import { 42 parsePluginIdentifier, 43 scopeToSettingSource, 44} from '../../utils/plugins/pluginIdentifier.js' 45import { 46 formatResolutionError, 47 installResolvedPlugin, 48} from '../../utils/plugins/pluginInstallationHelpers.js' 49import { 50 cachePlugin, 51 copyPluginToVersionedCache, 52 getVersionedCachePath, 53 getVersionedZipCachePath, 54 loadAllPlugins, 55 loadPluginManifest, 56} from '../../utils/plugins/pluginLoader.js' 57import { deletePluginOptions } from '../../utils/plugins/pluginOptionsStorage.js' 58import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js' 59import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js' 60import { calculatePluginVersion } from '../../utils/plugins/pluginVersioning.js' 61import type { 62 PluginMarketplaceEntry, 63 PluginScope, 64} from '../../utils/plugins/schemas.js' 65import { 66 getSettingsForSource, 67 updateSettingsForSource, 68} from '../../utils/settings/settings.js' 69import { plural } from '../../utils/stringUtils.js' 70 71/** Valid installable scopes (excludes 'managed' which can only be installed from managed-settings.json) */ 72export const VALID_INSTALLABLE_SCOPES = ['user', 'project', 'local'] as const 73 74/** Installation scope type derived from VALID_INSTALLABLE_SCOPES */ 75export type InstallableScope = (typeof VALID_INSTALLABLE_SCOPES)[number] 76 77/** Valid scopes for update operations (includes 'managed' since managed plugins can be updated) */ 78export const VALID_UPDATE_SCOPES: readonly PluginScope[] = [ 79 'user', 80 'project', 81 'local', 82 'managed', 83] as const 84 85/** 86 * Assert that a scope is a valid installable scope at runtime 87 * @param scope The scope to validate 88 * @throws Error if scope is not a valid installable scope 89 */ 90export function assertInstallableScope( 91 scope: string, 92): asserts scope is InstallableScope { 93 if (!VALID_INSTALLABLE_SCOPES.includes(scope as InstallableScope)) { 94 throw new Error( 95 `Invalid scope "${scope}". Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}`, 96 ) 97 } 98} 99 100/** 101 * Type guard to check if a scope is an installable scope (not 'managed'). 102 * Use this for type narrowing in conditional blocks. 103 */ 104export function isInstallableScope( 105 scope: PluginScope, 106): scope is InstallableScope { 107 return VALID_INSTALLABLE_SCOPES.includes(scope as InstallableScope) 108} 109 110/** 111 * Get the project path for scopes that are project-specific. 112 * Returns the original cwd for 'project' and 'local' scopes, undefined otherwise. 113 */ 114export function getProjectPathForScope(scope: PluginScope): string | undefined { 115 return scope === 'project' || scope === 'local' ? getOriginalCwd() : undefined 116} 117 118/** 119 * Is this plugin enabled (value === true) in .claude/settings.json? 120 * 121 * Distinct from V2 installed_plugins.json scope: that file tracks where a 122 * plugin was *installed from*, but the same plugin can also be enabled at 123 * project scope via settings. The uninstall UI needs to check THIS, because 124 * a user-scope install with a project-scope enablement means "uninstall" 125 * would succeed at removing the user install while leaving the project 126 * enablement active — the plugin keeps running. 127 */ 128export function isPluginEnabledAtProjectScope(pluginId: string): boolean { 129 return ( 130 getSettingsForSource('projectSettings')?.enabledPlugins?.[pluginId] === true 131 ) 132} 133 134// ============================================================================ 135// Result Types 136// ============================================================================ 137 138/** 139 * Result of a plugin operation 140 */ 141export type PluginOperationResult = { 142 success: boolean 143 message: string 144 pluginId?: string 145 pluginName?: string 146 scope?: PluginScope 147 /** Plugins that declare this plugin as a dependency (warning on uninstall/disable) */ 148 reverseDependents?: string[] 149} 150 151/** 152 * Result of a plugin update operation 153 */ 154export type PluginUpdateResult = { 155 success: boolean 156 message: string 157 pluginId?: string 158 newVersion?: string 159 oldVersion?: string 160 alreadyUpToDate?: boolean 161 scope?: PluginScope 162} 163 164// ============================================================================ 165// Helper Functions 166// ============================================================================ 167 168/** 169 * Search all editable settings scopes for a plugin ID matching the given input. 170 * 171 * If `plugin` contains `@`, it's treated as a full pluginId and returned if 172 * found in any scope. If `plugin` is a bare name, searches for any key 173 * starting with `{plugin}@` in any scope. 174 * 175 * Returns the most specific scope where the plugin is mentioned (regardless 176 * of enabled/disabled state) plus the resolved full pluginId. 177 * 178 * Precedence: local > project > user (most specific wins). 179 */ 180function findPluginInSettings(plugin: string): { 181 pluginId: string 182 scope: InstallableScope 183} | null { 184 const hasMarketplace = plugin.includes('@') 185 // Most specific first — first match wins 186 const searchOrder: InstallableScope[] = ['local', 'project', 'user'] 187 188 for (const scope of searchOrder) { 189 const enabledPlugins = getSettingsForSource( 190 scopeToSettingSource(scope), 191 )?.enabledPlugins 192 if (!enabledPlugins) continue 193 194 for (const key of Object.keys(enabledPlugins)) { 195 if (hasMarketplace ? key === plugin : key.startsWith(`${plugin}@`)) { 196 return { pluginId: key, scope } 197 } 198 } 199 } 200 return null 201} 202 203/** 204 * Helper function to find a plugin from loaded plugins 205 */ 206function findPluginByIdentifier( 207 plugin: string, 208 plugins: LoadedPlugin[], 209): LoadedPlugin | undefined { 210 const { name, marketplace } = parsePluginIdentifier(plugin) 211 212 return plugins.find(p => { 213 // Check exact name match 214 if (p.name === plugin || p.name === name) return true 215 216 // If marketplace specified, check if it matches the source 217 if (marketplace && p.source) { 218 return p.name === name && p.source.includes(`@${marketplace}`) 219 } 220 221 return false 222 }) 223} 224 225/** 226 * Resolve a plugin ID from V2 installed plugins data for a plugin that may 227 * have been delisted from its marketplace. Returns null if the plugin is not 228 * found in V2 data. 229 */ 230function resolveDelistedPluginId( 231 plugin: string, 232): { pluginId: string; pluginName: string } | null { 233 const { name } = parsePluginIdentifier(plugin) 234 const installedData = loadInstalledPluginsV2() 235 236 // Try exact match first, then search by name 237 if (installedData.plugins[plugin]?.length) { 238 return { pluginId: plugin, pluginName: name } 239 } 240 241 const matchingKey = Object.keys(installedData.plugins).find(key => { 242 const { name: keyName } = parsePluginIdentifier(key) 243 return keyName === name && (installedData.plugins[key]?.length ?? 0) > 0 244 }) 245 246 if (matchingKey) { 247 return { pluginId: matchingKey, pluginName: name } 248 } 249 250 return null 251} 252 253/** 254 * Get the most relevant installation for a plugin from V2 data. 255 * For project/local scoped plugins, prioritizes installations matching the current project. 256 * Priority order: local (matching project) > project (matching project) > user > first available 257 */ 258export function getPluginInstallationFromV2(pluginId: string): { 259 scope: PluginScope 260 projectPath?: string 261} { 262 const installedData = loadInstalledPluginsV2() 263 const installations = installedData.plugins[pluginId] 264 265 if (!installations || installations.length === 0) { 266 return { scope: 'user' } 267 } 268 269 const currentProjectPath = getOriginalCwd() 270 271 // Find installations by priority: local > project > user > managed 272 const localInstall = installations.find( 273 inst => inst.scope === 'local' && inst.projectPath === currentProjectPath, 274 ) 275 if (localInstall) { 276 return { scope: localInstall.scope, projectPath: localInstall.projectPath } 277 } 278 279 const projectInstall = installations.find( 280 inst => inst.scope === 'project' && inst.projectPath === currentProjectPath, 281 ) 282 if (projectInstall) { 283 return { 284 scope: projectInstall.scope, 285 projectPath: projectInstall.projectPath, 286 } 287 } 288 289 const userInstall = installations.find(inst => inst.scope === 'user') 290 if (userInstall) { 291 return { scope: userInstall.scope } 292 } 293 294 // Fall back to first installation (could be managed) 295 return { 296 scope: installations[0]!.scope, 297 projectPath: installations[0]!.projectPath, 298 } 299} 300 301// ============================================================================ 302// Core Operations 303// ============================================================================ 304 305/** 306 * Install a plugin (settings-first). 307 * 308 * Order of operations: 309 * 1. Search materialized marketplaces for the plugin 310 * 2. Write settings (THE ACTION — declares intent) 311 * 3. Cache plugin + record version hint (materialization) 312 * 313 * Marketplace reconciliation is NOT this function's responsibility — startup 314 * reconcile handles declared-but-not-materialized marketplaces. If the 315 * marketplace isn't found, "not found" is the correct error. 316 * 317 * @param plugin Plugin identifier (name or plugin@marketplace) 318 * @param scope Installation scope: user, project, or local (defaults to 'user') 319 * @returns Result indicating success/failure 320 */ 321export async function installPluginOp( 322 plugin: string, 323 scope: InstallableScope = 'user', 324): Promise<PluginOperationResult> { 325 assertInstallableScope(scope) 326 327 const { name: pluginName, marketplace: marketplaceName } = 328 parsePluginIdentifier(plugin) 329 330 // ── Search materialized marketplaces for the plugin ── 331 let foundPlugin: PluginMarketplaceEntry | undefined 332 let foundMarketplace: string | undefined 333 let marketplaceInstallLocation: string | undefined 334 335 if (marketplaceName) { 336 const pluginInfo = await getPluginById(plugin) 337 if (pluginInfo) { 338 foundPlugin = pluginInfo.entry 339 foundMarketplace = marketplaceName 340 marketplaceInstallLocation = pluginInfo.marketplaceInstallLocation 341 } 342 } else { 343 const marketplaces = await loadKnownMarketplacesConfig() 344 for (const [mktName, mktConfig] of Object.entries(marketplaces)) { 345 try { 346 const marketplace = await getMarketplace(mktName) 347 const pluginEntry = marketplace.plugins.find(p => p.name === pluginName) 348 if (pluginEntry) { 349 foundPlugin = pluginEntry 350 foundMarketplace = mktName 351 marketplaceInstallLocation = mktConfig.installLocation 352 break 353 } 354 } catch (error) { 355 logError(toError(error)) 356 continue 357 } 358 } 359 } 360 361 if (!foundPlugin || !foundMarketplace) { 362 const location = marketplaceName 363 ? `marketplace "${marketplaceName}"` 364 : 'any configured marketplace' 365 return { 366 success: false, 367 message: `Plugin "${pluginName}" not found in ${location}`, 368 } 369 } 370 371 const entry = foundPlugin 372 const pluginId = `${entry.name}@${foundMarketplace}` 373 374 const result = await installResolvedPlugin({ 375 pluginId, 376 entry, 377 scope, 378 marketplaceInstallLocation, 379 }) 380 381 if (!result.ok) { 382 switch (result.reason) { 383 case 'local-source-no-location': 384 return { 385 success: false, 386 message: `Cannot install local plugin "${result.pluginName}" without marketplace install location`, 387 } 388 case 'settings-write-failed': 389 return { 390 success: false, 391 message: `Failed to update settings: ${result.message}`, 392 } 393 case 'resolution-failed': 394 return { 395 success: false, 396 message: formatResolutionError(result.resolution), 397 } 398 case 'blocked-by-policy': 399 return { 400 success: false, 401 message: `Plugin "${result.pluginName}" is blocked by your organization's policy and cannot be installed`, 402 } 403 case 'dependency-blocked-by-policy': 404 return { 405 success: false, 406 message: `Plugin "${result.pluginName}" depends on "${result.blockedDependency}", which is blocked by your organization's policy`, 407 } 408 } 409 } 410 411 return { 412 success: true, 413 message: `Successfully installed plugin: ${pluginId} (scope: ${scope})${result.depNote}`, 414 pluginId, 415 pluginName: entry.name, 416 scope, 417 } 418} 419 420/** 421 * Uninstall a plugin 422 * 423 * @param plugin Plugin name or plugin@marketplace identifier 424 * @param scope Uninstall from scope: user, project, or local (defaults to 'user') 425 * @returns Result indicating success/failure 426 */ 427export async function uninstallPluginOp( 428 plugin: string, 429 scope: InstallableScope = 'user', 430 deleteDataDir = true, 431): Promise<PluginOperationResult> { 432 // Validate scope at runtime for early error detection 433 assertInstallableScope(scope) 434 435 const { enabled, disabled } = await loadAllPlugins() 436 const allPlugins = [...enabled, ...disabled] 437 438 // Find the plugin 439 const foundPlugin = findPluginByIdentifier(plugin, allPlugins) 440 441 const settingSource = scopeToSettingSource(scope) 442 const settings = getSettingsForSource(settingSource) 443 444 let pluginId: string 445 let pluginName: string 446 447 if (foundPlugin) { 448 // Find the matching settings key for this plugin (may differ from `plugin` 449 // if user gave short name but settings has plugin@marketplace) 450 pluginId = 451 Object.keys(settings?.enabledPlugins ?? {}).find( 452 k => 453 k === plugin || 454 k === foundPlugin.name || 455 k.startsWith(`${foundPlugin.name}@`), 456 ) ?? (plugin.includes('@') ? plugin : foundPlugin.name) 457 pluginName = foundPlugin.name 458 } else { 459 // Plugin not found via marketplace lookup — it may have been delisted. 460 // Fall back to installed_plugins.json (V2) which tracks installations 461 // independently of marketplace state. 462 const resolved = resolveDelistedPluginId(plugin) 463 if (!resolved) { 464 return { 465 success: false, 466 message: `Plugin "${plugin}" not found in installed plugins`, 467 } 468 } 469 pluginId = resolved.pluginId 470 pluginName = resolved.pluginName 471 } 472 473 // Check if the plugin is installed in this scope (in V2 file) 474 const projectPath = getProjectPathForScope(scope) 475 const installedData = loadInstalledPluginsV2() 476 const installations = installedData.plugins[pluginId] 477 const scopeInstallation = installations?.find( 478 i => i.scope === scope && i.projectPath === projectPath, 479 ) 480 481 if (!scopeInstallation) { 482 // Try to find where the plugin is actually installed to provide a helpful error 483 const { scope: actualScope } = getPluginInstallationFromV2(pluginId) 484 if (actualScope !== scope && installations && installations.length > 0) { 485 // Project scope is special: .claude/settings.json is shared with the team. 486 // Point users at the local-override escape hatch instead of --scope project. 487 if (actualScope === 'project') { 488 return { 489 success: false, 490 message: `Plugin "${plugin}" is enabled at project scope (.claude/settings.json, shared with your team). To disable just for you: claude plugin disable ${plugin} --scope local`, 491 } 492 } 493 return { 494 success: false, 495 message: `Plugin "${plugin}" is installed in ${actualScope} scope, not ${scope}. Use --scope ${actualScope} to uninstall.`, 496 } 497 } 498 return { 499 success: false, 500 message: `Plugin "${plugin}" is not installed in ${scope} scope. Use --scope to specify the correct scope.`, 501 } 502 } 503 504 const installPath = scopeInstallation.installPath 505 506 // Remove the plugin from the appropriate settings file (delete key entirely) 507 // Use undefined to signal deletion via mergeWith in updateSettingsForSource 508 const newEnabledPlugins: Record<string, boolean | string[] | undefined> = { 509 ...settings?.enabledPlugins, 510 } 511 newEnabledPlugins[pluginId] = undefined 512 updateSettingsForSource(settingSource, { 513 enabledPlugins: newEnabledPlugins, 514 }) 515 516 clearAllCaches() 517 518 // Remove from installed_plugins_v2.json for this scope 519 removePluginInstallation(pluginId, scope, projectPath) 520 521 const updatedData = loadInstalledPluginsV2() 522 const remainingInstallations = updatedData.plugins[pluginId] 523 const isLastScope = 524 !remainingInstallations || remainingInstallations.length === 0 525 if (isLastScope && installPath) { 526 await markPluginVersionOrphaned(installPath) 527 } 528 // Separate from the `&& installPath` guard above — deletePluginOptions only 529 // needs pluginId, not installPath. Last scope removed → wipe stored options 530 // and secrets. Before this, uninstalling left orphaned entries in 531 // settings.pluginConfigs (including the legacy ungated mcpServers sub-key 532 // from the MCPB Configure flow) and keychain pluginSecrets forever. No 533 // feature gate: deletePluginOptions no-ops when nothing is stored, and 534 // pluginConfigs.mcpServers is written ungated so its cleanup must run 535 // ungated too. 536 if (isLastScope) { 537 deletePluginOptions(pluginId) 538 if (deleteDataDir) { 539 await deletePluginDataDir(pluginId) 540 } 541 } 542 543 // Warn (don't block) if other enabled plugins depend on this one. 544 // Blocking creates tombstones — can't tear down a graph with a delisted 545 // plugin. Load-time verifyAndDemote catches the fallout. 546 const reverseDependents = findReverseDependents(pluginId, allPlugins) 547 const depWarn = formatReverseDependentsSuffix(reverseDependents) 548 549 return { 550 success: true, 551 message: `Successfully uninstalled plugin: ${pluginName} (scope: ${scope})${depWarn}`, 552 pluginId, 553 pluginName, 554 scope, 555 reverseDependents: 556 reverseDependents.length > 0 ? reverseDependents : undefined, 557 } 558} 559 560/** 561 * Set plugin enabled/disabled status (settings-first). 562 * 563 * Resolves the plugin ID and scope from settings — does NOT pre-gate on 564 * installed_plugins.json. Settings declares intent; if the plugin isn't 565 * cached yet, the next load will cache it. 566 * 567 * @param plugin Plugin name or plugin@marketplace identifier 568 * @param enabled true to enable, false to disable 569 * @param scope Optional scope. If not provided, auto-detects the most specific 570 * scope where the plugin is mentioned in settings. 571 * @returns Result indicating success/failure 572 */ 573export async function setPluginEnabledOp( 574 plugin: string, 575 enabled: boolean, 576 scope?: InstallableScope, 577): Promise<PluginOperationResult> { 578 const operation = enabled ? 'enable' : 'disable' 579 580 // Built-in plugins: always use user-scope settings, bypass the normal 581 // scope-resolution + installed_plugins lookup (they're not installed). 582 if (isBuiltinPluginId(plugin)) { 583 const { error } = updateSettingsForSource('userSettings', { 584 enabledPlugins: { 585 ...getSettingsForSource('userSettings')?.enabledPlugins, 586 [plugin]: enabled, 587 }, 588 }) 589 if (error) { 590 return { 591 success: false, 592 message: `Failed to ${operation} built-in plugin: ${error.message}`, 593 } 594 } 595 clearAllCaches() 596 const { name: pluginName } = parsePluginIdentifier(plugin) 597 return { 598 success: true, 599 message: `Successfully ${operation}d built-in plugin: ${pluginName}`, 600 pluginId: plugin, 601 pluginName, 602 scope: 'user', 603 } 604 } 605 606 if (scope) { 607 assertInstallableScope(scope) 608 } 609 610 // ── Resolve pluginId and scope from settings ── 611 // Search across editable scopes for any mention (enabled or disabled) of 612 // this plugin. Does NOT pre-gate on installed_plugins.json. 613 let pluginId: string 614 let resolvedScope: InstallableScope 615 616 const found = findPluginInSettings(plugin) 617 618 if (scope) { 619 // Explicit scope: use it. Resolve pluginId from settings if possible, 620 // otherwise require a full plugin@marketplace identifier. 621 resolvedScope = scope 622 if (found) { 623 pluginId = found.pluginId 624 } else if (plugin.includes('@')) { 625 pluginId = plugin 626 } else { 627 return { 628 success: false, 629 message: `Plugin "${plugin}" not found in settings. Use plugin@marketplace format.`, 630 } 631 } 632 } else if (found) { 633 // Auto-detect scope: use the most specific scope where the plugin is 634 // mentioned in settings. 635 pluginId = found.pluginId 636 resolvedScope = found.scope 637 } else if (plugin.includes('@')) { 638 // Not in any settings scope, but full pluginId given — default to user 639 // scope (matches install default). This allows enabling a plugin that 640 // was cached but never declared. 641 pluginId = plugin 642 resolvedScope = 'user' 643 } else { 644 return { 645 success: false, 646 message: `Plugin "${plugin}" not found in any editable settings scope. Use plugin@marketplace format.`, 647 } 648 } 649 650 // ── Policy guard ── 651 // Org-blocked plugins cannot be enabled at any scope. Check after pluginId 652 // is resolved so we catch both full identifiers and bare-name lookups. 653 if (enabled && isPluginBlockedByPolicy(pluginId)) { 654 return { 655 success: false, 656 message: `Plugin "${pluginId}" is blocked by your organization's policy and cannot be enabled`, 657 } 658 } 659 660 const settingSource = scopeToSettingSource(resolvedScope) 661 const scopeSettingsValue = 662 getSettingsForSource(settingSource)?.enabledPlugins?.[pluginId] 663 664 // ── Cross-scope hint: explicit scope given but plugin is elsewhere ── 665 // If the plugin is absent from the requested scope but present at a 666 // different scope, guide the user to the right --scope — UNLESS they're 667 // writing to a higher-precedence scope to override a lower one 668 // (e.g. `disable --scope local` to override a project-enabled plugin 669 // without touching the shared .claude/settings.json). 670 const SCOPE_PRECEDENCE: Record<InstallableScope, number> = { 671 user: 0, 672 project: 1, 673 local: 2, 674 } 675 const isOverride = 676 scope && found && SCOPE_PRECEDENCE[scope] > SCOPE_PRECEDENCE[found.scope] 677 if ( 678 scope && 679 scopeSettingsValue === undefined && 680 found && 681 found.scope !== scope && 682 !isOverride 683 ) { 684 return { 685 success: false, 686 message: `Plugin "${plugin}" is installed at ${found.scope} scope, not ${scope}. Use --scope ${found.scope} or omit --scope to auto-detect.`, 687 } 688 } 689 690 // ── Check current state (for idempotency messaging) ── 691 // When explicit scope given: check that scope's settings value directly 692 // (merged state can be wrong if plugin is enabled elsewhere but disabled here). 693 // When auto-detected: use merged effective state. 694 // When overriding a lower scope: check merged state — scopeSettingsValue is 695 // undefined (plugin not in this scope yet), which would read as "already 696 // disabled", but the whole point of the override is to write an explicit 697 // `false` that masks the lower scope's `true`. 698 const isCurrentlyEnabled = 699 scope && !isOverride 700 ? scopeSettingsValue === true 701 : getPluginEditableScopes().has(pluginId) 702 if (enabled === isCurrentlyEnabled) { 703 return { 704 success: false, 705 message: `Plugin "${plugin}" is already ${enabled ? 'enabled' : 'disabled'}${scope ? ` at ${scope} scope` : ''}`, 706 } 707 } 708 709 // On disable: capture reverse dependents from the PRE-disable snapshot, 710 // before we write settings and clear the memoized plugin cache. 711 let reverseDependents: string[] | undefined 712 if (!enabled) { 713 const { enabled: loadedEnabled, disabled } = await loadAllPlugins() 714 const rdeps = findReverseDependents(pluginId, [ 715 ...loadedEnabled, 716 ...disabled, 717 ]) 718 if (rdeps.length > 0) reverseDependents = rdeps 719 } 720 721 // ── ACTION: write settings ── 722 const { error } = updateSettingsForSource(settingSource, { 723 enabledPlugins: { 724 ...getSettingsForSource(settingSource)?.enabledPlugins, 725 [pluginId]: enabled, 726 }, 727 }) 728 if (error) { 729 return { 730 success: false, 731 message: `Failed to ${operation} plugin: ${error.message}`, 732 } 733 } 734 735 clearAllCaches() 736 737 const { name: pluginName } = parsePluginIdentifier(pluginId) 738 const depWarn = formatReverseDependentsSuffix(reverseDependents) 739 return { 740 success: true, 741 message: `Successfully ${operation}d plugin: ${pluginName} (scope: ${resolvedScope})${depWarn}`, 742 pluginId, 743 pluginName, 744 scope: resolvedScope, 745 reverseDependents, 746 } 747} 748 749/** 750 * Enable a plugin 751 * 752 * @param plugin Plugin name or plugin@marketplace identifier 753 * @param scope Optional scope. If not provided, finds the most specific scope for the current project. 754 * @returns Result indicating success/failure 755 */ 756export async function enablePluginOp( 757 plugin: string, 758 scope?: InstallableScope, 759): Promise<PluginOperationResult> { 760 return setPluginEnabledOp(plugin, true, scope) 761} 762 763/** 764 * Disable a plugin 765 * 766 * @param plugin Plugin name or plugin@marketplace identifier 767 * @param scope Optional scope. If not provided, finds the most specific scope for the current project. 768 * @returns Result indicating success/failure 769 */ 770export async function disablePluginOp( 771 plugin: string, 772 scope?: InstallableScope, 773): Promise<PluginOperationResult> { 774 return setPluginEnabledOp(plugin, false, scope) 775} 776 777/** 778 * Disable all enabled plugins 779 * 780 * @returns Result indicating success/failure with count of disabled plugins 781 */ 782export async function disableAllPluginsOp(): Promise<PluginOperationResult> { 783 const enabledPlugins = getPluginEditableScopes() 784 785 if (enabledPlugins.size === 0) { 786 return { success: true, message: 'No enabled plugins to disable' } 787 } 788 789 const disabled: string[] = [] 790 const errors: string[] = [] 791 792 for (const [pluginId] of enabledPlugins) { 793 const result = await setPluginEnabledOp(pluginId, false) 794 if (result.success) { 795 disabled.push(pluginId) 796 } else { 797 errors.push(`${pluginId}: ${result.message}`) 798 } 799 } 800 801 if (errors.length > 0) { 802 return { 803 success: false, 804 message: `Disabled ${disabled.length} ${plural(disabled.length, 'plugin')}, ${errors.length} failed:\n${errors.join('\n')}`, 805 } 806 } 807 808 return { 809 success: true, 810 message: `Disabled ${disabled.length} ${plural(disabled.length, 'plugin')}`, 811 } 812} 813 814/** 815 * Update a plugin to the latest version. 816 * 817 * This function performs a NON-INPLACE update: 818 * 1. Gets the plugin info from the marketplace 819 * 2. For remote plugins: downloads to temp dir and calculates version 820 * 3. For local plugins: calculates version from marketplace source 821 * 4. If version differs from currently installed, copies to new versioned cache directory 822 * 5. Updates installation in V2 file (memory stays unchanged until restart) 823 * 6. Cleans up old version if no longer referenced by any installation 824 * 825 * @param plugin Plugin name or plugin@marketplace identifier 826 * @param scope Scope to update. Unlike install/uninstall/enable/disable, managed scope IS allowed. 827 * @returns Result indicating success/failure with version info 828 */ 829export async function updatePluginOp( 830 plugin: string, 831 scope: PluginScope, 832): Promise<PluginUpdateResult> { 833 // Parse the plugin identifier to get the full plugin ID 834 const { name: pluginName, marketplace: marketplaceName } = 835 parsePluginIdentifier(plugin) 836 const pluginId = marketplaceName ? `${pluginName}@${marketplaceName}` : plugin 837 838 // Get plugin info from marketplace 839 const pluginInfo = await getPluginById(plugin) 840 if (!pluginInfo) { 841 return { 842 success: false, 843 message: `Plugin "${pluginName}" not found`, 844 pluginId, 845 scope, 846 } 847 } 848 849 const { entry, marketplaceInstallLocation } = pluginInfo 850 851 // Get installations from disk 852 const diskData = loadInstalledPluginsFromDisk() 853 const installations = diskData.plugins[pluginId] 854 855 if (!installations || installations.length === 0) { 856 return { 857 success: false, 858 message: `Plugin "${pluginName}" is not installed`, 859 pluginId, 860 scope, 861 } 862 } 863 864 // Determine projectPath based on scope 865 const projectPath = getProjectPathForScope(scope) 866 867 // Find the installation for this scope 868 const installation = installations.find( 869 inst => inst.scope === scope && inst.projectPath === projectPath, 870 ) 871 if (!installation) { 872 const scopeDesc = projectPath ? `${scope} (${projectPath})` : scope 873 return { 874 success: false, 875 message: `Plugin "${pluginName}" is not installed at scope ${scopeDesc}`, 876 pluginId, 877 scope, 878 } 879 } 880 881 return performPluginUpdate({ 882 pluginId, 883 pluginName, 884 entry, 885 marketplaceInstallLocation, 886 installation, 887 scope, 888 projectPath, 889 }) 890} 891 892/** 893 * Perform the actual plugin update: fetch source, calculate version, copy to cache, update disk. 894 * This is the core update execution extracted from updatePluginOp. 895 */ 896async function performPluginUpdate({ 897 pluginId, 898 pluginName, 899 entry, 900 marketplaceInstallLocation, 901 installation, 902 scope, 903 projectPath, 904}: { 905 pluginId: string 906 pluginName: string 907 entry: PluginMarketplaceEntry 908 marketplaceInstallLocation: string 909 installation: { version?: string; installPath: string } 910 scope: PluginScope 911 projectPath: string | undefined 912}): Promise<PluginUpdateResult> { 913 const fs = getFsImplementation() 914 const oldVersion = installation.version 915 916 let sourcePath: string 917 let newVersion: string 918 let shouldCleanupSource = false 919 let gitCommitSha: string | undefined 920 921 // Handle remote vs local plugins 922 if (typeof entry.source !== 'string') { 923 // Remote plugin: download to temp directory first 924 const cacheResult = await cachePlugin(entry.source, { 925 manifest: { name: entry.name }, 926 }) 927 sourcePath = cacheResult.path 928 shouldCleanupSource = true 929 gitCommitSha = cacheResult.gitCommitSha 930 931 // Calculate version from downloaded plugin. For git-subdir sources, 932 // cachePlugin captured the commit SHA before discarding the ephemeral 933 // clone (the extracted subdir has no .git, so the installPath-based 934 // fallback in calculatePluginVersion can't recover it). 935 newVersion = await calculatePluginVersion( 936 pluginId, 937 entry.source, 938 cacheResult.manifest, 939 cacheResult.path, 940 entry.version, 941 cacheResult.gitCommitSha, 942 ) 943 } else { 944 // Local plugin: use path from marketplace 945 // Stat directly — handle ENOENT inline rather than pre-checking existence 946 let marketplaceStats 947 try { 948 marketplaceStats = await fs.stat(marketplaceInstallLocation) 949 } catch (e: unknown) { 950 if (isENOENT(e)) { 951 return { 952 success: false, 953 message: `Marketplace directory not found at ${marketplaceInstallLocation}`, 954 pluginId, 955 scope, 956 } 957 } 958 throw e 959 } 960 const marketplaceDir = marketplaceStats.isDirectory() 961 ? marketplaceInstallLocation 962 : dirname(marketplaceInstallLocation) 963 sourcePath = join(marketplaceDir, entry.source) 964 965 // Verify sourcePath exists. This stat is required — neither downstream 966 // op reliably surfaces ENOENT: 967 // 1. calculatePluginVersion → findGitRoot walks UP past a missing dir 968 // to the marketplace .git, returning the same SHA as install-time → 969 // silent false-positive {success: true, alreadyUpToDate: true}. 970 // 2. copyPluginToVersionedCache (when versions differ) throws a raw 971 // ENOENT with no friendly message. 972 // TOCTOU is negligible for a user-managed local dir. 973 try { 974 await fs.stat(sourcePath) 975 } catch (e: unknown) { 976 if (isENOENT(e)) { 977 return { 978 success: false, 979 message: `Plugin source not found at ${sourcePath}`, 980 pluginId, 981 scope, 982 } 983 } 984 throw e 985 } 986 987 // Try to load manifest from plugin directory (for version info) 988 let pluginManifest: PluginManifest | undefined 989 const manifestPath = join(sourcePath, '.claude-plugin', 'plugin.json') 990 try { 991 pluginManifest = await loadPluginManifest( 992 manifestPath, 993 entry.name, 994 entry.source, 995 ) 996 } catch { 997 // Failed to load - will use other version sources 998 } 999 1000 // Calculate version from plugin source path 1001 newVersion = await calculatePluginVersion( 1002 pluginId, 1003 entry.source, 1004 pluginManifest, 1005 sourcePath, 1006 entry.version, 1007 ) 1008 } 1009 1010 // Use try/finally to ensure temp directory cleanup on any error 1011 try { 1012 // Check if this version already exists in cache 1013 let versionedPath = getVersionedCachePath(pluginId, newVersion) 1014 1015 // Check if installation is already at the new version 1016 const zipPath = getVersionedZipCachePath(pluginId, newVersion) 1017 const isUpToDate = 1018 installation.version === newVersion || 1019 installation.installPath === versionedPath || 1020 installation.installPath === zipPath 1021 if (isUpToDate) { 1022 return { 1023 success: true, 1024 message: `${pluginName} is already at the latest version (${newVersion}).`, 1025 pluginId, 1026 newVersion, 1027 oldVersion, 1028 alreadyUpToDate: true, 1029 scope, 1030 } 1031 } 1032 1033 // Copy to versioned cache (returns actual path, which may be .zip) 1034 versionedPath = await copyPluginToVersionedCache( 1035 sourcePath, 1036 pluginId, 1037 newVersion, 1038 entry, 1039 ) 1040 1041 // Store old version path for potential cleanup 1042 const oldVersionPath = installation.installPath 1043 1044 // Update disk JSON file for this installation 1045 // (memory stays unchanged until restart) 1046 updateInstallationPathOnDisk( 1047 pluginId, 1048 scope, 1049 projectPath, 1050 versionedPath, 1051 newVersion, 1052 gitCommitSha, 1053 ) 1054 1055 if (oldVersionPath && oldVersionPath !== versionedPath) { 1056 const updatedDiskData = loadInstalledPluginsFromDisk() 1057 const isOldVersionStillReferenced = Object.values( 1058 updatedDiskData.plugins, 1059 ).some(pluginInstallations => 1060 pluginInstallations.some(inst => inst.installPath === oldVersionPath), 1061 ) 1062 1063 if (!isOldVersionStillReferenced) { 1064 await markPluginVersionOrphaned(oldVersionPath) 1065 } 1066 } 1067 1068 const scopeDesc = projectPath ? `${scope} (${projectPath})` : scope 1069 const message = `Plugin "${pluginName}" updated from ${oldVersion || 'unknown'} to ${newVersion} for scope ${scopeDesc}. Restart to apply changes.` 1070 1071 return { 1072 success: true, 1073 message, 1074 pluginId, 1075 newVersion, 1076 oldVersion, 1077 scope, 1078 } 1079 } finally { 1080 // Clean up temp source if it was a remote download 1081 if ( 1082 shouldCleanupSource && 1083 sourcePath !== getVersionedCachePath(pluginId, newVersion) 1084 ) { 1085 await fs.rm(sourcePath, { recursive: true, force: true }) 1086 } 1087 } 1088}