Download src/services/plugins/pluginOperations.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 35.6 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/services/plugins/pluginOperations.ts
- Command line
-
hf download hf://chenbhao/codev/src/services/plugins/pluginOperations.ts
-
curl -L -o pluginOperations.ts https://huggingface.co/chenbhao/codev/resolve/main/src/services/plugins/pluginOperations.ts
35.6 kB
| /** | |
| * Core plugin operations (install, uninstall, enable, disable, update) | |
| * | |
| * This module provides pure library functions that can be used by both: | |
| * - CLI commands (`claude plugin install/uninstall/enable/disable/update`) | |
| * - Interactive UI (ManagePlugins.tsx) | |
| * | |
| * Functions in this module: | |
| * - Do NOT call process.exit() | |
| * - Do NOT write to console | |
| * - Return result objects indicating success/failure with messages | |
| * - Can throw errors for unexpected failures | |
| */ | |
| import { dirname, join } from 'path' | |
| import { getOriginalCwd } from '../../bootstrap/state.js' | |
| import { isBuiltinPluginId } from '../../plugins/builtinPlugins.js' | |
| import type { LoadedPlugin, PluginManifest } from '../../types/plugin.js' | |
| import { isENOENT, toError } from '../../utils/errors.js' | |
| import { getFsImplementation } from '../../utils/fsOperations.js' | |
| import { logError } from '../../utils/log.js' | |
| import { | |
| clearAllCaches, | |
| markPluginVersionOrphaned, | |
| } from '../../utils/plugins/cacheUtils.js' | |
| import { | |
| findReverseDependents, | |
| formatReverseDependentsSuffix, | |
| } from '../../utils/plugins/dependencyResolver.js' | |
| import { | |
| loadInstalledPluginsFromDisk, | |
| loadInstalledPluginsV2, | |
| removePluginInstallation, | |
| updateInstallationPathOnDisk, | |
| } from '../../utils/plugins/installedPluginsManager.js' | |
| import { | |
| getMarketplace, | |
| getPluginById, | |
| loadKnownMarketplacesConfig, | |
| } from '../../utils/plugins/marketplaceManager.js' | |
| import { deletePluginDataDir } from '../../utils/plugins/pluginDirectories.js' | |
| import { | |
| parsePluginIdentifier, | |
| scopeToSettingSource, | |
| } from '../../utils/plugins/pluginIdentifier.js' | |
| import { | |
| formatResolutionError, | |
| installResolvedPlugin, | |
| } from '../../utils/plugins/pluginInstallationHelpers.js' | |
| import { | |
| cachePlugin, | |
| copyPluginToVersionedCache, | |
| getVersionedCachePath, | |
| getVersionedZipCachePath, | |
| loadAllPlugins, | |
| loadPluginManifest, | |
| } from '../../utils/plugins/pluginLoader.js' | |
| import { deletePluginOptions } from '../../utils/plugins/pluginOptionsStorage.js' | |
| import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js' | |
| import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js' | |
| import { calculatePluginVersion } from '../../utils/plugins/pluginVersioning.js' | |
| import type { | |
| PluginMarketplaceEntry, | |
| PluginScope, | |
| } from '../../utils/plugins/schemas.js' | |
| import { | |
| getSettingsForSource, | |
| updateSettingsForSource, | |
| } from '../../utils/settings/settings.js' | |
| import { plural } from '../../utils/stringUtils.js' | |
| /** Valid installable scopes (excludes 'managed' which can only be installed from managed-settings.json) */ | |
| export const VALID_INSTALLABLE_SCOPES = ['user', 'project', 'local'] as const | |
| /** Installation scope type derived from VALID_INSTALLABLE_SCOPES */ | |
| export type InstallableScope = (typeof VALID_INSTALLABLE_SCOPES)[number] | |
| /** Valid scopes for update operations (includes 'managed' since managed plugins can be updated) */ | |
| export const VALID_UPDATE_SCOPES: readonly PluginScope[] = [ | |
| 'user', | |
| 'project', | |
| 'local', | |
| 'managed', | |
| ] as const | |
| /** | |
| * Assert that a scope is a valid installable scope at runtime | |
| * @param scope The scope to validate | |
| * @throws Error if scope is not a valid installable scope | |
| */ | |
| export function assertInstallableScope( | |
| scope: string, | |
| ): asserts scope is InstallableScope { | |
| if (!VALID_INSTALLABLE_SCOPES.includes(scope as InstallableScope)) { | |
| throw new Error( | |
| `Invalid scope "${scope}". Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}`, | |
| ) | |
| } | |
| } | |
| /** | |
| * Type guard to check if a scope is an installable scope (not 'managed'). | |
| * Use this for type narrowing in conditional blocks. | |
| */ | |
| export function isInstallableScope( | |
| scope: PluginScope, | |
| ): scope is InstallableScope { | |
| return VALID_INSTALLABLE_SCOPES.includes(scope as InstallableScope) | |
| } | |
| /** | |
| * Get the project path for scopes that are project-specific. | |
| * Returns the original cwd for 'project' and 'local' scopes, undefined otherwise. | |
| */ | |
| export function getProjectPathForScope(scope: PluginScope): string | undefined { | |
| return scope === 'project' || scope === 'local' ? getOriginalCwd() : undefined | |
| } | |
| /** | |
| * Is this plugin enabled (value === true) in .claude/settings.json? | |
| * | |
| * Distinct from V2 installed_plugins.json scope: that file tracks where a | |
| * plugin was *installed from*, but the same plugin can also be enabled at | |
| * project scope via settings. The uninstall UI needs to check THIS, because | |
| * a user-scope install with a project-scope enablement means "uninstall" | |
| * would succeed at removing the user install while leaving the project | |
| * enablement active — the plugin keeps running. | |
| */ | |
| export function isPluginEnabledAtProjectScope(pluginId: string): boolean { | |
| return ( | |
| getSettingsForSource('projectSettings')?.enabledPlugins?.[pluginId] === true | |
| ) | |
| } | |
| // ============================================================================ | |
| // Result Types | |
| // ============================================================================ | |
| /** | |
| * Result of a plugin operation | |
| */ | |
| export type PluginOperationResult = { | |
| success: boolean | |
| message: string | |
| pluginId?: string | |
| pluginName?: string | |
| scope?: PluginScope | |
| /** Plugins that declare this plugin as a dependency (warning on uninstall/disable) */ | |
| reverseDependents?: string[] | |
| } | |
| /** | |
| * Result of a plugin update operation | |
| */ | |
| export type PluginUpdateResult = { | |
| success: boolean | |
| message: string | |
| pluginId?: string | |
| newVersion?: string | |
| oldVersion?: string | |
| alreadyUpToDate?: boolean | |
| scope?: PluginScope | |
| } | |
| // ============================================================================ | |
| // Helper Functions | |
| // ============================================================================ | |
| /** | |
| * Search all editable settings scopes for a plugin ID matching the given input. | |
| * | |
| * If `plugin` contains `@`, it's treated as a full pluginId and returned if | |
| * found in any scope. If `plugin` is a bare name, searches for any key | |
| * starting with `{plugin}@` in any scope. | |
| * | |
| * Returns the most specific scope where the plugin is mentioned (regardless | |
| * of enabled/disabled state) plus the resolved full pluginId. | |
| * | |
| * Precedence: local > project > user (most specific wins). | |
| */ | |
| function findPluginInSettings(plugin: string): { | |
| pluginId: string | |
| scope: InstallableScope | |
| } | null { | |
| const hasMarketplace = plugin.includes('@') | |
| // Most specific first — first match wins | |
| const searchOrder: InstallableScope[] = ['local', 'project', 'user'] | |
| for (const scope of searchOrder) { | |
| const enabledPlugins = getSettingsForSource( | |
| scopeToSettingSource(scope), | |
| )?.enabledPlugins | |
| if (!enabledPlugins) continue | |
| for (const key of Object.keys(enabledPlugins)) { | |
| if (hasMarketplace ? key === plugin : key.startsWith(`${plugin}@`)) { | |
| return { pluginId: key, scope } | |
| } | |
| } | |
| } | |
| return null | |
| } | |
| /** | |
| * Helper function to find a plugin from loaded plugins | |
| */ | |
| function findPluginByIdentifier( | |
| plugin: string, | |
| plugins: LoadedPlugin[], | |
| ): LoadedPlugin | undefined { | |
| const { name, marketplace } = parsePluginIdentifier(plugin) | |
| return plugins.find(p => { | |
| // Check exact name match | |
| if (p.name === plugin || p.name === name) return true | |
| // If marketplace specified, check if it matches the source | |
| if (marketplace && p.source) { | |
| return p.name === name && p.source.includes(`@${marketplace}`) | |
| } | |
| return false | |
| }) | |
| } | |
| /** | |
| * Resolve a plugin ID from V2 installed plugins data for a plugin that may | |
| * have been delisted from its marketplace. Returns null if the plugin is not | |
| * found in V2 data. | |
| */ | |
| function resolveDelistedPluginId( | |
| plugin: string, | |
| ): { pluginId: string; pluginName: string } | null { | |
| const { name } = parsePluginIdentifier(plugin) | |
| const installedData = loadInstalledPluginsV2() | |
| // Try exact match first, then search by name | |
| if (installedData.plugins[plugin]?.length) { | |
| return { pluginId: plugin, pluginName: name } | |
| } | |
| const matchingKey = Object.keys(installedData.plugins).find(key => { | |
| const { name: keyName } = parsePluginIdentifier(key) | |
| return keyName === name && (installedData.plugins[key]?.length ?? 0) > 0 | |
| }) | |
| if (matchingKey) { | |
| return { pluginId: matchingKey, pluginName: name } | |
| } | |
| return null | |
| } | |
| /** | |
| * Get the most relevant installation for a plugin from V2 data. | |
| * For project/local scoped plugins, prioritizes installations matching the current project. | |
| * Priority order: local (matching project) > project (matching project) > user > first available | |
| */ | |
| export function getPluginInstallationFromV2(pluginId: string): { | |
| scope: PluginScope | |
| projectPath?: string | |
| } { | |
| const installedData = loadInstalledPluginsV2() | |
| const installations = installedData.plugins[pluginId] | |
| if (!installations || installations.length === 0) { | |
| return { scope: 'user' } | |
| } | |
| const currentProjectPath = getOriginalCwd() | |
| // Find installations by priority: local > project > user > managed | |
| const localInstall = installations.find( | |
| inst => inst.scope === 'local' && inst.projectPath === currentProjectPath, | |
| ) | |
| if (localInstall) { | |
| return { scope: localInstall.scope, projectPath: localInstall.projectPath } | |
| } | |
| const projectInstall = installations.find( | |
| inst => inst.scope === 'project' && inst.projectPath === currentProjectPath, | |
| ) | |
| if (projectInstall) { | |
| return { | |
| scope: projectInstall.scope, | |
| projectPath: projectInstall.projectPath, | |
| } | |
| } | |
| const userInstall = installations.find(inst => inst.scope === 'user') | |
| if (userInstall) { | |
| return { scope: userInstall.scope } | |
| } | |
| // Fall back to first installation (could be managed) | |
| return { | |
| scope: installations[0]!.scope, | |
| projectPath: installations[0]!.projectPath, | |
| } | |
| } | |
| // ============================================================================ | |
| // Core Operations | |
| // ============================================================================ | |
| /** | |
| * Install a plugin (settings-first). | |
| * | |
| * Order of operations: | |
| * 1. Search materialized marketplaces for the plugin | |
| * 2. Write settings (THE ACTION — declares intent) | |
| * 3. Cache plugin + record version hint (materialization) | |
| * | |
| * Marketplace reconciliation is NOT this function's responsibility — startup | |
| * reconcile handles declared-but-not-materialized marketplaces. If the | |
| * marketplace isn't found, "not found" is the correct error. | |
| * | |
| * @param plugin Plugin identifier (name or plugin@marketplace) | |
| * @param scope Installation scope: user, project, or local (defaults to 'user') | |
| * @returns Result indicating success/failure | |
| */ | |
| export async function installPluginOp( | |
| plugin: string, | |
| scope: InstallableScope = 'user', | |
| ): Promise<PluginOperationResult> { | |
| assertInstallableScope(scope) | |
| const { name: pluginName, marketplace: marketplaceName } = | |
| parsePluginIdentifier(plugin) | |
| // ── Search materialized marketplaces for the plugin ── | |
| let foundPlugin: PluginMarketplaceEntry | undefined | |
| let foundMarketplace: string | undefined | |
| let marketplaceInstallLocation: string | undefined | |
| if (marketplaceName) { | |
| const pluginInfo = await getPluginById(plugin) | |
| if (pluginInfo) { | |
| foundPlugin = pluginInfo.entry | |
| foundMarketplace = marketplaceName | |
| marketplaceInstallLocation = pluginInfo.marketplaceInstallLocation | |
| } | |
| } else { | |
| const marketplaces = await loadKnownMarketplacesConfig() | |
| for (const [mktName, mktConfig] of Object.entries(marketplaces)) { | |
| try { | |
| const marketplace = await getMarketplace(mktName) | |
| const pluginEntry = marketplace.plugins.find(p => p.name === pluginName) | |
| if (pluginEntry) { | |
| foundPlugin = pluginEntry | |
| foundMarketplace = mktName | |
| marketplaceInstallLocation = mktConfig.installLocation | |
| break | |
| } | |
| } catch (error) { | |
| logError(toError(error)) | |
| continue | |
| } | |
| } | |
| } | |
| if (!foundPlugin || !foundMarketplace) { | |
| const location = marketplaceName | |
| ? `marketplace "${marketplaceName}"` | |
| : 'any configured marketplace' | |
| return { | |
| success: false, | |
| message: `Plugin "${pluginName}" not found in ${location}`, | |
| } | |
| } | |
| const entry = foundPlugin | |
| const pluginId = `${entry.name}@${foundMarketplace}` | |
| const result = await installResolvedPlugin({ | |
| pluginId, | |
| entry, | |
| scope, | |
| marketplaceInstallLocation, | |
| }) | |
| if (!result.ok) { | |
| switch (result.reason) { | |
| case 'local-source-no-location': | |
| return { | |
| success: false, | |
| message: `Cannot install local plugin "${result.pluginName}" without marketplace install location`, | |
| } | |
| case 'settings-write-failed': | |
| return { | |
| success: false, | |
| message: `Failed to update settings: ${result.message}`, | |
| } | |
| case 'resolution-failed': | |
| return { | |
| success: false, | |
| message: formatResolutionError(result.resolution), | |
| } | |
| case 'blocked-by-policy': | |
| return { | |
| success: false, | |
| message: `Plugin "${result.pluginName}" is blocked by your organization's policy and cannot be installed`, | |
| } | |
| case 'dependency-blocked-by-policy': | |
| return { | |
| success: false, | |
| message: `Plugin "${result.pluginName}" depends on "${result.blockedDependency}", which is blocked by your organization's policy`, | |
| } | |
| } | |
| } | |
| return { | |
| success: true, | |
| message: `Successfully installed plugin: ${pluginId} (scope: ${scope})${result.depNote}`, | |
| pluginId, | |
| pluginName: entry.name, | |
| scope, | |
| } | |
| } | |
| /** | |
| * Uninstall a plugin | |
| * | |
| * @param plugin Plugin name or plugin@marketplace identifier | |
| * @param scope Uninstall from scope: user, project, or local (defaults to 'user') | |
| * @returns Result indicating success/failure | |
| */ | |
| export async function uninstallPluginOp( | |
| plugin: string, | |
| scope: InstallableScope = 'user', | |
| deleteDataDir = true, | |
| ): Promise<PluginOperationResult> { | |
| // Validate scope at runtime for early error detection | |
| assertInstallableScope(scope) | |
| const { enabled, disabled } = await loadAllPlugins() | |
| const allPlugins = [...enabled, ...disabled] | |
| // Find the plugin | |
| const foundPlugin = findPluginByIdentifier(plugin, allPlugins) | |
| const settingSource = scopeToSettingSource(scope) | |
| const settings = getSettingsForSource(settingSource) | |
| let pluginId: string | |
| let pluginName: string | |
| if (foundPlugin) { | |
| // Find the matching settings key for this plugin (may differ from `plugin` | |
| // if user gave short name but settings has plugin@marketplace) | |
| pluginId = | |
| Object.keys(settings?.enabledPlugins ?? {}).find( | |
| k => | |
| k === plugin || | |
| k === foundPlugin.name || | |
| k.startsWith(`${foundPlugin.name}@`), | |
| ) ?? (plugin.includes('@') ? plugin : foundPlugin.name) | |
| pluginName = foundPlugin.name | |
| } else { | |
| // Plugin not found via marketplace lookup — it may have been delisted. | |
| // Fall back to installed_plugins.json (V2) which tracks installations | |
| // independently of marketplace state. | |
| const resolved = resolveDelistedPluginId(plugin) | |
| if (!resolved) { | |
| return { | |
| success: false, | |
| message: `Plugin "${plugin}" not found in installed plugins`, | |
| } | |
| } | |
| pluginId = resolved.pluginId | |
| pluginName = resolved.pluginName | |
| } | |
| // Check if the plugin is installed in this scope (in V2 file) | |
| const projectPath = getProjectPathForScope(scope) | |
| const installedData = loadInstalledPluginsV2() | |
| const installations = installedData.plugins[pluginId] | |
| const scopeInstallation = installations?.find( | |
| i => i.scope === scope && i.projectPath === projectPath, | |
| ) | |
| if (!scopeInstallation) { | |
| // Try to find where the plugin is actually installed to provide a helpful error | |
| const { scope: actualScope } = getPluginInstallationFromV2(pluginId) | |
| if (actualScope !== scope && installations && installations.length > 0) { | |
| // Project scope is special: .claude/settings.json is shared with the team. | |
| // Point users at the local-override escape hatch instead of --scope project. | |
| if (actualScope === 'project') { | |
| return { | |
| success: false, | |
| 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`, | |
| } | |
| } | |
| return { | |
| success: false, | |
| message: `Plugin "${plugin}" is installed in ${actualScope} scope, not ${scope}. Use --scope ${actualScope} to uninstall.`, | |
| } | |
| } | |
| return { | |
| success: false, | |
| message: `Plugin "${plugin}" is not installed in ${scope} scope. Use --scope to specify the correct scope.`, | |
| } | |
| } | |
| const installPath = scopeInstallation.installPath | |
| // Remove the plugin from the appropriate settings file (delete key entirely) | |
| // Use undefined to signal deletion via mergeWith in updateSettingsForSource | |
| const newEnabledPlugins: Record<string, boolean | string[] | undefined> = { | |
| ...settings?.enabledPlugins, | |
| } | |
| newEnabledPlugins[pluginId] = undefined | |
| updateSettingsForSource(settingSource, { | |
| enabledPlugins: newEnabledPlugins, | |
| }) | |
| clearAllCaches() | |
| // Remove from installed_plugins_v2.json for this scope | |
| removePluginInstallation(pluginId, scope, projectPath) | |
| const updatedData = loadInstalledPluginsV2() | |
| const remainingInstallations = updatedData.plugins[pluginId] | |
| const isLastScope = | |
| !remainingInstallations || remainingInstallations.length === 0 | |
| if (isLastScope && installPath) { | |
| await markPluginVersionOrphaned(installPath) | |
| } | |
| // Separate from the `&& installPath` guard above — deletePluginOptions only | |
| // needs pluginId, not installPath. Last scope removed → wipe stored options | |
| // and secrets. Before this, uninstalling left orphaned entries in | |
| // settings.pluginConfigs (including the legacy ungated mcpServers sub-key | |
| // from the MCPB Configure flow) and keychain pluginSecrets forever. No | |
| // feature gate: deletePluginOptions no-ops when nothing is stored, and | |
| // pluginConfigs.mcpServers is written ungated so its cleanup must run | |
| // ungated too. | |
| if (isLastScope) { | |
| deletePluginOptions(pluginId) | |
| if (deleteDataDir) { | |
| await deletePluginDataDir(pluginId) | |
| } | |
| } | |
| // Warn (don't block) if other enabled plugins depend on this one. | |
| // Blocking creates tombstones — can't tear down a graph with a delisted | |
| // plugin. Load-time verifyAndDemote catches the fallout. | |
| const reverseDependents = findReverseDependents(pluginId, allPlugins) | |
| const depWarn = formatReverseDependentsSuffix(reverseDependents) | |
| return { | |
| success: true, | |
| message: `Successfully uninstalled plugin: ${pluginName} (scope: ${scope})${depWarn}`, | |
| pluginId, | |
| pluginName, | |
| scope, | |
| reverseDependents: | |
| reverseDependents.length > 0 ? reverseDependents : undefined, | |
| } | |
| } | |
| /** | |
| * Set plugin enabled/disabled status (settings-first). | |
| * | |
| * Resolves the plugin ID and scope from settings — does NOT pre-gate on | |
| * installed_plugins.json. Settings declares intent; if the plugin isn't | |
| * cached yet, the next load will cache it. | |
| * | |
| * @param plugin Plugin name or plugin@marketplace identifier | |
| * @param enabled true to enable, false to disable | |
| * @param scope Optional scope. If not provided, auto-detects the most specific | |
| * scope where the plugin is mentioned in settings. | |
| * @returns Result indicating success/failure | |
| */ | |
| export async function setPluginEnabledOp( | |
| plugin: string, | |
| enabled: boolean, | |
| scope?: InstallableScope, | |
| ): Promise<PluginOperationResult> { | |
| const operation = enabled ? 'enable' : 'disable' | |
| // Built-in plugins: always use user-scope settings, bypass the normal | |
| // scope-resolution + installed_plugins lookup (they're not installed). | |
| if (isBuiltinPluginId(plugin)) { | |
| const { error } = updateSettingsForSource('userSettings', { | |
| enabledPlugins: { | |
| ...getSettingsForSource('userSettings')?.enabledPlugins, | |
| [plugin]: enabled, | |
| }, | |
| }) | |
| if (error) { | |
| return { | |
| success: false, | |
| message: `Failed to ${operation} built-in plugin: ${error.message}`, | |
| } | |
| } | |
| clearAllCaches() | |
| const { name: pluginName } = parsePluginIdentifier(plugin) | |
| return { | |
| success: true, | |
| message: `Successfully ${operation}d built-in plugin: ${pluginName}`, | |
| pluginId: plugin, | |
| pluginName, | |
| scope: 'user', | |
| } | |
| } | |
| if (scope) { | |
| assertInstallableScope(scope) | |
| } | |
| // ── Resolve pluginId and scope from settings ── | |
| // Search across editable scopes for any mention (enabled or disabled) of | |
| // this plugin. Does NOT pre-gate on installed_plugins.json. | |
| let pluginId: string | |
| let resolvedScope: InstallableScope | |
| const found = findPluginInSettings(plugin) | |
| if (scope) { | |
| // Explicit scope: use it. Resolve pluginId from settings if possible, | |
| // otherwise require a full plugin@marketplace identifier. | |
| resolvedScope = scope | |
| if (found) { | |
| pluginId = found.pluginId | |
| } else if (plugin.includes('@')) { | |
| pluginId = plugin | |
| } else { | |
| return { | |
| success: false, | |
| message: `Plugin "${plugin}" not found in settings. Use plugin@marketplace format.`, | |
| } | |
| } | |
| } else if (found) { | |
| // Auto-detect scope: use the most specific scope where the plugin is | |
| // mentioned in settings. | |
| pluginId = found.pluginId | |
| resolvedScope = found.scope | |
| } else if (plugin.includes('@')) { | |
| // Not in any settings scope, but full pluginId given — default to user | |
| // scope (matches install default). This allows enabling a plugin that | |
| // was cached but never declared. | |
| pluginId = plugin | |
| resolvedScope = 'user' | |
| } else { | |
| return { | |
| success: false, | |
| message: `Plugin "${plugin}" not found in any editable settings scope. Use plugin@marketplace format.`, | |
| } | |
| } | |
| // ── Policy guard ── | |
| // Org-blocked plugins cannot be enabled at any scope. Check after pluginId | |
| // is resolved so we catch both full identifiers and bare-name lookups. | |
| if (enabled && isPluginBlockedByPolicy(pluginId)) { | |
| return { | |
| success: false, | |
| message: `Plugin "${pluginId}" is blocked by your organization's policy and cannot be enabled`, | |
| } | |
| } | |
| const settingSource = scopeToSettingSource(resolvedScope) | |
| const scopeSettingsValue = | |
| getSettingsForSource(settingSource)?.enabledPlugins?.[pluginId] | |
| // ── Cross-scope hint: explicit scope given but plugin is elsewhere ── | |
| // If the plugin is absent from the requested scope but present at a | |
| // different scope, guide the user to the right --scope — UNLESS they're | |
| // writing to a higher-precedence scope to override a lower one | |
| // (e.g. `disable --scope local` to override a project-enabled plugin | |
| // without touching the shared .claude/settings.json). | |
| const SCOPE_PRECEDENCE: Record<InstallableScope, number> = { | |
| user: 0, | |
| project: 1, | |
| local: 2, | |
| } | |
| const isOverride = | |
| scope && found && SCOPE_PRECEDENCE[scope] > SCOPE_PRECEDENCE[found.scope] | |
| if ( | |
| scope && | |
| scopeSettingsValue === undefined && | |
| found && | |
| found.scope !== scope && | |
| !isOverride | |
| ) { | |
| return { | |
| success: false, | |
| message: `Plugin "${plugin}" is installed at ${found.scope} scope, not ${scope}. Use --scope ${found.scope} or omit --scope to auto-detect.`, | |
| } | |
| } | |
| // ── Check current state (for idempotency messaging) ── | |
| // When explicit scope given: check that scope's settings value directly | |
| // (merged state can be wrong if plugin is enabled elsewhere but disabled here). | |
| // When auto-detected: use merged effective state. | |
| // When overriding a lower scope: check merged state — scopeSettingsValue is | |
| // undefined (plugin not in this scope yet), which would read as "already | |
| // disabled", but the whole point of the override is to write an explicit | |
| // `false` that masks the lower scope's `true`. | |
| const isCurrentlyEnabled = | |
| scope && !isOverride | |
| ? scopeSettingsValue === true | |
| : getPluginEditableScopes().has(pluginId) | |
| if (enabled === isCurrentlyEnabled) { | |
| return { | |
| success: false, | |
| message: `Plugin "${plugin}" is already ${enabled ? 'enabled' : 'disabled'}${scope ? ` at ${scope} scope` : ''}`, | |
| } | |
| } | |
| // On disable: capture reverse dependents from the PRE-disable snapshot, | |
| // before we write settings and clear the memoized plugin cache. | |
| let reverseDependents: string[] | undefined | |
| if (!enabled) { | |
| const { enabled: loadedEnabled, disabled } = await loadAllPlugins() | |
| const rdeps = findReverseDependents(pluginId, [ | |
| ...loadedEnabled, | |
| ...disabled, | |
| ]) | |
| if (rdeps.length > 0) reverseDependents = rdeps | |
| } | |
| // ── ACTION: write settings ── | |
| const { error } = updateSettingsForSource(settingSource, { | |
| enabledPlugins: { | |
| ...getSettingsForSource(settingSource)?.enabledPlugins, | |
| [pluginId]: enabled, | |
| }, | |
| }) | |
| if (error) { | |
| return { | |
| success: false, | |
| message: `Failed to ${operation} plugin: ${error.message}`, | |
| } | |
| } | |
| clearAllCaches() | |
| const { name: pluginName } = parsePluginIdentifier(pluginId) | |
| const depWarn = formatReverseDependentsSuffix(reverseDependents) | |
| return { | |
| success: true, | |
| message: `Successfully ${operation}d plugin: ${pluginName} (scope: ${resolvedScope})${depWarn}`, | |
| pluginId, | |
| pluginName, | |
| scope: resolvedScope, | |
| reverseDependents, | |
| } | |
| } | |
| /** | |
| * Enable a plugin | |
| * | |
| * @param plugin Plugin name or plugin@marketplace identifier | |
| * @param scope Optional scope. If not provided, finds the most specific scope for the current project. | |
| * @returns Result indicating success/failure | |
| */ | |
| export async function enablePluginOp( | |
| plugin: string, | |
| scope?: InstallableScope, | |
| ): Promise<PluginOperationResult> { | |
| return setPluginEnabledOp(plugin, true, scope) | |
| } | |
| /** | |
| * Disable a plugin | |
| * | |
| * @param plugin Plugin name or plugin@marketplace identifier | |
| * @param scope Optional scope. If not provided, finds the most specific scope for the current project. | |
| * @returns Result indicating success/failure | |
| */ | |
| export async function disablePluginOp( | |
| plugin: string, | |
| scope?: InstallableScope, | |
| ): Promise<PluginOperationResult> { | |
| return setPluginEnabledOp(plugin, false, scope) | |
| } | |
| /** | |
| * Disable all enabled plugins | |
| * | |
| * @returns Result indicating success/failure with count of disabled plugins | |
| */ | |
| export async function disableAllPluginsOp(): Promise<PluginOperationResult> { | |
| const enabledPlugins = getPluginEditableScopes() | |
| if (enabledPlugins.size === 0) { | |
| return { success: true, message: 'No enabled plugins to disable' } | |
| } | |
| const disabled: string[] = [] | |
| const errors: string[] = [] | |
| for (const [pluginId] of enabledPlugins) { | |
| const result = await setPluginEnabledOp(pluginId, false) | |
| if (result.success) { | |
| disabled.push(pluginId) | |
| } else { | |
| errors.push(`${pluginId}: ${result.message}`) | |
| } | |
| } | |
| if (errors.length > 0) { | |
| return { | |
| success: false, | |
| message: `Disabled ${disabled.length} ${plural(disabled.length, 'plugin')}, ${errors.length} failed:\n${errors.join('\n')}`, | |
| } | |
| } | |
| return { | |
| success: true, | |
| message: `Disabled ${disabled.length} ${plural(disabled.length, 'plugin')}`, | |
| } | |
| } | |
| /** | |
| * Update a plugin to the latest version. | |
| * | |
| * This function performs a NON-INPLACE update: | |
| * 1. Gets the plugin info from the marketplace | |
| * 2. For remote plugins: downloads to temp dir and calculates version | |
| * 3. For local plugins: calculates version from marketplace source | |
| * 4. If version differs from currently installed, copies to new versioned cache directory | |
| * 5. Updates installation in V2 file (memory stays unchanged until restart) | |
| * 6. Cleans up old version if no longer referenced by any installation | |
| * | |
| * @param plugin Plugin name or plugin@marketplace identifier | |
| * @param scope Scope to update. Unlike install/uninstall/enable/disable, managed scope IS allowed. | |
| * @returns Result indicating success/failure with version info | |
| */ | |
| export async function updatePluginOp( | |
| plugin: string, | |
| scope: PluginScope, | |
| ): Promise<PluginUpdateResult> { | |
| // Parse the plugin identifier to get the full plugin ID | |
| const { name: pluginName, marketplace: marketplaceName } = | |
| parsePluginIdentifier(plugin) | |
| const pluginId = marketplaceName ? `${pluginName}@${marketplaceName}` : plugin | |
| // Get plugin info from marketplace | |
| const pluginInfo = await getPluginById(plugin) | |
| if (!pluginInfo) { | |
| return { | |
| success: false, | |
| message: `Plugin "${pluginName}" not found`, | |
| pluginId, | |
| scope, | |
| } | |
| } | |
| const { entry, marketplaceInstallLocation } = pluginInfo | |
| // Get installations from disk | |
| const diskData = loadInstalledPluginsFromDisk() | |
| const installations = diskData.plugins[pluginId] | |
| if (!installations || installations.length === 0) { | |
| return { | |
| success: false, | |
| message: `Plugin "${pluginName}" is not installed`, | |
| pluginId, | |
| scope, | |
| } | |
| } | |
| // Determine projectPath based on scope | |
| const projectPath = getProjectPathForScope(scope) | |
| // Find the installation for this scope | |
| const installation = installations.find( | |
| inst => inst.scope === scope && inst.projectPath === projectPath, | |
| ) | |
| if (!installation) { | |
| const scopeDesc = projectPath ? `${scope} (${projectPath})` : scope | |
| return { | |
| success: false, | |
| message: `Plugin "${pluginName}" is not installed at scope ${scopeDesc}`, | |
| pluginId, | |
| scope, | |
| } | |
| } | |
| return performPluginUpdate({ | |
| pluginId, | |
| pluginName, | |
| entry, | |
| marketplaceInstallLocation, | |
| installation, | |
| scope, | |
| projectPath, | |
| }) | |
| } | |
| /** | |
| * Perform the actual plugin update: fetch source, calculate version, copy to cache, update disk. | |
| * This is the core update execution extracted from updatePluginOp. | |
| */ | |
| async function performPluginUpdate({ | |
| pluginId, | |
| pluginName, | |
| entry, | |
| marketplaceInstallLocation, | |
| installation, | |
| scope, | |
| projectPath, | |
| }: { | |
| pluginId: string | |
| pluginName: string | |
| entry: PluginMarketplaceEntry | |
| marketplaceInstallLocation: string | |
| installation: { version?: string; installPath: string } | |
| scope: PluginScope | |
| projectPath: string | undefined | |
| }): Promise<PluginUpdateResult> { | |
| const fs = getFsImplementation() | |
| const oldVersion = installation.version | |
| let sourcePath: string | |
| let newVersion: string | |
| let shouldCleanupSource = false | |
| let gitCommitSha: string | undefined | |
| // Handle remote vs local plugins | |
| if (typeof entry.source !== 'string') { | |
| // Remote plugin: download to temp directory first | |
| const cacheResult = await cachePlugin(entry.source, { | |
| manifest: { name: entry.name }, | |
| }) | |
| sourcePath = cacheResult.path | |
| shouldCleanupSource = true | |
| gitCommitSha = cacheResult.gitCommitSha | |
| // Calculate version from downloaded plugin. For git-subdir sources, | |
| // cachePlugin captured the commit SHA before discarding the ephemeral | |
| // clone (the extracted subdir has no .git, so the installPath-based | |
| // fallback in calculatePluginVersion can't recover it). | |
| newVersion = await calculatePluginVersion( | |
| pluginId, | |
| entry.source, | |
| cacheResult.manifest, | |
| cacheResult.path, | |
| entry.version, | |
| cacheResult.gitCommitSha, | |
| ) | |
| } else { | |
| // Local plugin: use path from marketplace | |
| // Stat directly — handle ENOENT inline rather than pre-checking existence | |
| let marketplaceStats | |
| try { | |
| marketplaceStats = await fs.stat(marketplaceInstallLocation) | |
| } catch (e: unknown) { | |
| if (isENOENT(e)) { | |
| return { | |
| success: false, | |
| message: `Marketplace directory not found at ${marketplaceInstallLocation}`, | |
| pluginId, | |
| scope, | |
| } | |
| } | |
| throw e | |
| } | |
| const marketplaceDir = marketplaceStats.isDirectory() | |
| ? marketplaceInstallLocation | |
| : dirname(marketplaceInstallLocation) | |
| sourcePath = join(marketplaceDir, entry.source) | |
| // Verify sourcePath exists. This stat is required — neither downstream | |
| // op reliably surfaces ENOENT: | |
| // 1. calculatePluginVersion → findGitRoot walks UP past a missing dir | |
| // to the marketplace .git, returning the same SHA as install-time → | |
| // silent false-positive {success: true, alreadyUpToDate: true}. | |
| // 2. copyPluginToVersionedCache (when versions differ) throws a raw | |
| // ENOENT with no friendly message. | |
| // TOCTOU is negligible for a user-managed local dir. | |
| try { | |
| await fs.stat(sourcePath) | |
| } catch (e: unknown) { | |
| if (isENOENT(e)) { | |
| return { | |
| success: false, | |
| message: `Plugin source not found at ${sourcePath}`, | |
| pluginId, | |
| scope, | |
| } | |
| } | |
| throw e | |
| } | |
| // Try to load manifest from plugin directory (for version info) | |
| let pluginManifest: PluginManifest | undefined | |
| const manifestPath = join(sourcePath, '.claude-plugin', 'plugin.json') | |
| try { | |
| pluginManifest = await loadPluginManifest( | |
| manifestPath, | |
| entry.name, | |
| entry.source, | |
| ) | |
| } catch { | |
| // Failed to load - will use other version sources | |
| } | |
| // Calculate version from plugin source path | |
| newVersion = await calculatePluginVersion( | |
| pluginId, | |
| entry.source, | |
| pluginManifest, | |
| sourcePath, | |
| entry.version, | |
| ) | |
| } | |
| // Use try/finally to ensure temp directory cleanup on any error | |
| try { | |
| // Check if this version already exists in cache | |
| let versionedPath = getVersionedCachePath(pluginId, newVersion) | |
| // Check if installation is already at the new version | |
| const zipPath = getVersionedZipCachePath(pluginId, newVersion) | |
| const isUpToDate = | |
| installation.version === newVersion || | |
| installation.installPath === versionedPath || | |
| installation.installPath === zipPath | |
| if (isUpToDate) { | |
| return { | |
| success: true, | |
| message: `${pluginName} is already at the latest version (${newVersion}).`, | |
| pluginId, | |
| newVersion, | |
| oldVersion, | |
| alreadyUpToDate: true, | |
| scope, | |
| } | |
| } | |
| // Copy to versioned cache (returns actual path, which may be .zip) | |
| versionedPath = await copyPluginToVersionedCache( | |
| sourcePath, | |
| pluginId, | |
| newVersion, | |
| entry, | |
| ) | |
| // Store old version path for potential cleanup | |
| const oldVersionPath = installation.installPath | |
| // Update disk JSON file for this installation | |
| // (memory stays unchanged until restart) | |
| updateInstallationPathOnDisk( | |
| pluginId, | |
| scope, | |
| projectPath, | |
| versionedPath, | |
| newVersion, | |
| gitCommitSha, | |
| ) | |
| if (oldVersionPath && oldVersionPath !== versionedPath) { | |
| const updatedDiskData = loadInstalledPluginsFromDisk() | |
| const isOldVersionStillReferenced = Object.values( | |
| updatedDiskData.plugins, | |
| ).some(pluginInstallations => | |
| pluginInstallations.some(inst => inst.installPath === oldVersionPath), | |
| ) | |
| if (!isOldVersionStillReferenced) { | |
| await markPluginVersionOrphaned(oldVersionPath) | |
| } | |
| } | |
| const scopeDesc = projectPath ? `${scope} (${projectPath})` : scope | |
| const message = `Plugin "${pluginName}" updated from ${oldVersion || 'unknown'} to ${newVersion} for scope ${scopeDesc}. Restart to apply changes.` | |
| return { | |
| success: true, | |
| message, | |
| pluginId, | |
| newVersion, | |
| oldVersion, | |
| scope, | |
| } | |
| } finally { | |
| // Clean up temp source if it was a remote download | |
| if ( | |
| shouldCleanupSource && | |
| sourcePath !== getVersionedCachePath(pluginId, newVersion) | |
| ) { | |
| await fs.rm(sourcePath, { recursive: true, force: true }) | |
| } | |
| } | |
| } | |