/ services / plugins / pluginOperations.ts
pluginOperations.ts
   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   */
  14  import { dirname, join } from 'path'
  15  import { getOriginalCwd } from '../../bootstrap/state.js'
  16  import { isBuiltinPluginId } from '../../plugins/builtinPlugins.js'
  17  import type { LoadedPlugin, PluginManifest } from '../../types/plugin.js'
  18  import { isENOENT, toError } from '../../utils/errors.js'
  19  import { getFsImplementation } from '../../utils/fsOperations.js'
  20  import { logError } from '../../utils/log.js'
  21  import {
  22    clearAllCaches,
  23    markPluginVersionOrphaned,
  24  } from '../../utils/plugins/cacheUtils.js'
  25  import {
  26    findReverseDependents,
  27    formatReverseDependentsSuffix,
  28  } from '../../utils/plugins/dependencyResolver.js'
  29  import {
  30    loadInstalledPluginsFromDisk,
  31    loadInstalledPluginsV2,
  32    removePluginInstallation,
  33    updateInstallationPathOnDisk,
  34  } from '../../utils/plugins/installedPluginsManager.js'
  35  import {
  36    getMarketplace,
  37    getPluginById,
  38    loadKnownMarketplacesConfig,
  39  } from '../../utils/plugins/marketplaceManager.js'
  40  import { deletePluginDataDir } from '../../utils/plugins/pluginDirectories.js'
  41  import {
  42    parsePluginIdentifier,
  43    scopeToSettingSource,
  44  } from '../../utils/plugins/pluginIdentifier.js'
  45  import {
  46    formatResolutionError,
  47    installResolvedPlugin,
  48  } from '../../utils/plugins/pluginInstallationHelpers.js'
  49  import {
  50    cachePlugin,
  51    copyPluginToVersionedCache,
  52    getVersionedCachePath,
  53    getVersionedZipCachePath,
  54    loadAllPlugins,
  55    loadPluginManifest,
  56  } from '../../utils/plugins/pluginLoader.js'
  57  import { deletePluginOptions } from '../../utils/plugins/pluginOptionsStorage.js'
  58  import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'
  59  import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js'
  60  import { calculatePluginVersion } from '../../utils/plugins/pluginVersioning.js'
  61  import type {
  62    PluginMarketplaceEntry,
  63    PluginScope,
  64  } from '../../utils/plugins/schemas.js'
  65  import {
  66    getSettingsForSource,
  67    updateSettingsForSource,
  68  } from '../../utils/settings/settings.js'
  69  import { plural } from '../../utils/stringUtils.js'
  70  
  71  /** Valid installable scopes (excludes 'managed' which can only be installed from managed-settings.json) */
  72  export const VALID_INSTALLABLE_SCOPES = ['user', 'project', 'local'] as const
  73  
  74  /** Installation scope type derived from VALID_INSTALLABLE_SCOPES */
  75  export type InstallableScope = (typeof VALID_INSTALLABLE_SCOPES)[number]
  76  
  77  /** Valid scopes for update operations (includes 'managed' since managed plugins can be updated) */
  78  export 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   */
  90  export 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   */
 104  export 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   */
 114  export 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   */
 128  export 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   */
 141  export 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   */
 154  export 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   */
 180  function 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   */
 206  function 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   */
 230  function 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   */
 258  export 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   */
 321  export 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   */
 427  export 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   */
 573  export 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   */
 756  export 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   */
 770  export 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   */
 782  export 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   */
 829  export 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   */
 896  async 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  }