Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
21 kB · 595 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596/** * Shared helper functions for plugin installation * * This module contains common utilities used across the plugin installation * system to reduce code duplication and improve maintainability. */
import { randomBytes } from 'crypto'import { rename, rm } from 'fs/promises'import { dirname, join, resolve, sep } from 'path'import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent,} from '../../services/analytics/index.js'import { getCwd } from '../cwd.js'import { toError } from '../errors.js'import { getFsImplementation } from '../fsOperations.js'import { logError } from '../log.js'import { getSettingsForSource, updateSettingsForSource,} from '../settings/settings.js'import { buildPluginTelemetryFields } from '../telemetry/pluginTelemetry.js'import { clearAllCaches } from './cacheUtils.js'import { formatDependencyCountSuffix, getEnabledPluginIdsForScope, type ResolutionResult, resolveDependencyClosure,} from './dependencyResolver.js'import { addInstalledPlugin, getGitCommitSha,} from './installedPluginsManager.js'import { getManagedPluginNames } from './managedPlugins.js'import { getMarketplaceCacheOnly, getPluginById } from './marketplaceManager.js'import { isOfficialMarketplaceName, parsePluginIdentifier, scopeToSettingSource,} from './pluginIdentifier.js'import { cachePlugin, getVersionedCachePath, getVersionedZipCachePath,} from './pluginLoader.js'import { isPluginBlockedByPolicy } from './pluginPolicy.js'import { calculatePluginVersion } from './pluginVersioning.js'import { isLocalPluginSource, type PluginMarketplaceEntry, type PluginScope, type PluginSource,} from './schemas.js'import { convertDirectoryToZipInPlace, isPluginZipCacheEnabled,} from './zipCache.js'
/** * Plugin installation metadata for installed_plugins.json */export type PluginInstallationInfo = { pluginId: string installPath: string version?: string}
/** * Get current ISO timestamp */export function getCurrentTimestamp(): string { return new Date().toISOString()}
/** * Validate that a resolved path stays within a base directory. * Prevents path traversal attacks where malicious paths like './../../../etc/passwd' * could escape the expected directory. * * @param basePath - The base directory that the resolved path must stay within * @param relativePath - The relative path to validate * @returns The validated absolute path * @throws Error if the path would escape the base directory */export function validatePathWithinBase( basePath: string, relativePath: string,): string { const resolvedPath = resolve(basePath, relativePath) const normalizedBase = resolve(basePath) + sep
// Check if the resolved path starts with the base path // Adding sep ensures we don't match partial directory names // e.g., /foo/bar should not match /foo/barbaz if ( !resolvedPath.startsWith(normalizedBase) && resolvedPath !== resolve(basePath) ) { throw new Error( `Path traversal detected: "${relativePath}" would escape the base directory`, ) }
return resolvedPath}
/** * Cache a plugin (local or external) and add it to installed_plugins.json * * This function combines the common pattern of: * 1. Caching a plugin to ~/.claude/plugins/cache/ * 2. Adding it to the installed plugins registry * * Both local plugins (with string source like "./path") and external plugins * (with object source like {source: "github", ...}) are cached to the same * location to ensure consistent behavior. * * @param pluginId - Plugin ID in "plugin@marketplace" format * @param entry - Plugin marketplace entry * @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'. * 'managed' scope is used for plugins installed automatically from managed settings. * @param projectPath - Project path (required for project/local scopes) * @param localSourcePath - For local plugins, the resolved absolute path to the source directory * @returns The installation path */export async function cacheAndRegisterPlugin( pluginId: string, entry: PluginMarketplaceEntry, scope: PluginScope = 'user', projectPath?: string, localSourcePath?: string,): Promise<string> { // For local plugins, we need the resolved absolute path // Cast to PluginSource since cachePlugin handles any string path at runtime const source: PluginSource = typeof entry.source === 'string' && localSourcePath ? (localSourcePath as PluginSource) : entry.source
const cacheResult = await cachePlugin(source, { manifest: entry as PluginMarketplaceEntry, })
// For local plugins, use the original source path for Git SHA calculation // because the cached temp directory doesn't have .git (it's copied from a // subdirectory of the marketplace git repo). For external plugins, use the // cached path. For git-subdir sources, cachePlugin already captured the SHA // before discarding the ephemeral clone (the extracted subdir has no .git). const pathForGitSha = localSourcePath || cacheResult.path const gitCommitSha = cacheResult.gitCommitSha ?? (await getGitCommitSha(pathForGitSha))
const now = getCurrentTimestamp() const version = await calculatePluginVersion( pluginId, entry.source, cacheResult.manifest, pathForGitSha, entry.version, cacheResult.gitCommitSha, )
// Move the cached plugin to the versioned path: cache/marketplace/plugin/version/ const versionedPath = getVersionedCachePath(pluginId, version) let finalPath = cacheResult.path
// Only move if the paths are different and plugin was cached to a different location if (cacheResult.path !== versionedPath) { // Create the versioned directory structure await getFsImplementation().mkdir(dirname(versionedPath))
// Remove existing versioned path if present (force: no-op if missing) await rm(versionedPath, { recursive: true, force: true })
// Check if versionedPath is a subdirectory of cacheResult.path // This happens when marketplace name equals plugin name (e.g., "exa-mcp-server@exa-mcp-server") // In this case, we can't directly rename because we'd be moving a directory into itself const normalizedCachePath = cacheResult.path.endsWith(sep) ? cacheResult.path : cacheResult.path + sep const isSubdirectory = versionedPath.startsWith(normalizedCachePath)
if (isSubdirectory) { // Move to a temp location first, then to final destination // We can't directly rename/copy a directory into its own subdirectory // Use the parent of cacheResult.path (same filesystem) to avoid EXDEV // errors when /tmp is on a different filesystem (e.g., tmpfs) const tempPath = join( dirname(cacheResult.path), `.claude-plugin-temp-${Date.now()}-${randomBytes(4).toString('hex')}`, ) await rename(cacheResult.path, tempPath) await getFsImplementation().mkdir(dirname(versionedPath)) await rename(tempPath, versionedPath) } else { // Move the cached plugin to the versioned location await rename(cacheResult.path, versionedPath) } finalPath = versionedPath }
// Zip cache mode: convert directory to ZIP and remove the directory if (isPluginZipCacheEnabled()) { const zipPath = getVersionedZipCachePath(pluginId, version) await convertDirectoryToZipInPlace(finalPath, zipPath) finalPath = zipPath }
// Add to both V1 and V2 installed_plugins files with correct scope addInstalledPlugin( pluginId, { version, installedAt: now, lastUpdated: now, installPath: finalPath, gitCommitSha, }, scope, projectPath, )
return finalPath}
/** * Register a plugin installation without caching * * Used for local plugins that are already on disk and don't need remote caching. * External plugins should use cacheAndRegisterPlugin() instead. * * @param info - Plugin installation information * @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'. * 'managed' scope is used for plugins registered from managed settings. * @param projectPath - Project path (required for project/local scopes) */export function registerPluginInstallation( info: PluginInstallationInfo, scope: PluginScope = 'user', projectPath?: string,): void { const now = getCurrentTimestamp() addInstalledPlugin( info.pluginId, { version: info.version || 'unknown', installedAt: now, lastUpdated: now, installPath: info.installPath, }, scope, projectPath, )}
/** * Parse plugin ID into components * * @param pluginId - Plugin ID in "plugin@marketplace" format * @returns Parsed components or null if invalid */export function parsePluginId( pluginId: string,): { name: string; marketplace: string } | null { const parts = pluginId.split('@') if (parts.length !== 2 || !parts[0] || !parts[1]) { return null }
return { name: parts[0], marketplace: parts[1], }}
/** * Structured result from the install core. Wrappers format messages and * handle analytics/error-catching around this. */export type InstallCoreResult = | { ok: true; closure: string[]; depNote: string } | { ok: false; reason: 'local-source-no-location'; pluginName: string } | { ok: false; reason: 'settings-write-failed'; message: string } | { ok: false reason: 'resolution-failed' resolution: ResolutionResult & { ok: false } } | { ok: false; reason: 'blocked-by-policy'; pluginName: string } | { ok: false reason: 'dependency-blocked-by-policy' pluginName: string blockedDependency: string }
/** * Format a failed ResolutionResult into a user-facing message. Unified on * the richer CLI messages (the "Is the X marketplace added?" hint is useful * for UI users too). */export function formatResolutionError( r: ResolutionResult & { ok: false },): string { switch (r.reason) { case 'cycle': return `Dependency cycle: ${r.chain.join(' → ')}` case 'cross-marketplace': { const depMkt = parsePluginIdentifier(r.dependency).marketplace const where = depMkt ? `marketplace "${depMkt}"` : 'a different marketplace' const hint = depMkt ? ` Add "${depMkt}" to allowCrossMarketplaceDependenciesOn in the ROOT marketplace's marketplace.json (the marketplace of the plugin you're installing — only its allowlist applies; no transitive trust).` : '' return `Dependency "${r.dependency}" (required by ${r.requiredBy}) is in ${where}, which is not in the allowlist — cross-marketplace dependencies are blocked by default. Install it manually first.${hint}` } case 'not-found': { const { marketplace: depMkt } = parsePluginIdentifier(r.missing) return depMkt ? `Dependency "${r.missing}" (required by ${r.requiredBy}) not found. Is the "${depMkt}" marketplace added?` : `Dependency "${r.missing}" (required by ${r.requiredBy}) not found in any configured marketplace` } }}
/** * Core plugin install logic, shared by the CLI path (`installPluginOp`) and * the interactive UI path (`installPluginFromMarketplace`). Given a * pre-resolved marketplace entry, this: * * 1. Guards against local-source plugins without a marketplace install * location (would silently no-op otherwise). * 2. Resolves the transitive dependency closure (when PLUGIN_DEPENDENCIES * is on; trivial single-plugin closure otherwise). * 3. Writes the entire closure to enabledPlugins in one settings update. * 4. Caches each closure member (downloads/copies sources as needed). * 5. Clears memoization caches. * * Returns a structured result. Message formatting, analytics, and top-level * error wrapping stay in the caller-specific wrappers. * * @param marketplaceInstallLocation Pass this if the caller already has it * (from a prior marketplace search) to avoid a redundant lookup. */export async function installResolvedPlugin({ pluginId, entry, scope, marketplaceInstallLocation,}: { pluginId: string entry: PluginMarketplaceEntry scope: 'user' | 'project' | 'local' marketplaceInstallLocation?: string}): Promise<InstallCoreResult> { const settingSource = scopeToSettingSource(scope)
// ── Policy guard ── // Org-blocked plugins (managed-settings.json enabledPlugins: false) cannot // be installed. Checked here so all install paths (CLI, UI, hint-triggered) // are covered in one place. if (isPluginBlockedByPolicy(pluginId)) { return { ok: false, reason: 'blocked-by-policy', pluginName: entry.name } }
// ── Resolve dependency closure ── // depInfo caches marketplace lookups so the materialize loop doesn't // re-fetch. Seed the root if the caller gave us its install location. const depInfo = new Map< string, { entry: PluginMarketplaceEntry; marketplaceInstallLocation: string } >() // Without this guard, a local-source root with undefined // marketplaceInstallLocation falls through: depInfo isn't seeded, the // materialize loop's `if (!info) continue` skips the root, and the user // sees "Successfully installed" while nothing is cached. if (isLocalPluginSource(entry.source) && !marketplaceInstallLocation) { return { ok: false, reason: 'local-source-no-location', pluginName: entry.name, } } if (marketplaceInstallLocation) { depInfo.set(pluginId, { entry, marketplaceInstallLocation }) }
const rootMarketplace = parsePluginIdentifier(pluginId).marketplace const allowedCrossMarketplaces = new Set( (rootMarketplace ? (await getMarketplaceCacheOnly(rootMarketplace)) ?.allowCrossMarketplaceDependenciesOn : undefined) ?? [], ) const resolution = await resolveDependencyClosure( pluginId, async id => { if (depInfo.has(id)) return depInfo.get(id)!.entry if (id === pluginId) return entry const info = await getPluginById(id) if (info) depInfo.set(id, info) return info?.entry ?? null }, getEnabledPluginIdsForScope(settingSource), allowedCrossMarketplaces, ) if (!resolution.ok) { return { ok: false, reason: 'resolution-failed', resolution } }
// ── Policy guard for transitive dependencies ── // The root plugin was already checked above, but any dependency in the // closure could also be policy-blocked. Check before writing to settings // so a non-blocked plugin can't pull in a blocked dependency. for (const id of resolution.closure) { if (id !== pluginId && isPluginBlockedByPolicy(id)) { return { ok: false, reason: 'dependency-blocked-by-policy', pluginName: entry.name, blockedDependency: id, } } }
// ── ACTION: write entire closure to settings in one call ── const closureEnabled: Record<string, true> = {} for (const id of resolution.closure) closureEnabled[id] = true const { error } = updateSettingsForSource(settingSource, { enabledPlugins: { ...getSettingsForSource(settingSource)?.enabledPlugins, ...closureEnabled, }, }) if (error) { return { ok: false, reason: 'settings-write-failed', message: error.message, } }
// ── Materialize: cache each closure member ── const projectPath = scope !== 'user' ? getCwd() : undefined for (const id of resolution.closure) { let info = depInfo.get(id) // Root wasn't pre-seeded (caller didn't pass marketplaceInstallLocation // for a non-local source). Fetch now; it's needed for the cache write. if (!info && id === pluginId) { const mktLocation = (await getPluginById(id))?.marketplaceInstallLocation if (mktLocation) info = { entry, marketplaceInstallLocation: mktLocation } } if (!info) continue
let localSourcePath: string | undefined const { source } = info.entry if (isLocalPluginSource(source)) { localSourcePath = validatePathWithinBase( info.marketplaceInstallLocation, source, ) } await cacheAndRegisterPlugin( id, info.entry, scope, projectPath, localSourcePath, ) }
clearAllCaches()
const depNote = formatDependencyCountSuffix( resolution.closure.filter(id => id !== pluginId), ) return { ok: true, closure: resolution.closure, depNote }}
/** * Result of a plugin installation operation */export type InstallPluginResult = | { success: true; message: string } | { success: false; error: string }
/** * Parameters for installing a plugin from marketplace */export type InstallPluginParams = { pluginId: string entry: PluginMarketplaceEntry marketplaceName: string scope?: 'user' | 'project' | 'local' trigger?: 'hint' | 'user'}
/** * Install a single plugin from a marketplace with the specified scope. * Interactive-UI wrapper around `installResolvedPlugin` — adds try/catch, * analytics, and UI-style message formatting. */export async function installPluginFromMarketplace({ pluginId, entry, marketplaceName, scope = 'user', trigger = 'user',}: InstallPluginParams): Promise<InstallPluginResult> { try { // Look up the marketplace install location for local-source plugins. // Without this, plugins with relative-path sources fail from the // interactive UI path (/plugin install) even though the CLI path works. const pluginInfo = await getPluginById(pluginId) const marketplaceInstallLocation = pluginInfo?.marketplaceInstallLocation
const result = await installResolvedPlugin({ pluginId, entry, scope, marketplaceInstallLocation, })
if (!result.ok) { switch (result.reason) { case 'local-source-no-location': return { success: false, error: `Cannot install local plugin "${result.pluginName}" without marketplace install location`, } case 'settings-write-failed': return { success: false, error: `Failed to update settings: ${result.message}`, } case 'resolution-failed': return { success: false, error: formatResolutionError(result.resolution), } case 'blocked-by-policy': return { success: false, error: `Plugin "${result.pluginName}" is blocked by your organization's policy and cannot be installed`, } case 'dependency-blocked-by-policy': return { success: false, error: `Cannot install "${result.pluginName}": dependency "${result.blockedDependency}" is blocked by your organization's policy`, } } }
// _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns. // plugin_id kept in additional_metadata (redacted to 'third-party' for // non-official) because dbt external_claude_code_plugin_installs.sql // extracts $.plugin_id for official-marketplace install tracking. Other // plugin lifecycle events drop the blob key — no downstream consumers. logEvent('tengu_plugin_installed', { _PROTO_plugin_name: entry.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, _PROTO_marketplace_name: marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, plugin_id: (isOfficialMarketplaceName(marketplaceName) ? pluginId : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, install_source: (trigger === 'hint' ? 'ui-suggestion' : 'ui-discover') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginTelemetryFields( entry.name, marketplaceName, getManagedPluginNames(), ), ...(entry.version && { version: entry.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), })
return { success: true, message: `✓ Installed ${entry.name}${result.depNote}. Run /reload-plugins to activate.`, } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) logError(toError(err)) return { success: false, error: `Failed to install: ${errorMessage}` } }}