Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
36 kB · 1088 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089/** * 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 }) } }}