Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
110 kB · 3302 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303/** * Plugin Loader Module * * This module is responsible for discovering, loading, and validating Claude Code plugins * from various sources including marketplaces and git repositories. * * NPM packages are also supported but must be referenced through marketplaces - the marketplace * entry contains the NPM package information. * * Plugin Discovery Sources (in order of precedence): * 1. Marketplace-based plugins (plugin@marketplace format in settings) * 2. Session-only plugins (from --plugin-dir CLI flag or SDK plugins option) * * Plugin Directory Structure: * ``` * my-plugin/ * ├── plugin.json # Optional manifest with metadata * ├── commands/ # Custom slash commands * │ ├── build.md * │ └── deploy.md * ├── agents/ # Custom AI agents * │ └── test-runner.md * └── hooks/ # Hook configurations * └── hooks.json # Hook definitions * ``` * * The loader handles: * - Plugin manifest validation * - Hooks configuration loading and variable resolution * - Duplicate name detection * - Enable/disable state management * - Error collection and reporting */
import { copyFile, readdir, readFile, readlink, realpath, rename, rm, rmdir, stat, symlink,} from 'fs/promises'import memoize from 'lodash-es/memoize.js'import { basename, dirname, join, relative, resolve, sep } from 'path'import { getInlinePlugins } from '../../bootstrap/state.js'import { BUILTIN_MARKETPLACE_NAME, getBuiltinPlugins,} from '../../plugins/builtinPlugins.js'import type { LoadedPlugin, PluginComponent, PluginError, PluginLoadResult, PluginManifest,} from '../../types/plugin.js'import { logForDebugging } from '../debug.js'import { isEnvTruthy } from '../envUtils.js'import { errorMessage, getErrnoPath, isENOENT, isFsInaccessible, toError,} from '../errors.js'import { execFileNoThrow, execFileNoThrowWithCwd } from '../execFileNoThrow.js'import { pathExists } from '../file.js'import { getFsImplementation } from '../fsOperations.js'import { gitExe } from '../git.js'import { lazySchema } from '../lazySchema.js'import { logError } from '../log.js'import { getSettings_DEPRECATED } from '../settings/settings.js'import { clearPluginSettingsBase, getPluginSettingsBase, resetSettingsCache, setPluginSettingsBase,} from '../settings/settingsCache.js'import type { HooksSettings } from '../settings/types.js'import { SettingsSchema } from '../settings/types.js'import { jsonParse, jsonStringify } from '../slowOperations.js'import { getAddDirEnabledPlugins } from './addDirPluginSettings.js'import { verifyAndDemote } from './dependencyResolver.js'import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'import { checkGitAvailable } from './gitAvailability.js'import { getInMemoryInstalledPlugins } from './installedPluginsManager.js'import { getManagedPluginNames } from './managedPlugins.js'import { formatSourceForDisplay, getBlockedMarketplaces, getStrictKnownMarketplaces, isSourceAllowedByPolicy, isSourceInBlocklist,} from './marketplaceHelpers.js'import { getMarketplaceCacheOnly, getPluginByIdCacheOnly, loadKnownMarketplacesConfigSafe,} from './marketplaceManager.js'import { getPluginSeedDirs, getPluginsDirectory } from './pluginDirectories.js'import { parsePluginIdentifier } from './pluginIdentifier.js'import { validatePathWithinBase } from './pluginInstallationHelpers.js'import { calculatePluginVersion } from './pluginVersioning.js'import { type CommandMetadata, PluginHooksSchema, PluginIdSchema, PluginManifestSchema, type PluginMarketplaceEntry, type PluginSource,} from './schemas.js'import { convertDirectoryToZipInPlace, extractZipToDirectory, getSessionPluginCachePath, isPluginZipCacheEnabled,} from './zipCache.js'
/** * Get the path where plugin cache is stored */export function getPluginCachePath(): string { return join(getPluginsDirectory(), 'cache')}
/** * Compute the versioned cache path under a specific base plugins directory. * Used to probe both primary and seed caches. * * @param baseDir - Base plugins directory (e.g. getPluginsDirectory() or seed dir) * @param pluginId - Plugin identifier in format "name@marketplace" * @param version - Version string (semver, git SHA, etc.) * @returns Absolute path to versioned plugin directory under baseDir */export function getVersionedCachePathIn( baseDir: string, pluginId: string, version: string,): string { const { name: pluginName, marketplace } = parsePluginIdentifier(pluginId) const sanitizedMarketplace = (marketplace || 'unknown').replace( /[^a-zA-Z0-9\-_]/g, '-', ) const sanitizedPlugin = (pluginName || pluginId).replace( /[^a-zA-Z0-9\-_]/g, '-', ) // Sanitize version to prevent path traversal attacks const sanitizedVersion = version.replace(/[^a-zA-Z0-9\-_.]/g, '-') return join( baseDir, 'cache', sanitizedMarketplace, sanitizedPlugin, sanitizedVersion, )}
/** * Get versioned cache path for a plugin under the primary plugins directory. * Format: ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/ * * @param pluginId - Plugin identifier in format "name@marketplace" * @param version - Version string (semver, git SHA, etc.) * @returns Absolute path to versioned plugin directory */export function getVersionedCachePath( pluginId: string, version: string,): string { return getVersionedCachePathIn(getPluginsDirectory(), pluginId, version)}
/** * Get versioned ZIP cache path for a plugin. * This is the zip cache variant of getVersionedCachePath. */export function getVersionedZipCachePath( pluginId: string, version: string,): string { return `${getVersionedCachePath(pluginId, version)}.zip`}
/** * Probe seed directories for a populated cache at this plugin version. * Seeds are checked in precedence order; first hit wins. Returns null if no * seed is configured or none contains a populated directory at this version. */async function probeSeedCache( pluginId: string, version: string,): Promise<string | null> { for (const seedDir of getPluginSeedDirs()) { const seedPath = getVersionedCachePathIn(seedDir, pluginId, version) try { const entries = await readdir(seedPath) if (entries.length > 0) return seedPath } catch { // Try next seed } } return null}
/** * When the computed version is 'unknown', probe seed/cache/<m>/<p>/ for an * actual version dir. Handles the first-boot chicken-and-egg where the * version can only be known after cloning, but seed already has the clone. * * Per seed, only matches when exactly one version exists (typical BYOC case). * Multiple versions within a single seed → ambiguous → try next seed. * Seeds are checked in precedence order; first match wins. */export async function probeSeedCacheAnyVersion( pluginId: string,): Promise<string | null> { for (const seedDir of getPluginSeedDirs()) { // The parent of the version dir — computed the same way as // getVersionedCachePathIn, just without the version component. const pluginDir = dirname(getVersionedCachePathIn(seedDir, pluginId, '_')) try { const versions = await readdir(pluginDir) if (versions.length !== 1) continue const versionDir = join(pluginDir, versions[0]!) const entries = await readdir(versionDir) if (entries.length > 0) return versionDir } catch { // Try next seed } } return null}
/** * Get legacy (non-versioned) cache path for a plugin. * Format: ~/.claude/plugins/cache/{plugin-name}/ * * Used for backward compatibility with existing installations. * * @param pluginName - Plugin name (without marketplace suffix) * @returns Absolute path to legacy plugin directory */export function getLegacyCachePath(pluginName: string): string { const cachePath = getPluginCachePath() return join(cachePath, pluginName.replace(/[^a-zA-Z0-9\-_]/g, '-'))}
/** * Resolve plugin path with fallback to legacy location. * * Always: * 1. Try versioned path first if version is provided * 2. Fall back to legacy path for existing installations * 3. Return versioned path for new installations * * @param pluginId - Plugin identifier in format "name@marketplace" * @param version - Optional version string * @returns Absolute path to plugin directory */export async function resolvePluginPath( pluginId: string, version?: string,): Promise<string> { // Try versioned path first if (version) { const versionedPath = getVersionedCachePath(pluginId, version) if (await pathExists(versionedPath)) { return versionedPath } }
// Fall back to legacy path for existing installations const pluginName = parsePluginIdentifier(pluginId).name || pluginId const legacyPath = getLegacyCachePath(pluginName) if (await pathExists(legacyPath)) { return legacyPath }
// Return versioned path for new installations return version ? getVersionedCachePath(pluginId, version) : legacyPath}
/** * Recursively copy a directory. * Exported for testing purposes. */export async function copyDir(src: string, dest: string): Promise<void> { await getFsImplementation().mkdir(dest)
const entries = await readdir(src, { withFileTypes: true })
for (const entry of entries) { const srcPath = join(src, entry.name) const destPath = join(dest, entry.name)
if (entry.isDirectory()) { await copyDir(srcPath, destPath) } else if (entry.isFile()) { await copyFile(srcPath, destPath) } else if (entry.isSymbolicLink()) { const linkTarget = await readlink(srcPath)
// Resolve the symlink to get the actual target path // This prevents circular symlinks when src and dest overlap (e.g., via symlink chains) let resolvedTarget: string try { resolvedTarget = await realpath(srcPath) } catch { // Broken symlink - copy the raw link target as-is await symlink(linkTarget, destPath) continue }
// Resolve the source directory to handle symlinked source dirs let resolvedSrc: string try { resolvedSrc = await realpath(src) } catch { resolvedSrc = src }
// Check if target is within the source tree (using proper path prefix matching) const srcPrefix = resolvedSrc.endsWith(sep) ? resolvedSrc : resolvedSrc + sep if ( resolvedTarget.startsWith(srcPrefix) || resolvedTarget === resolvedSrc ) { // Target is within source tree - create relative symlink that preserves // the same structure in the destination const targetRelativeToSrc = relative(resolvedSrc, resolvedTarget) const destTargetPath = join(dest, targetRelativeToSrc) const relativeLinkPath = relative(dirname(destPath), destTargetPath) await symlink(relativeLinkPath, destPath) } else { // Target is outside source tree - use absolute resolved path await symlink(resolvedTarget, destPath) } } }}
/** * Copy plugin files to versioned cache directory. * * For local plugins: Uses entry.source from marketplace.json as the single source of truth. * For remote plugins: Falls back to copying sourcePath (the downloaded content). * * @param sourcePath - Path to the plugin source (used as fallback for remote plugins) * @param pluginId - Plugin identifier in format "name@marketplace" * @param version - Version string for versioned path * @param entry - Optional marketplace entry containing the source field * @param marketplaceDir - Marketplace directory for resolving entry.source (undefined for remote plugins) * @returns Path to the cached plugin directory * @throws Error if the source directory is not found * @throws Error if the destination directory is empty after copy */export async function copyPluginToVersionedCache( sourcePath: string, pluginId: string, version: string, entry?: PluginMarketplaceEntry, marketplaceDir?: string,): Promise<string> { // When zip cache is enabled, the canonical format is a ZIP file const zipCacheMode = isPluginZipCacheEnabled() const cachePath = getVersionedCachePath(pluginId, version) const zipPath = getVersionedZipCachePath(pluginId, version)
// If cache already exists (directory or ZIP), return it if (zipCacheMode) { if (await pathExists(zipPath)) { logForDebugging( `Plugin ${pluginId} version ${version} already cached at ${zipPath}`, ) return zipPath } } else if (await pathExists(cachePath)) { const entries = await readdir(cachePath) if (entries.length > 0) { logForDebugging( `Plugin ${pluginId} version ${version} already cached at ${cachePath}`, ) return cachePath } // Directory exists but is empty, remove it so we can recreate with content logForDebugging( `Removing empty cache directory for ${pluginId} at ${cachePath}`, ) await rmdir(cachePath) }
// Seed cache hit — return seed path in place (read-only, no copy). // Callers handle both directory and .zip paths; this returns a directory. const seedPath = await probeSeedCache(pluginId, version) if (seedPath) { logForDebugging( `Using seed cache for ${pluginId}@${version} at ${seedPath}`, ) return seedPath }
// Create parent directories await getFsImplementation().mkdir(dirname(cachePath))
// For local plugins: copy entry.source directory (the single source of truth) // For remote plugins: marketplaceDir is undefined, fall back to copying sourcePath if (entry && typeof entry.source === 'string' && marketplaceDir) { const sourceDir = validatePathWithinBase(marketplaceDir, entry.source)
logForDebugging( `Copying source directory ${entry.source} for plugin ${pluginId}`, ) try { await copyDir(sourceDir, cachePath) } catch (e: unknown) { // Only remap ENOENT from the top-level sourceDir itself — nested ENOENTs // from recursive copyDir (broken symlinks, raced deletes) should preserve // their original path in the error. if (isENOENT(e) && getErrnoPath(e) === sourceDir) { throw new Error( `Plugin source directory not found: ${sourceDir} (from entry.source: ${entry.source})`, ) } throw e } } else { // Fallback for remote plugins (already downloaded) or plugins without entry.source logForDebugging( `Copying plugin ${pluginId} to versioned cache (fallback to full copy)`, ) await copyDir(sourcePath, cachePath) }
// Remove .git directory from cache if present const gitPath = join(cachePath, '.git') await rm(gitPath, { recursive: true, force: true })
// Validate that cache has content - if empty, throw so fallback can be used const cacheEntries = await readdir(cachePath) if (cacheEntries.length === 0) { throw new Error( `Failed to copy plugin ${pluginId} to versioned cache: destination is empty after copy`, ) }
// Zip cache mode: convert directory to ZIP and remove the directory if (zipCacheMode) { await convertDirectoryToZipInPlace(cachePath, zipPath) logForDebugging( `Successfully cached plugin ${pluginId} as ZIP at ${zipPath}`, ) return zipPath }
logForDebugging(`Successfully cached plugin ${pluginId} at ${cachePath}`) return cachePath}
/** * Validate a git URL using Node.js URL parsing */function validateGitUrl(url: string): string { try { const parsed = new URL(url) if (!['https:', 'http:', 'file:'].includes(parsed.protocol)) { if (!/^git@[a-zA-Z0-9.-]+:/.test(url)) { throw new Error( `Invalid git URL protocol: ${parsed.protocol}. Only HTTPS, HTTP, file:// and SSH (git@) URLs are supported.`, ) } } return url } catch { if (/^git@[a-zA-Z0-9.-]+:/.test(url)) { return url } throw new Error(`Invalid git URL: ${url}`) }}
/** * Install a plugin from npm using a global cache (exported for testing) */export async function installFromNpm( packageName: string, targetPath: string, options: { registry?: string; version?: string } = {},): Promise<void> { const npmCachePath = join(getPluginsDirectory(), 'npm-cache')
await getFsImplementation().mkdir(npmCachePath)
const packageSpec = options.version ? `${packageName}@${options.version}` : packageName const packagePath = join(npmCachePath, 'node_modules', packageName) const needsInstall = !(await pathExists(packagePath))
if (needsInstall) { logForDebugging(`Installing npm package ${packageSpec} to cache`) const args = ['install', packageSpec, '--prefix', npmCachePath] if (options.registry) { args.push('--registry', options.registry) } const result = await execFileNoThrow('npm', args, { useCwd: false })
if (result.code !== 0) { throw new Error(`Failed to install npm package: ${result.stderr}`) } }
await copyDir(packagePath, targetPath) logForDebugging( `Copied npm package ${packageName} from cache to ${targetPath}`, )}
/** * Clone a git repository (exported for testing) * * @param gitUrl - The git URL to clone * @param targetPath - Where to clone the repository * @param ref - Optional branch or tag to checkout * @param sha - Optional specific commit SHA to checkout */export async function gitClone( gitUrl: string, targetPath: string, ref?: string, sha?: string,): Promise<void> { // Use --recurse-submodules to initialize submodules // Always start with shallow clone for efficiency const args = [ 'clone', '--depth', '1', '--recurse-submodules', '--shallow-submodules', ]
// Add --branch flag for specific ref (works for both branches and tags) if (ref) { args.push('--branch', ref) }
// If sha is specified, use --no-checkout since we'll checkout the SHA separately if (sha) { args.push('--no-checkout') }
args.push(gitUrl, targetPath)
const cloneStarted = performance.now() const cloneResult = await execFileNoThrow(gitExe(), args)
if (cloneResult.code !== 0) { logPluginFetch( 'plugin_clone', gitUrl, 'failure', performance.now() - cloneStarted, classifyFetchError(cloneResult.stderr), ) throw new Error(`Failed to clone repository: ${cloneResult.stderr}`) }
// If sha is specified, fetch and checkout that specific commit if (sha) { // Try shallow fetch of the specific SHA first (most efficient) const shallowFetchResult = await execFileNoThrowWithCwd( gitExe(), ['fetch', '--depth', '1', 'origin', sha], { cwd: targetPath }, )
if (shallowFetchResult.code !== 0) { // Some servers don't support fetching arbitrary SHAs // Fall back to unshallow fetch to get full history logForDebugging( `Shallow fetch of SHA ${sha} failed, falling back to unshallow fetch`, ) const unshallowResult = await execFileNoThrowWithCwd( gitExe(), ['fetch', '--unshallow'], { cwd: targetPath }, )
if (unshallowResult.code !== 0) { logPluginFetch( 'plugin_clone', gitUrl, 'failure', performance.now() - cloneStarted, classifyFetchError(unshallowResult.stderr), ) throw new Error( `Failed to fetch commit ${sha}: ${unshallowResult.stderr}`, ) } }
// Checkout the specific commit const checkoutResult = await execFileNoThrowWithCwd( gitExe(), ['checkout', sha], { cwd: targetPath }, )
if (checkoutResult.code !== 0) { logPluginFetch( 'plugin_clone', gitUrl, 'failure', performance.now() - cloneStarted, classifyFetchError(checkoutResult.stderr), ) throw new Error( `Failed to checkout commit ${sha}: ${checkoutResult.stderr}`, ) } }
// Fire success only after ALL network ops (clone + optional SHA fetch) // complete — same telemetry-scope discipline as mcpb and marketplace_url. logPluginFetch( 'plugin_clone', gitUrl, 'success', performance.now() - cloneStarted, )}
/** * Install a plugin from a git URL */async function installFromGit( gitUrl: string, targetPath: string, ref?: string, sha?: string,): Promise<void> { const safeUrl = validateGitUrl(gitUrl) await gitClone(safeUrl, targetPath, ref, sha) const refMessage = ref ? ` (ref: ${ref})` : '' logForDebugging( `Cloned repository from ${safeUrl}${refMessage} to ${targetPath}`, )}
/** * Install a plugin from GitHub */async function installFromGitHub( repo: string, targetPath: string, ref?: string, sha?: string,): Promise<void> { if (!/^[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_.]+$/.test(repo)) { throw new Error( `Invalid GitHub repository format: ${repo}. Expected format: owner/repo`, ) } // Use HTTPS for CCR (no SSH keys), SSH for normal CLI const gitUrl = isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ? `https://github.com/${repo}.git` : `git@github.com:${repo}.git` return installFromGit(gitUrl, targetPath, ref, sha)}
/** * Resolve a git-subdir `url` field to a clonable git URL. * Accepts GitHub owner/repo shorthand (converted to ssh or https depending on * CLAUDE_CODE_REMOTE) or any URL that passes validateGitUrl (https, http, * file, git@ ssh). */function resolveGitSubdirUrl(url: string): string { if (/^[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_.]+$/.test(url)) { return isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ? `https://github.com/${url}.git` : `git@github.com:${url}.git` } return validateGitUrl(url)}
/** * Install a plugin from a subdirectory of a git repository (exported for * testing). * * Uses partial clone (--filter=tree:0) + sparse-checkout so only the tree * objects along the path and the blobs under it are downloaded. For large * monorepos this is dramatically cheaper than a full clone — the tree objects * for a million-file repo can be hundreds of MB, all avoided here. * * Sequence: * 1. clone --depth 1 --filter=tree:0 --no-checkout [--branch ref] * 2. sparse-checkout set --cone -- <path> * 3. If sha: fetch --depth 1 origin <sha> (fallback: --unshallow), then * checkout <sha>. The partial-clone filter is stored in remote config so * subsequent fetches respect it; --unshallow gets all commits but trees * and blobs remain lazy. * If no sha: checkout HEAD (points to ref if --branch was used). * 4. Move <cloneDir>/<path> to targetPath and discard the clone. * * The clone is ephemeral — it goes into a sibling temp directory and is * removed after the subdir is extracted. targetPath ends up containing only * the plugin files with no .git directory. */export async function installFromGitSubdir( url: string, targetPath: string, subdirPath: string, ref?: string, sha?: string,): Promise<string | undefined> { if (!(await checkGitAvailable())) { throw new Error( 'git-subdir plugin source requires git to be installed and on PATH. ' + 'Install git (version 2.25 or later for sparse-checkout cone mode) and try again.', ) }
const gitUrl = resolveGitSubdirUrl(url) // Clone into a sibling temp dir (same filesystem → rename works, no EXDEV). const cloneDir = `${targetPath}.clone`
const cloneArgs = [ 'clone', '--depth', '1', '--filter=tree:0', '--no-checkout', ] if (ref) { cloneArgs.push('--branch', ref) } cloneArgs.push(gitUrl, cloneDir)
const cloneResult = await execFileNoThrow(gitExe(), cloneArgs) if (cloneResult.code !== 0) { throw new Error( `Failed to clone repository for git-subdir source: ${cloneResult.stderr}`, ) }
try { const sparseResult = await execFileNoThrowWithCwd( gitExe(), ['sparse-checkout', 'set', '--cone', '--', subdirPath], { cwd: cloneDir }, ) if (sparseResult.code !== 0) { throw new Error( `git sparse-checkout set failed (git >= 2.25 required for cone mode): ${sparseResult.stderr}`, ) }
// Capture the resolved commit SHA before discarding the clone. The // extracted subdir has no .git, so the caller can't rev-parse it later. // If the source specified a full 40-char sha we already know it; otherwise // read HEAD (which points to ref's tip after --branch, or the remote // default branch if no ref was given). let resolvedSha: string | undefined
if (sha) { const fetchSha = await execFileNoThrowWithCwd( gitExe(), ['fetch', '--depth', '1', 'origin', sha], { cwd: cloneDir }, ) if (fetchSha.code !== 0) { logForDebugging( `Shallow fetch of SHA ${sha} failed for git-subdir, falling back to unshallow fetch`, ) const unshallow = await execFileNoThrowWithCwd( gitExe(), ['fetch', '--unshallow'], { cwd: cloneDir }, ) if (unshallow.code !== 0) { throw new Error(`Failed to fetch commit ${sha}: ${unshallow.stderr}`) } } const checkout = await execFileNoThrowWithCwd( gitExe(), ['checkout', sha], { cwd: cloneDir }, ) if (checkout.code !== 0) { throw new Error(`Failed to checkout commit ${sha}: ${checkout.stderr}`) } resolvedSha = sha } else { // checkout HEAD materializes the working tree (this is where blobs are // lazy-fetched — the slow, network-bound step). It doesn't move HEAD; // --branch at clone time already positioned it. rev-parse HEAD is a // purely read-only ref lookup (no index lock), so it runs safely in // parallel with checkout and we avoid waiting on the network for it. const [checkout, revParse] = await Promise.all([ execFileNoThrowWithCwd(gitExe(), ['checkout', 'HEAD'], { cwd: cloneDir, }), execFileNoThrowWithCwd(gitExe(), ['rev-parse', 'HEAD'], { cwd: cloneDir, }), ]) if (checkout.code !== 0) { throw new Error( `git checkout after sparse-checkout failed: ${checkout.stderr}`, ) } if (revParse.code === 0) { resolvedSha = revParse.stdout.trim() } }
// Path traversal guard: resolve+verify the subdir stays inside cloneDir // before moving it out. rename ENOENT is wrapped with a friendlier // message that references the source path, not internal temp dirs. const resolvedSubdir = validatePathWithinBase(cloneDir, subdirPath) try { await rename(resolvedSubdir, targetPath) } catch (e: unknown) { if (isENOENT(e)) { throw new Error( `Subdirectory '${subdirPath}' not found in repository ${gitUrl}${ref ? ` (ref: ${ref})` : ''}. ` + 'Check that the path is correct and exists at the specified ref/sha.', ) } throw e }
const refMsg = ref ? ` ref=${ref}` : '' const shaMsg = resolvedSha ? ` sha=${resolvedSha}` : '' logForDebugging( `Extracted subdir ${subdirPath} from ${gitUrl}${refMsg}${shaMsg} to ${targetPath}`, ) return resolvedSha } finally { await rm(cloneDir, { recursive: true, force: true }) }}
/** * Install a plugin from a local path */async function installFromLocal( sourcePath: string, targetPath: string,): Promise<void> { if (!(await pathExists(sourcePath))) { throw new Error(`Source path does not exist: ${sourcePath}`) }
await copyDir(sourcePath, targetPath)
const gitPath = join(targetPath, '.git') await rm(gitPath, { recursive: true, force: true })}
/** * Generate a temporary cache name for a plugin */export function generateTemporaryCacheNameForPlugin( source: PluginSource,): string { const timestamp = Date.now() const random = Math.random().toString(36).substring(2, 8)
let prefix: string
if (typeof source === 'string') { prefix = 'local' } else { switch (source.source) { case 'npm': prefix = 'npm' break case 'pip': prefix = 'pip' break case 'github': prefix = 'github' break case 'url': prefix = 'git' break case 'git-subdir': prefix = 'subdir' break default: prefix = 'unknown' } }
return `temp_${prefix}_${timestamp}_${random}`}
/** * Cache a plugin from an external source */export async function cachePlugin( source: PluginSource, options?: { manifest?: PluginManifest },): Promise<{ path: string; manifest: PluginManifest; gitCommitSha?: string }> { const cachePath = getPluginCachePath()
await getFsImplementation().mkdir(cachePath)
const tempName = generateTemporaryCacheNameForPlugin(source) const tempPath = join(cachePath, tempName)
let shouldCleanup = false let gitCommitSha: string | undefined
try { logForDebugging( `Caching plugin from source: ${jsonStringify(source)} to temporary path ${tempPath}`, )
shouldCleanup = true
if (typeof source === 'string') { await installFromLocal(source, tempPath) } else { switch (source.source) { case 'npm': await installFromNpm(source.package, tempPath, { registry: source.registry, version: source.version, }) break case 'github': await installFromGitHub(source.repo, tempPath, source.ref, source.sha) break case 'url': await installFromGit(source.url, tempPath, source.ref, source.sha) break case 'git-subdir': gitCommitSha = await installFromGitSubdir( source.url, tempPath, source.path, source.ref, source.sha, ) break case 'pip': throw new Error('Python package plugins are not yet supported') default: throw new Error(`Unsupported plugin source type`) } } } catch (error) { if (shouldCleanup && (await pathExists(tempPath))) { logForDebugging(`Cleaning up failed installation at ${tempPath}`) try { await rm(tempPath, { recursive: true, force: true }) } catch (cleanupError) { logForDebugging(`Failed to clean up installation: ${cleanupError}`, { level: 'error', }) } } throw error }
const manifestPath = join(tempPath, '.claude-plugin', 'plugin.json') const legacyManifestPath = join(tempPath, 'plugin.json') let manifest: PluginManifest
if (await pathExists(manifestPath)) { try { const content = await readFile(manifestPath, { encoding: 'utf-8' }) const parsed = jsonParse(content) const result = PluginManifestSchema().safeParse(parsed)
if (result.success) { manifest = result.data } else { // Manifest exists but is invalid - throw error const errors = result.error.issues .map(err => `${err.path.join('.')}: ${err.message}`) .join(', ')
logForDebugging(`Invalid manifest at ${manifestPath}: ${errors}`, { level: 'error', })
throw new Error( `Plugin has an invalid manifest file at ${manifestPath}. Validation errors: ${errors}`, ) } } catch (error) { // Check if this is a validation error we just threw if ( error instanceof Error && error.message.includes('invalid manifest file') ) { throw error }
// JSON parse error const errorMsg = errorMessage(error) logForDebugging( `Failed to parse manifest at ${manifestPath}: ${errorMsg}`, { level: 'error', }, )
throw new Error( `Plugin has a corrupt manifest file at ${manifestPath}. JSON parse error: ${errorMsg}`, ) } } else if (await pathExists(legacyManifestPath)) { try { const content = await readFile(legacyManifestPath, { encoding: 'utf-8', }) const parsed = jsonParse(content) const result = PluginManifestSchema().safeParse(parsed)
if (result.success) { manifest = result.data } else { // Manifest exists but is invalid - throw error const errors = result.error.issues .map(err => `${err.path.join('.')}: ${err.message}`) .join(', ')
logForDebugging( `Invalid legacy manifest at ${legacyManifestPath}: ${errors}`, { level: 'error' }, )
throw new Error( `Plugin has an invalid manifest file at ${legacyManifestPath}. Validation errors: ${errors}`, ) } } catch (error) { // Check if this is a validation error we just threw if ( error instanceof Error && error.message.includes('invalid manifest file') ) { throw error }
// JSON parse error const errorMsg = errorMessage(error) logForDebugging( `Failed to parse legacy manifest at ${legacyManifestPath}: ${errorMsg}`, { level: 'error', }, )
throw new Error( `Plugin has a corrupt manifest file at ${legacyManifestPath}. JSON parse error: ${errorMsg}`, ) } } else { manifest = options?.manifest || { name: tempName, description: `Plugin cached from ${typeof source === 'string' ? source : source.source}`, } }
const finalName = manifest.name.replace(/[^a-zA-Z0-9-_]/g, '-') const finalPath = join(cachePath, finalName)
if (await pathExists(finalPath)) { logForDebugging(`Removing old cached version at ${finalPath}`) await rm(finalPath, { recursive: true, force: true }) }
await rename(tempPath, finalPath)
logForDebugging(`Successfully cached plugin ${manifest.name} to ${finalPath}`)
return { path: finalPath, manifest, ...(gitCommitSha && { gitCommitSha }), }}
/** * Loads and validates a plugin manifest from a JSON file. * * The manifest provides metadata about the plugin including name, version, * description, author, and other optional fields. If no manifest exists, * a minimal one is created to allow the plugin to function. * * Example plugin.json: * ```json * { * "name": "code-assistant", * "version": "1.2.0", * "description": "AI-powered code assistance tools", * "author": { * "name": "John Doe", * "email": "john@example.com" * }, * "keywords": ["coding", "ai", "assistant"], * "homepage": "https://example.com/code-assistant", * "hooks": "./custom-hooks.json", * "commands": ["./extra-commands/*.md"] * } * ``` */
/** * Loads and validates a plugin manifest from a JSON file. * * The manifest provides metadata about the plugin including name, version, * description, author, and other optional fields. If no manifest exists, * a minimal one is created to allow the plugin to function. * * Unknown keys in the manifest are silently stripped (PluginManifestSchema * uses zod's default strip behavior, not .strict()). Type mismatches and * other validation errors still fail. * * Behavior: * - Missing file: Creates default with provided name and source * - Invalid JSON: Throws error with parse details * - Schema validation failure: Throws error with validation details * * @param manifestPath - Full path to the plugin.json file * @param pluginName - Name to use in default manifest (e.g., "my-plugin") * @param source - Source description for default manifest (e.g., "git:repo" or ".claude-plugin/name") * @returns A valid PluginManifest object (either loaded or default) * @throws Error if manifest exists but is invalid (corrupt JSON or schema validation failure) */export async function loadPluginManifest( manifestPath: string, pluginName: string, source: string,): Promise<PluginManifest> { // Check if manifest file exists // If not, create a minimal manifest to allow plugin to function if (!(await pathExists(manifestPath))) { // Return default manifest with provided name and source return { name: pluginName, description: `Plugin from ${source}`, } }
try { // Read and parse the manifest JSON file const content = await readFile(manifestPath, { encoding: 'utf-8' }) const parsedJson = jsonParse(content)
// Validate against the PluginManifest schema const result = PluginManifestSchema().safeParse(parsedJson)
if (result.success) { // Valid manifest - return the validated data return result.data }
// Schema validation failed but JSON was valid const errors = result.error.issues .map(err => err.path.length > 0 ? `${err.path.join('.')}: ${err.message}` : err.message, ) .join(', ')
logForDebugging( `Plugin ${pluginName} has an invalid manifest file at ${manifestPath}. Validation errors: ${errors}`, { level: 'error' }, )
throw new Error( `Plugin ${pluginName} has an invalid manifest file at ${manifestPath}.\n\nValidation errors: ${errors}`, ) } catch (error) { // Check if this is the error we just threw (validation error) if ( error instanceof Error && error.message.includes('invalid manifest file') ) { throw error }
// JSON parsing failed or file read error const errorMsg = errorMessage(error)
logForDebugging( `Plugin ${pluginName} has a corrupt manifest file at ${manifestPath}. Parse error: ${errorMsg}`, { level: 'error' }, )
throw new Error( `Plugin ${pluginName} has a corrupt manifest file at ${manifestPath}.\n\nJSON parse error: ${errorMsg}`, ) }}
/** * Loads and validates plugin hooks configuration from a JSON file. * IMPORTANT: Only call this when the hooks file is expected to exist. * * @param hooksConfigPath - Full path to the hooks.json file * @param pluginName - Plugin name for error messages * @returns Validated HooksSettings * @throws Error if file doesn't exist or is invalid */async function loadPluginHooks( hooksConfigPath: string, pluginName: string,): Promise<HooksSettings> { if (!(await pathExists(hooksConfigPath))) { throw new Error( `Hooks file not found at ${hooksConfigPath} for plugin ${pluginName}. If the manifest declares hooks, the file must exist.`, ) }
const content = await readFile(hooksConfigPath, { encoding: 'utf-8' }) const rawHooksConfig = jsonParse(content)
// The hooks.json file has a wrapper structure with description and hooks // Use PluginHooksSchema to validate and extract the hooks property const validatedPluginHooks = PluginHooksSchema().parse(rawHooksConfig)
return validatedPluginHooks.hooks as HooksSettings}
/** * Validate a list of plugin component relative paths by checking existence in parallel. * * This helper parallelizes the pathExists checks (the expensive async part) while * preserving deterministic error/log ordering by iterating results sequentially. * * Introduced to fix a perf regression from the sync→async fs migration: sequential * `for { await pathExists }` loops add ~1-5ms of event-loop overhead per iteration. * With many plugins × several component types, this compounds to hundreds of ms. * * @param relPaths - Relative paths from the manifest/marketplace entry to validate * @param pluginPath - Plugin root directory to resolve relative paths against * @param pluginName - Plugin name for error messages * @param source - Source identifier for PluginError records * @param component - Which component these paths belong to (for error records) * @param componentLabel - Human-readable label for log messages (e.g. "Agent", "Skill") * @param contextLabel - Where the path came from, for log messages * (e.g. "specified in manifest but", "from marketplace entry") * @param errors - Error array to push path-not-found errors into (mutated) * @returns Array of full paths that exist on disk, in original order */async function validatePluginPaths( relPaths: string[], pluginPath: string, pluginName: string, source: string, component: PluginComponent, componentLabel: string, contextLabel: string, errors: PluginError[],): Promise<string[]> { // Parallelize the async pathExists checks const checks = await Promise.all( relPaths.map(async relPath => { const fullPath = join(pluginPath, relPath) return { relPath, fullPath, exists: await pathExists(fullPath) } }), ) // Process results in original order to keep error/log ordering deterministic const validPaths: string[] = [] for (const { relPath, fullPath, exists } of checks) { if (exists) { validPaths.push(fullPath) } else { logForDebugging( `${componentLabel} path ${relPath} ${contextLabel} not found at ${fullPath} for ${pluginName}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${fullPath} for ${pluginName}`, ), ) errors.push({ type: 'path-not-found', source, plugin: pluginName, path: fullPath, component, }) } } return validPaths}
/** * Creates a LoadedPlugin object from a plugin directory path. * * This is the central function that assembles a complete plugin representation * by scanning the plugin directory structure and loading all components. * It handles both fully-featured plugins with manifests and minimal plugins * with just commands or agents directories. * * Directory structure it looks for: * ``` * plugin-directory/ * ├── plugin.json # Optional: Plugin manifest * ├── commands/ # Optional: Custom slash commands * │ ├── build.md # /build command * │ └── test.md # /test command * ├── agents/ # Optional: Custom AI agents * │ ├── reviewer.md # Code review agent * │ └── optimizer.md # Performance optimization agent * └── hooks/ # Optional: Hook configurations * └── hooks.json # Hook definitions * ``` * * Component detection: * - Manifest: Loaded from plugin.json if present, otherwise creates default * - Commands: Sets commandsPath if commands/ directory exists * - Agents: Sets agentsPath if agents/ directory exists * - Hooks: Loads from hooks/hooks.json if present * * The function is tolerant of missing components - a plugin can have * any combination of the above directories/files. Missing component files * are reported as errors but don't prevent plugin loading. * * @param pluginPath - Absolute path to the plugin directory * @param source - Source identifier (e.g., "git:repo", ".claude-plugin/my-plugin") * @param enabled - Initial enabled state (may be overridden by settings) * @param fallbackName - Name to use if manifest doesn't specify one * @param strict - When true, adds errors for duplicate hook files (default: true) * @returns Object containing the LoadedPlugin and any errors encountered */export async function createPluginFromPath( pluginPath: string, source: string, enabled: boolean, fallbackName: string, strict = true,): Promise<{ plugin: LoadedPlugin; errors: PluginError[] }> { const errors: PluginError[] = []
// Step 1: Load or create the plugin manifest // This provides metadata about the plugin (name, version, etc.) const manifestPath = join(pluginPath, '.claude-plugin', 'plugin.json') const manifest = await loadPluginManifest(manifestPath, fallbackName, source)
// Step 2: Create the base plugin object // Start with required fields from manifest and parameters const plugin: LoadedPlugin = { name: manifest.name, // Use name from manifest (or fallback) manifest, // Store full manifest for later use path: pluginPath, // Absolute path to plugin directory source, // Source identifier (e.g., "git:repo" or ".claude-plugin/name") repository: source, // For backward compatibility with Plugin Repository enabled, // Current enabled state }
// Step 3: Auto-detect optional directories in parallel const [ commandsDirExists, agentsDirExists, skillsDirExists, outputStylesDirExists, ] = await Promise.all([ !manifest.commands ? pathExists(join(pluginPath, 'commands')) : false, !manifest.agents ? pathExists(join(pluginPath, 'agents')) : false, !manifest.skills ? pathExists(join(pluginPath, 'skills')) : false, !manifest.outputStyles ? pathExists(join(pluginPath, 'output-styles')) : false, ])
const commandsPath = join(pluginPath, 'commands') if (commandsDirExists) { plugin.commandsPath = commandsPath }
// Step 3a: Process additional command paths from manifest if (manifest.commands) { // Check if it's an object mapping (record of command name → metadata) const firstValue = Object.values(manifest.commands)[0] if ( typeof manifest.commands === 'object' && !Array.isArray(manifest.commands) && firstValue && typeof firstValue === 'object' && ('source' in firstValue || 'content' in firstValue) ) { // Object mapping format: { "about": { "source": "./README.md", ... } } const commandsMetadata: Record<string, CommandMetadata> = {} const validPaths: string[] = []
// Parallelize pathExists checks; process results in order to keep // error/log ordering deterministic. const entries = Object.entries(manifest.commands) const checks = await Promise.all( entries.map(async ([commandName, metadata]) => { if (!metadata || typeof metadata !== 'object') { return { commandName, metadata, kind: 'skip' as const } } if (metadata.source) { const fullPath = join(pluginPath, metadata.source) return { commandName, metadata, kind: 'source' as const, fullPath, exists: await pathExists(fullPath), } } if (metadata.content) { return { commandName, metadata, kind: 'content' as const } } return { commandName, metadata, kind: 'skip' as const } }), ) for (const check of checks) { if (check.kind === 'skip') continue if (check.kind === 'content') { // For inline content commands, add metadata without path commandsMetadata[check.commandName] = check.metadata continue } // kind === 'source' if (check.exists) { validPaths.push(check.fullPath) commandsMetadata[check.commandName] = check.metadata } else { logForDebugging( `Command ${check.commandName} path ${check.metadata.source} specified in manifest but not found at ${check.fullPath} for ${manifest.name}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${check.fullPath} for ${manifest.name}`, ), ) errors.push({ type: 'path-not-found', source, plugin: manifest.name, path: check.fullPath, component: 'commands', }) } }
// Set commandsPaths if there are file-based commands if (validPaths.length > 0) { plugin.commandsPaths = validPaths } // Set commandsMetadata if there are any commands (file-based or inline) if (Object.keys(commandsMetadata).length > 0) { plugin.commandsMetadata = commandsMetadata } } else { // Path or array of paths format const commandPaths = Array.isArray(manifest.commands) ? manifest.commands : [manifest.commands]
// Parallelize pathExists checks; process results in order. const checks = await Promise.all( commandPaths.map(async cmdPath => { if (typeof cmdPath !== 'string') { return { cmdPath, kind: 'invalid' as const } } const fullPath = join(pluginPath, cmdPath) return { cmdPath, kind: 'path' as const, fullPath, exists: await pathExists(fullPath), } }), ) const validPaths: string[] = [] for (const check of checks) { if (check.kind === 'invalid') { logForDebugging( `Unexpected command format in manifest for ${manifest.name}`, { level: 'error' }, ) continue } if (check.exists) { validPaths.push(check.fullPath) } else { logForDebugging( `Command path ${check.cmdPath} specified in manifest but not found at ${check.fullPath} for ${manifest.name}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${check.fullPath} for ${manifest.name}`, ), ) errors.push({ type: 'path-not-found', source, plugin: manifest.name, path: check.fullPath, component: 'commands', }) } }
if (validPaths.length > 0) { plugin.commandsPaths = validPaths } } }
// Step 4: Register agents directory if detected const agentsPath = join(pluginPath, 'agents') if (agentsDirExists) { plugin.agentsPath = agentsPath }
// Step 4a: Process additional agent paths from manifest if (manifest.agents) { const agentPaths = Array.isArray(manifest.agents) ? manifest.agents : [manifest.agents]
const validPaths = await validatePluginPaths( agentPaths, pluginPath, manifest.name, source, 'agents', 'Agent', 'specified in manifest but', errors, )
if (validPaths.length > 0) { plugin.agentsPaths = validPaths } }
// Step 4b: Register skills directory if detected const skillsPath = join(pluginPath, 'skills') if (skillsDirExists) { plugin.skillsPath = skillsPath }
// Step 4c: Process additional skill paths from manifest if (manifest.skills) { const skillPaths = Array.isArray(manifest.skills) ? manifest.skills : [manifest.skills]
const validPaths = await validatePluginPaths( skillPaths, pluginPath, manifest.name, source, 'skills', 'Skill', 'specified in manifest but', errors, )
if (validPaths.length > 0) { plugin.skillsPaths = validPaths } }
// Step 4d: Register output-styles directory if detected const outputStylesPath = join(pluginPath, 'output-styles') if (outputStylesDirExists) { plugin.outputStylesPath = outputStylesPath }
// Step 4e: Process additional output style paths from manifest if (manifest.outputStyles) { const outputStylePaths = Array.isArray(manifest.outputStyles) ? manifest.outputStyles : [manifest.outputStyles]
const validPaths = await validatePluginPaths( outputStylePaths, pluginPath, manifest.name, source, 'output-styles', 'Output style', 'specified in manifest but', errors, )
if (validPaths.length > 0) { plugin.outputStylesPaths = validPaths } }
// Step 5: Load hooks configuration let mergedHooks: HooksSettings | undefined const loadedHookPaths = new Set<string>() // Track loaded hook files
// Load from standard hooks/hooks.json if it exists const standardHooksPath = join(pluginPath, 'hooks', 'hooks.json') if (await pathExists(standardHooksPath)) { try { mergedHooks = await loadPluginHooks(standardHooksPath, manifest.name) // Track the normalized path to prevent duplicate loading try { loadedHookPaths.add(await realpath(standardHooksPath)) } catch { // If realpathSync fails, use original path loadedHookPaths.add(standardHooksPath) } logForDebugging( `Loaded hooks from standard location for plugin ${manifest.name}: ${standardHooksPath}`, ) } catch (error) { const errorMsg = errorMessage(error) logForDebugging( `Failed to load hooks for ${manifest.name}: ${errorMsg}`, { level: 'error', }, ) logError(toError(error)) errors.push({ type: 'hook-load-failed', source, plugin: manifest.name, hookPath: standardHooksPath, reason: errorMsg, }) } }
// Load and merge hooks from manifest.hooks if specified if (manifest.hooks) { const manifestHooksArray = Array.isArray(manifest.hooks) ? manifest.hooks : [manifest.hooks]
for (const hookSpec of manifestHooksArray) { if (typeof hookSpec === 'string') { // Path to additional hooks file const hookFilePath = join(pluginPath, hookSpec) if (!(await pathExists(hookFilePath))) { logForDebugging( `Hooks file ${hookSpec} specified in manifest but not found at ${hookFilePath} for ${manifest.name}`, { level: 'error' }, ) logError( new Error( `Plugin component file not found: ${hookFilePath} for ${manifest.name}`, ), ) errors.push({ type: 'path-not-found', source, plugin: manifest.name, path: hookFilePath, component: 'hooks', }) continue }
// Check if this path resolves to an already-loaded hooks file let normalizedPath: string try { normalizedPath = await realpath(hookFilePath) } catch { // If realpathSync fails, use original path normalizedPath = hookFilePath }
if (loadedHookPaths.has(normalizedPath)) { logForDebugging( `Skipping duplicate hooks file for plugin ${manifest.name}: ${hookSpec} ` + `(resolves to already-loaded file: ${normalizedPath})`, ) if (strict) { const errorMsg = `Duplicate hooks file detected: ${hookSpec} resolves to already-loaded file ${normalizedPath}. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.` logError(new Error(errorMsg)) errors.push({ type: 'hook-load-failed', source, plugin: manifest.name, hookPath: hookFilePath, reason: errorMsg, }) } continue }
try { const additionalHooks = await loadPluginHooks( hookFilePath, manifest.name, ) try { mergedHooks = mergeHooksSettings(mergedHooks, additionalHooks) loadedHookPaths.add(normalizedPath) logForDebugging( `Loaded and merged hooks from manifest for plugin ${manifest.name}: ${hookSpec}`, ) } catch (mergeError) { const mergeErrorMsg = errorMessage(mergeError) logForDebugging( `Failed to merge hooks from ${hookSpec} for ${manifest.name}: ${mergeErrorMsg}`, { level: 'error' }, ) logError(toError(mergeError)) errors.push({ type: 'hook-load-failed', source, plugin: manifest.name, hookPath: hookFilePath, reason: `Failed to merge: ${mergeErrorMsg}`, }) } } catch (error) { const errorMsg = errorMessage(error) logForDebugging( `Failed to load hooks from ${hookSpec} for ${manifest.name}: ${errorMsg}`, { level: 'error' }, ) logError(toError(error)) errors.push({ type: 'hook-load-failed', source, plugin: manifest.name, hookPath: hookFilePath, reason: errorMsg, }) } } else if (typeof hookSpec === 'object') { // Inline hooks mergedHooks = mergeHooksSettings(mergedHooks, hookSpec as HooksSettings) } } }
if (mergedHooks) { plugin.hooksConfig = mergedHooks }
// Step 6: Load plugin settings // Settings can come from settings.json in the plugin directory or from manifest.settings // Only allowlisted keys are kept (currently: agent) const pluginSettings = await loadPluginSettings(pluginPath, manifest) if (pluginSettings) { plugin.settings = pluginSettings }
return { plugin, errors }}
/** * Schema derived from SettingsSchema that only keeps keys plugins are allowed to set. * Uses .strip() so unknown keys are silently removed during parsing. */const PluginSettingsSchema = lazySchema(() => SettingsSchema() .pick({ agent: true, }) .strip(),)
/** * Parse raw settings through PluginSettingsSchema, returning only allowlisted keys. * Returns undefined if parsing fails or all keys are filtered out. */function parsePluginSettings( raw: Record<string, unknown>,): Record<string, unknown> | undefined { const result = PluginSettingsSchema().safeParse(raw) if (!result.success) { return undefined } const data = result.data if (Object.keys(data).length === 0) { return undefined } return data}
/** * Load plugin settings from settings.json file or manifest.settings. * settings.json takes priority over manifest.settings when both exist. * Only allowlisted keys are included in the result. */async function loadPluginSettings( pluginPath: string, manifest: PluginManifest,): Promise<Record<string, unknown> | undefined> { // Try loading settings.json from the plugin directory const settingsJsonPath = join(pluginPath, 'settings.json') try { const content = await readFile(settingsJsonPath, { encoding: 'utf-8' }) const parsed = jsonParse(content) if (isRecord(parsed)) { const filtered = parsePluginSettings(parsed) if (filtered) { logForDebugging( `Loaded settings from settings.json for plugin ${manifest.name}`, ) return filtered } } } catch (e: unknown) { // Missing/inaccessible is expected - settings.json is optional if (!isFsInaccessible(e)) { logForDebugging( `Failed to parse settings.json for plugin ${manifest.name}: ${e}`, { level: 'warn' }, ) } }
// Fall back to manifest.settings if (manifest.settings) { const filtered = parsePluginSettings( manifest.settings as Record<string, unknown>, ) if (filtered) { logForDebugging( `Loaded settings from manifest for plugin ${manifest.name}`, ) return filtered } }
return undefined}
/** * Merge two HooksSettings objects */function mergeHooksSettings( base: HooksSettings | undefined, additional: HooksSettings,): HooksSettings { if (!base) { return additional }
const merged = { ...base }
for (const [event, matchers] of Object.entries(additional)) { if (!merged[event as keyof HooksSettings]) { merged[event as keyof HooksSettings] = matchers } else { // Merge matchers for this event merged[event as keyof HooksSettings] = [ ...(merged[event as keyof HooksSettings] || []), ...matchers, ] } }
return merged}
/** * Shared discovery/policy/merge pipeline for both load modes. * * Resolves enabledPlugins → marketplace entries, runs enterprise policy * checks, pre-loads catalogs, then dispatches each entry to the full or * cache-only per-entry loader. The ONLY difference between loadAllPlugins * and loadAllPluginsCacheOnly is which loader runs — discovery and policy * are identical. */async function loadPluginsFromMarketplaces({ cacheOnly,}: { cacheOnly: boolean}): Promise<{ plugins: LoadedPlugin[] errors: PluginError[]}> { const settings = getSettings_DEPRECATED() // Merge --add-dir plugins at lowest priority; standard settings win on conflict const enabledPlugins = { ...getAddDirEnabledPlugins(), ...(settings.enabledPlugins || {}), } const plugins: LoadedPlugin[] = [] const errors: PluginError[] = []
// Filter to plugin@marketplace format and validate const marketplacePluginEntries = Object.entries(enabledPlugins).filter( ([key, value]) => { // Check if it's in plugin@marketplace format (includes both enabled and disabled) const isValidFormat = PluginIdSchema().safeParse(key).success if (!isValidFormat || value === undefined) return false // Skip built-in plugins — handled separately by getBuiltinPlugins() const { marketplace } = parsePluginIdentifier(key) return marketplace !== BUILTIN_MARKETPLACE_NAME }, )
// Load known marketplaces config to look up sources for policy checking. // Use the Safe variant so a corrupted config file doesn't crash all plugin // loading — this is a read-only path, so returning {} degrades gracefully. const knownMarketplaces = await loadKnownMarketplacesConfigSafe()
// Fail-closed guard for enterprise policy: if a policy IS configured and we // cannot resolve a marketplace's source (config returned {} due to corruption, // or entry missing), we must NOT silently skip the policy check and load the // plugin anyway. Before Safe, a corrupted config crashed everything (loud, // fail-closed). With Safe + no guard, the policy check short-circuits on // undefined marketplaceConfig and the fallback path (getPluginByIdCacheOnly) // loads the plugin unchecked — a silent fail-open. This guard restores // fail-closed: unknown source + active policy → block. // // Allowlist: any value (including []) is active — empty allowlist = deny all. // Blocklist: empty [] is a semantic no-op — only non-empty counts as active. const strictAllowlist = getStrictKnownMarketplaces() const blocklist = getBlockedMarketplaces() const hasEnterprisePolicy = strictAllowlist !== null || (blocklist !== null && blocklist.length > 0)
// Pre-load marketplace catalogs once per marketplace rather than re-reading // known_marketplaces.json + marketplace.json for every plugin. This is the // hot path — with N plugins across M marketplaces, the old per-plugin // getPluginByIdCacheOnly() did 2N config reads + N catalog reads; this does M. const uniqueMarketplaces = new Set( marketplacePluginEntries .map(([pluginId]) => parsePluginIdentifier(pluginId).marketplace) .filter((m): m is string => !!m), ) const marketplaceCatalogs = new Map< string, Awaited<ReturnType<typeof getMarketplaceCacheOnly>> >() await Promise.all( [...uniqueMarketplaces].map(async name => { marketplaceCatalogs.set(name, await getMarketplaceCacheOnly(name)) }), )
// Look up installed versions once so the first-pass ZIP cache check // can hit even when the marketplace entry omits `version`. const installedPluginsData = getInMemoryInstalledPlugins()
// Load all marketplace plugins in parallel for faster startup const results = await Promise.allSettled( marketplacePluginEntries.map(async ([pluginId, enabledValue]) => { const { name: pluginName, marketplace: marketplaceName } = parsePluginIdentifier(pluginId)
// Check if marketplace source is allowed by enterprise policy const marketplaceConfig = knownMarketplaces[marketplaceName!]
// Fail-closed: if enterprise policy is active and we can't look up the // marketplace source (config corrupted/empty, or entry missing), block // rather than silently skip the policy check. See hasEnterprisePolicy // comment above for the fail-open hazard this guards against. // // This also fires for the "stale enabledPlugins entry with no registered // marketplace" case, which is a UX trade-off: the user gets a policy // error instead of plugin-not-found. Accepted because the fallback path // (getPluginByIdCacheOnly) does a raw cast of known_marketplaces.json // with NO schema validation — if one entry is malformed enough to fail // our validation but readable enough for the raw cast, it would load // unchecked. Unverifiable source + active policy → block, always. if (!marketplaceConfig && hasEnterprisePolicy) { // We can't know whether the unverifiable source would actually be in // the blocklist or not in the allowlist — so pick the error variant // that matches whichever policy IS configured. If an allowlist exists, // "not in allowed list" is the right framing; if only a blocklist // exists, "blocked by blocklist" is less misleading than showing an // empty allowed-sources list. errors.push({ type: 'marketplace-blocked-by-policy', source: pluginId, plugin: pluginName, marketplace: marketplaceName!, blockedByBlocklist: strictAllowlist === null, allowedSources: (strictAllowlist ?? []).map(s => formatSourceForDisplay(s), ), }) return null }
if ( marketplaceConfig && !isSourceAllowedByPolicy(marketplaceConfig.source) ) { // Check if explicitly blocked vs not in allowlist for better error context const isBlocked = isSourceInBlocklist(marketplaceConfig.source) const allowlist = getStrictKnownMarketplaces() || [] errors.push({ type: 'marketplace-blocked-by-policy', source: pluginId, plugin: pluginName, marketplace: marketplaceName!, blockedByBlocklist: isBlocked, allowedSources: isBlocked ? [] : allowlist.map(s => formatSourceForDisplay(s)), }) return null }
// Look up plugin entry from pre-loaded marketplace catalog (no per-plugin I/O). // Fall back to getPluginByIdCacheOnly if the catalog couldn't be pre-loaded. let result: Awaited<ReturnType<typeof getPluginByIdCacheOnly>> = null const marketplace = marketplaceCatalogs.get(marketplaceName!) if (marketplace && marketplaceConfig) { const entry = marketplace.plugins.find(p => p.name === pluginName) if (entry) { result = { entry, marketplaceInstallLocation: marketplaceConfig.installLocation, } } } else { result = await getPluginByIdCacheOnly(pluginId) }
if (!result) { errors.push({ type: 'plugin-not-found', source: pluginId, pluginId: pluginName!, marketplace: marketplaceName!, }) return null }
// installed_plugins.json records what's actually cached on disk // (version for the full loader's first-pass probe, installPath for // the cache-only loader's direct read). const installEntry = installedPluginsData.plugins[pluginId]?.[0] return cacheOnly ? loadPluginFromMarketplaceEntryCacheOnly( result.entry, result.marketplaceInstallLocation, pluginId, enabledValue === true, errors, installEntry?.installPath, ) : loadPluginFromMarketplaceEntry( result.entry, result.marketplaceInstallLocation, pluginId, enabledValue === true, errors, installEntry?.version, ) }), )
for (const [i, result] of results.entries()) { if (result.status === 'fulfilled' && result.value) { plugins.push(result.value) } else if (result.status === 'rejected') { const err = toError(result.reason) logError(err) const pluginId = marketplacePluginEntries[i]![0] errors.push({ type: 'generic-error', source: pluginId, plugin: pluginId.split('@')[0], error: err.message, }) } }
return { plugins, errors }}
/** * Cache-only variant of loadPluginFromMarketplaceEntry. * * Skips network (cachePlugin) and disk-copy (copyPluginToVersionedCache). * Reads directly from the recorded installPath; if missing, emits * 'plugin-cache-miss'. Still extracts ZIP-cached plugins (local, fast). */async function loadPluginFromMarketplaceEntryCacheOnly( entry: PluginMarketplaceEntry, marketplaceInstallLocation: string, pluginId: string, enabled: boolean, errorsOut: PluginError[], installPath: string | undefined,): Promise<LoadedPlugin | null> { let pluginPath: string
if (typeof entry.source === 'string') { // Local relative path — read from the marketplace source dir directly. // Skip copyPluginToVersionedCache; startup doesn't need a fresh copy. let marketplaceDir: string try { marketplaceDir = (await stat(marketplaceInstallLocation)).isDirectory() ? marketplaceInstallLocation : join(marketplaceInstallLocation, '..') } catch { errorsOut.push({ type: 'plugin-cache-miss', source: pluginId, plugin: entry.name, installPath: marketplaceInstallLocation, }) return null } pluginPath = join(marketplaceDir, entry.source) // finishLoadingPluginFromPath reads pluginPath — its error handling // surfaces ENOENT as a load failure, no need to pre-check here. } else { // External source (npm/github/url/git-subdir) — use recorded installPath. if (!installPath || !(await pathExists(installPath))) { errorsOut.push({ type: 'plugin-cache-miss', source: pluginId, plugin: entry.name, installPath: installPath ?? '(not recorded)', }) return null } pluginPath = installPath }
// Zip cache extraction — must still happen in cacheOnly mode (invariant 4) if (isPluginZipCacheEnabled() && pluginPath.endsWith('.zip')) { const sessionDir = await getSessionPluginCachePath() const extractDir = join( sessionDir, pluginId.replace(/[^a-zA-Z0-9@\-_]/g, '-'), ) try { await extractZipToDirectory(pluginPath, extractDir) pluginPath = extractDir } catch (error) { logForDebugging(`Failed to extract plugin ZIP ${pluginPath}: ${error}`, { level: 'error', }) errorsOut.push({ type: 'plugin-cache-miss', source: pluginId, plugin: entry.name, installPath: pluginPath, }) return null } }
// Delegate to the shared tail — identical to the full loader from here return finishLoadingPluginFromPath( entry, pluginId, enabled, errorsOut, pluginPath, )}
/** * Load a plugin from a marketplace entry based on its source configuration. * * Handles different source types: * - Relative path: Loads from marketplace repo directory * - npm/github/url: Caches then loads from cache * * @param installedVersion - Version from installed_plugins.json, used as a * first-pass hint for the versioned cache lookup when the marketplace entry * omits `version`. Avoids re-cloning external plugins just to discover the * version we already recorded at install time. * * Returns both the loaded plugin and any errors encountered during loading. * Errors include missing component files and hook load failures. */async function loadPluginFromMarketplaceEntry( entry: PluginMarketplaceEntry, marketplaceInstallLocation: string, pluginId: string, enabled: boolean, errorsOut: PluginError[], installedVersion?: string,): Promise<LoadedPlugin | null> { logForDebugging( `Loading plugin ${entry.name} from source: ${jsonStringify(entry.source)}`, ) let pluginPath: string
if (typeof entry.source === 'string') { // Relative path - resolve relative to marketplace install location const marketplaceDir = ( await stat(marketplaceInstallLocation) ).isDirectory() ? marketplaceInstallLocation : join(marketplaceInstallLocation, '..') const sourcePluginPath = join(marketplaceDir, entry.source)
if (!(await pathExists(sourcePluginPath))) { const error = new Error(`Plugin path not found: ${sourcePluginPath}`) logForDebugging(`Plugin path not found: ${sourcePluginPath}`, { level: 'error', }) logError(error) errorsOut.push({ type: 'generic-error', source: pluginId, error: `Plugin directory not found at path: ${sourcePluginPath}. Check that the marketplace entry has the correct path.`, }) return null }
// Always copy local plugins to versioned cache try { // Try to load manifest from plugin directory to check for version field first const manifestPath = join( sourcePluginPath, '.claude-plugin', 'plugin.json', ) let pluginManifest: PluginManifest | undefined try { pluginManifest = await loadPluginManifest( manifestPath, entry.name, entry.source, ) } catch { // Manifest loading failed - will fall back to provided version or git SHA }
// Calculate version with fallback order: // 1. Plugin manifest version, 2. Marketplace entry version, 3. Git SHA, 4. 'unknown' const version = await calculatePluginVersion( pluginId, entry.source, pluginManifest, marketplaceDir, entry.version, // Marketplace entry version as fallback )
// Copy to versioned cache pluginPath = await copyPluginToVersionedCache( sourcePluginPath, pluginId, version, entry, marketplaceDir, )
logForDebugging( `Resolved local plugin ${entry.name} to versioned cache: ${pluginPath}`, ) } catch (error) { // If copy fails, fall back to loading from marketplace directly const errorMsg = errorMessage(error) logForDebugging( `Failed to copy plugin ${entry.name} to versioned cache: ${errorMsg}. Using marketplace path.`, { level: 'warn' }, ) pluginPath = sourcePluginPath } } else { // External source (npm, github, url, pip) - always use versioned cache try { // Calculate version with fallback order: // 1. No manifest yet, 2. installed_plugins.json version, // 3. Marketplace entry version, 4. source.sha (pinned commits — the // exact value the post-clone call at cached.gitCommitSha would see), // 5. 'unknown' → ref-tracked, falls through to clone by design. const version = await calculatePluginVersion( pluginId, entry.source, undefined, undefined, installedVersion ?? entry.version, 'sha' in entry.source ? entry.source.sha : undefined, )
const versionedPath = getVersionedCachePath(pluginId, version)
// Check for cached version — ZIP file (zip cache mode) or directory const zipPath = getVersionedZipCachePath(pluginId, version) if (isPluginZipCacheEnabled() && (await pathExists(zipPath))) { logForDebugging( `Using versioned cached plugin ZIP ${entry.name} from ${zipPath}`, ) pluginPath = zipPath } else if (await pathExists(versionedPath)) { logForDebugging( `Using versioned cached plugin ${entry.name} from ${versionedPath}`, ) pluginPath = versionedPath } else { // Seed cache probe (CCR pre-baked images, read-only). Seed content is // frozen at image build time — no freshness concern, 'whatever's there' // is what the image builder put there. Primary cache is NOT probed // here; ref-tracked sources fall through to clone (the re-clone IS // the freshness mechanism). If the clone fails, the plugin is simply // disabled for this session — errorsOut.push below surfaces it. const seedPath = (await probeSeedCache(pluginId, version)) ?? (version === 'unknown' ? await probeSeedCacheAnyVersion(pluginId) : null) if (seedPath) { pluginPath = seedPath logForDebugging( `Using seed cache for external plugin ${entry.name} at ${seedPath}`, ) } else { // Download to temp location, then copy to versioned cache const cached = await cachePlugin(entry.source, { manifest: { name: entry.name }, })
// If the pre-clone version was deterministic (source.sha / // entry.version / installedVersion), REUSE it. The post-clone // recomputation with cached.manifest can return a DIFFERENT value // — manifest.version (step 1) outranks gitCommitSha (step 3) — // which would cache at e.g. "2.0.0/" while every warm start // probes "{sha12}-{hash}/". Mismatched keys = re-clone forever. // Recomputation is only needed when pre-clone was 'unknown' // (ref-tracked, no hints) — the clone is the ONLY way to learn. const actualVersion = version !== 'unknown' ? version : await calculatePluginVersion( pluginId, entry.source, cached.manifest, cached.path, installedVersion ?? entry.version, cached.gitCommitSha, )
// Copy to versioned cache // For external sources, marketplaceDir is not applicable (already downloaded) pluginPath = await copyPluginToVersionedCache( cached.path, pluginId, actualVersion, entry, undefined, )
// Clean up temp path if (cached.path !== pluginPath) { await rm(cached.path, { recursive: true, force: true }) } } } } catch (error) { const errorMsg = errorMessage(error) logForDebugging(`Failed to cache plugin ${entry.name}: ${errorMsg}`, { level: 'error', }) logError(toError(error)) errorsOut.push({ type: 'generic-error', source: pluginId, error: `Failed to download/cache plugin ${entry.name}: ${errorMsg}`, }) return null } }
// Zip cache mode: extract ZIP to session temp dir before loading if (isPluginZipCacheEnabled() && pluginPath.endsWith('.zip')) { const sessionDir = await getSessionPluginCachePath() const extractDir = join( sessionDir, pluginId.replace(/[^a-zA-Z0-9@\-_]/g, '-'), ) try { await extractZipToDirectory(pluginPath, extractDir) logForDebugging(`Extracted plugin ZIP to session dir: ${extractDir}`) pluginPath = extractDir } catch (error) { // Corrupt ZIP: delete it so next install attempt re-creates it logForDebugging( `Failed to extract plugin ZIP ${pluginPath}, deleting corrupt file: ${error}`, ) await rm(pluginPath, { force: true }).catch(() => {}) throw error } }
return finishLoadingPluginFromPath( entry, pluginId, enabled, errorsOut, pluginPath, )}
/** * Shared tail of both loadPluginFromMarketplaceEntry variants. * * Once pluginPath is resolved (via clone, cache, or installPath lookup), * the rest of the load — manifest probe, createPluginFromPath, marketplace * entry supplementation — is identical. Extracted so the cache-only path * doesn't duplicate ~500 lines. */async function finishLoadingPluginFromPath( entry: PluginMarketplaceEntry, pluginId: string, enabled: boolean, errorsOut: PluginError[], pluginPath: string,): Promise<LoadedPlugin | null> { const errors: PluginError[] = []
// Check if plugin.json exists to determine if we should use marketplace manifest const manifestPath = join(pluginPath, '.claude-plugin', 'plugin.json') const hasManifest = await pathExists(manifestPath)
const { plugin, errors: pluginErrors } = await createPluginFromPath( pluginPath, pluginId, enabled, entry.name, entry.strict ?? true, // Respect marketplace entry's strict setting ) errors.push(...pluginErrors)
// Set sha from source if available (for github and url source types) if ( typeof entry.source === 'object' && 'sha' in entry.source && entry.source.sha ) { plugin.sha = entry.source.sha }
// If there's no plugin.json, use marketplace entry as manifest (regardless of strict mode) if (!hasManifest) { plugin.manifest = { ...entry, id: undefined, source: undefined, strict: undefined, } as PluginManifest plugin.name = plugin.manifest.name
// Process commands from marketplace entry if (entry.commands) { // Check if it's an object mapping const firstValue = Object.values(entry.commands)[0] if ( typeof entry.commands === 'object' && !Array.isArray(entry.commands) && firstValue && typeof firstValue === 'object' && ('source' in firstValue || 'content' in firstValue) ) { // Object mapping format const commandsMetadata: Record<string, CommandMetadata> = {} const validPaths: string[] = []
// Parallelize pathExists checks; process results in order. const entries = Object.entries(entry.commands) const checks = await Promise.all( entries.map(async ([commandName, metadata]) => { if (!metadata || typeof metadata !== 'object' || !metadata.source) { return { commandName, metadata, skip: true as const } } const fullPath = join(pluginPath, metadata.source) return { commandName, metadata, skip: false as const, fullPath, exists: await pathExists(fullPath), } }), ) for (const check of checks) { if (check.skip) continue if (check.exists) { validPaths.push(check.fullPath) commandsMetadata[check.commandName] = check.metadata } else { logForDebugging( `Command ${check.commandName} path ${check.metadata.source} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${check.fullPath} for ${entry.name}`, ), ) errors.push({ type: 'path-not-found', source: pluginId, plugin: entry.name, path: check.fullPath, component: 'commands', }) } }
if (validPaths.length > 0) { plugin.commandsPaths = validPaths plugin.commandsMetadata = commandsMetadata } } else { // Path or array of paths format const commandPaths = Array.isArray(entry.commands) ? entry.commands : [entry.commands]
// Parallelize pathExists checks; process results in order. const checks = await Promise.all( commandPaths.map(async cmdPath => { if (typeof cmdPath !== 'string') { return { cmdPath, kind: 'invalid' as const } } const fullPath = join(pluginPath, cmdPath) return { cmdPath, kind: 'path' as const, fullPath, exists: await pathExists(fullPath), } }), ) const validPaths: string[] = [] for (const check of checks) { if (check.kind === 'invalid') { logForDebugging( `Unexpected command format in marketplace entry for ${entry.name}`, { level: 'error' }, ) continue } if (check.exists) { validPaths.push(check.fullPath) } else { logForDebugging( `Command path ${check.cmdPath} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${check.fullPath} for ${entry.name}`, ), ) errors.push({ type: 'path-not-found', source: pluginId, plugin: entry.name, path: check.fullPath, component: 'commands', }) } }
if (validPaths.length > 0) { plugin.commandsPaths = validPaths } } }
// Process agents from marketplace entry if (entry.agents) { const agentPaths = Array.isArray(entry.agents) ? entry.agents : [entry.agents]
const validPaths = await validatePluginPaths( agentPaths, pluginPath, entry.name, pluginId, 'agents', 'Agent', 'from marketplace entry', errors, )
if (validPaths.length > 0) { plugin.agentsPaths = validPaths } }
// Process skills from marketplace entry if (entry.skills) { logForDebugging( `Processing ${Array.isArray(entry.skills) ? entry.skills.length : 1} skill paths for plugin ${entry.name}`, ) const skillPaths = Array.isArray(entry.skills) ? entry.skills : [entry.skills]
// Parallelize pathExists checks; process results in order. // Note: previously this loop called pathExists() TWICE per iteration // (once in a debug log template, once in the if) — now called once. const checks = await Promise.all( skillPaths.map(async skillPath => { const fullPath = join(pluginPath, skillPath) return { skillPath, fullPath, exists: await pathExists(fullPath) } }), ) const validPaths: string[] = [] for (const { skillPath, fullPath, exists } of checks) { logForDebugging( `Checking skill path: ${skillPath} -> ${fullPath} (exists: ${exists})`, ) if (exists) { validPaths.push(fullPath) } else { logForDebugging( `Skill path ${skillPath} from marketplace entry not found at ${fullPath} for ${entry.name}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${fullPath} for ${entry.name}`, ), ) errors.push({ type: 'path-not-found', source: pluginId, plugin: entry.name, path: fullPath, component: 'skills', }) } }
logForDebugging( `Found ${validPaths.length} valid skill paths for plugin ${entry.name}, setting skillsPaths`, ) if (validPaths.length > 0) { plugin.skillsPaths = validPaths } } else { logForDebugging(`Plugin ${entry.name} has no entry.skills defined`) }
// Process output styles from marketplace entry if (entry.outputStyles) { const outputStylePaths = Array.isArray(entry.outputStyles) ? entry.outputStyles : [entry.outputStyles]
const validPaths = await validatePluginPaths( outputStylePaths, pluginPath, entry.name, pluginId, 'output-styles', 'Output style', 'from marketplace entry', errors, )
if (validPaths.length > 0) { plugin.outputStylesPaths = validPaths } }
// Process inline hooks from marketplace entry if (entry.hooks) { plugin.hooksConfig = entry.hooks as HooksSettings } } else if ( !entry.strict && hasManifest && (entry.commands || entry.agents || entry.skills || entry.hooks || entry.outputStyles) ) { // In non-strict mode with plugin.json, marketplace entries for commands/agents/skills/hooks/outputStyles are conflicts const error = new Error( `Plugin ${entry.name} has both plugin.json and marketplace manifest entries for commands/agents/skills/hooks/outputStyles. This is a conflict.`, ) logForDebugging( `Plugin ${entry.name} has both plugin.json and marketplace manifest entries for commands/agents/skills/hooks/outputStyles. This is a conflict.`, { level: 'error' }, ) logError(error) errorsOut.push({ type: 'generic-error', source: pluginId, error: `Plugin ${entry.name} has conflicting manifests: both plugin.json and marketplace entry specify components. Set strict: true in marketplace entry or remove component specs from one location.`, }) return null } else if (hasManifest) { // Has plugin.json - marketplace can supplement commands/agents/skills/hooks/outputStyles
// Supplement commands from marketplace entry if (entry.commands) { // Check if it's an object mapping const firstValue = Object.values(entry.commands)[0] if ( typeof entry.commands === 'object' && !Array.isArray(entry.commands) && firstValue && typeof firstValue === 'object' && ('source' in firstValue || 'content' in firstValue) ) { // Object mapping format - merge metadata const commandsMetadata: Record<string, CommandMetadata> = { ...(plugin.commandsMetadata || {}), } const validPaths: string[] = []
// Parallelize pathExists checks; process results in order. const entries = Object.entries(entry.commands) const checks = await Promise.all( entries.map(async ([commandName, metadata]) => { if (!metadata || typeof metadata !== 'object' || !metadata.source) { return { commandName, metadata, skip: true as const } } const fullPath = join(pluginPath, metadata.source) return { commandName, metadata, skip: false as const, fullPath, exists: await pathExists(fullPath), } }), ) for (const check of checks) { if (check.skip) continue if (check.exists) { validPaths.push(check.fullPath) commandsMetadata[check.commandName] = check.metadata } else { logForDebugging( `Command ${check.commandName} path ${check.metadata.source} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${check.fullPath} for ${entry.name}`, ), ) errors.push({ type: 'path-not-found', source: pluginId, plugin: entry.name, path: check.fullPath, component: 'commands', }) } }
if (validPaths.length > 0) { plugin.commandsPaths = [ ...(plugin.commandsPaths || []), ...validPaths, ] plugin.commandsMetadata = commandsMetadata } } else { // Path or array of paths format const commandPaths = Array.isArray(entry.commands) ? entry.commands : [entry.commands]
// Parallelize pathExists checks; process results in order. const checks = await Promise.all( commandPaths.map(async cmdPath => { if (typeof cmdPath !== 'string') { return { cmdPath, kind: 'invalid' as const } } const fullPath = join(pluginPath, cmdPath) return { cmdPath, kind: 'path' as const, fullPath, exists: await pathExists(fullPath), } }), ) const validPaths: string[] = [] for (const check of checks) { if (check.kind === 'invalid') { logForDebugging( `Unexpected command format in marketplace entry for ${entry.name}`, { level: 'error' }, ) continue } if (check.exists) { validPaths.push(check.fullPath) } else { logForDebugging( `Command path ${check.cmdPath} from marketplace entry not found at ${check.fullPath} for ${entry.name}`, { level: 'warn' }, ) logError( new Error( `Plugin component file not found: ${check.fullPath} for ${entry.name}`, ), ) errors.push({ type: 'path-not-found', source: pluginId, plugin: entry.name, path: check.fullPath, component: 'commands', }) } }
if (validPaths.length > 0) { plugin.commandsPaths = [ ...(plugin.commandsPaths || []), ...validPaths, ] } } }
// Supplement agents from marketplace entry if (entry.agents) { const agentPaths = Array.isArray(entry.agents) ? entry.agents : [entry.agents]
const validPaths = await validatePluginPaths( agentPaths, pluginPath, entry.name, pluginId, 'agents', 'Agent', 'from marketplace entry', errors, )
if (validPaths.length > 0) { plugin.agentsPaths = [...(plugin.agentsPaths || []), ...validPaths] } }
// Supplement skills from marketplace entry if (entry.skills) { const skillPaths = Array.isArray(entry.skills) ? entry.skills : [entry.skills]
const validPaths = await validatePluginPaths( skillPaths, pluginPath, entry.name, pluginId, 'skills', 'Skill', 'from marketplace entry', errors, )
if (validPaths.length > 0) { plugin.skillsPaths = [...(plugin.skillsPaths || []), ...validPaths] } }
// Supplement output styles from marketplace entry if (entry.outputStyles) { const outputStylePaths = Array.isArray(entry.outputStyles) ? entry.outputStyles : [entry.outputStyles]
const validPaths = await validatePluginPaths( outputStylePaths, pluginPath, entry.name, pluginId, 'output-styles', 'Output style', 'from marketplace entry', errors, )
if (validPaths.length > 0) { plugin.outputStylesPaths = [ ...(plugin.outputStylesPaths || []), ...validPaths, ] } }
// Supplement hooks from marketplace entry if (entry.hooks) { plugin.hooksConfig = { ...(plugin.hooksConfig || {}), ...(entry.hooks as HooksSettings), } } }
errorsOut.push(...errors) return plugin}
/** * Load session-only plugins from --plugin-dir CLI flag. * * These plugins are loaded directly without going through the marketplace system. * They appear with source='plugin-name@inline' and are always enabled for the current session. * * @param sessionPluginPaths - Array of plugin directory paths from CLI * @returns LoadedPlugin objects and any errors encountered */async function loadSessionOnlyPlugins( sessionPluginPaths: Array<string>,): Promise<{ plugins: LoadedPlugin[]; errors: PluginError[] }> { if (sessionPluginPaths.length === 0) { return { plugins: [], errors: [] } }
const plugins: LoadedPlugin[] = [] const errors: PluginError[] = []
for (const [index, pluginPath] of sessionPluginPaths.entries()) { try { const resolvedPath = resolve(pluginPath)
if (!(await pathExists(resolvedPath))) { logForDebugging( `Plugin path does not exist: ${resolvedPath}, skipping`, { level: 'warn' }, ) errors.push({ type: 'path-not-found', source: `inline[${index}]`, path: resolvedPath, component: 'commands', }) continue }
const dirName = basename(resolvedPath) const { plugin, errors: pluginErrors } = await createPluginFromPath( resolvedPath, `${dirName}@inline`, // temporary, will be updated after we know the real name true, // always enabled dirName, )
// Update source to use the actual plugin name from manifest plugin.source = `${plugin.name}@inline` plugin.repository = `${plugin.name}@inline`
plugins.push(plugin) errors.push(...pluginErrors)
logForDebugging(`Loaded inline plugin from path: ${plugin.name}`) } catch (error) { const errorMsg = errorMessage(error) logForDebugging( `Failed to load session plugin from ${pluginPath}: ${errorMsg}`, { level: 'warn' }, ) errors.push({ type: 'generic-error', source: `inline[${index}]`, error: `Failed to load plugin: ${errorMsg}`, }) } }
if (plugins.length > 0) { logForDebugging( `Loaded ${plugins.length} session-only plugins from --plugin-dir`, ) }
return { plugins, errors }}
/** * Merge plugins from session (--plugin-dir), marketplace (installed), and * builtin sources. Session plugins override marketplace plugins with the * same name — the user explicitly pointed at a directory for this session. * * Exception: marketplace plugins locked by managed settings (policySettings) * cannot be overridden. Enterprise admin intent beats local dev convenience. * When a session plugin collides with a managed one, the session copy is * dropped and an error is returned for surfacing. * * Without this dedup, both versions sat in the array and marketplace won * on first-match, making --plugin-dir useless for iterating on an * installed plugin. */export function mergePluginSources(sources: { session: LoadedPlugin[] marketplace: LoadedPlugin[] builtin: LoadedPlugin[] managedNames?: Set<string> | null}): { plugins: LoadedPlugin[]; errors: PluginError[] } { const errors: PluginError[] = [] const managed = sources.managedNames
// Managed settings win over --plugin-dir. Drop session plugins whose // name appears in policySettings.enabledPlugins (whether force-enabled // OR force-disabled — both are admin intent that --plugin-dir must not // bypass). Surface an error so the user knows why their dev copy was // ignored. // // NOTE: managedNames contains the pluginId prefix (entry.name), which is // expected to equal manifest.name by convention (schema description at // schemas.ts PluginMarketplaceEntry.name). If a marketplace publishes a // plugin where entry.name ≠ manifest.name, this guard will silently miss — // but that's a marketplace misconfiguration that breaks other things too // (e.g., ManagePlugins constructs pluginIds from manifest.name). const sessionPlugins = sources.session.filter(p => { if (managed?.has(p.name)) { logForDebugging( `Plugin "${p.name}" from --plugin-dir is blocked by managed settings`, { level: 'warn' }, ) errors.push({ type: 'generic-error', source: p.source, plugin: p.name, error: `--plugin-dir copy of "${p.name}" ignored: plugin is locked by managed settings`, }) return false } return true })
const sessionNames = new Set(sessionPlugins.map(p => p.name)) const marketplacePlugins = sources.marketplace.filter(p => { if (sessionNames.has(p.name)) { logForDebugging( `Plugin "${p.name}" from --plugin-dir overrides installed version`, ) return false } return true }) // Session first, then non-overridden marketplace, then builtin. // Downstream first-match consumers see session plugins before // installed ones for any that slipped past the name filter. return { plugins: [...sessionPlugins, ...marketplacePlugins, ...sources.builtin], errors, }}
/** * Main plugin loading function that discovers and loads all plugins. * * This function is memoized to avoid repeated filesystem scanning and is * the primary entry point for the plugin system. It discovers plugins from * multiple sources and returns categorized results. * * Loading order and precedence (see mergePluginSources): * 1. Session-only plugins (from --plugin-dir CLI flag) — override * installed plugins with the same name, UNLESS that plugin is * locked by managed settings (policySettings, either force-enabled * or force-disabled) * 2. Marketplace-based plugins (plugin@marketplace format from settings) * 3. Built-in plugins shipped with the CLI * * Name collision: session plugin wins over installed. The user explicitly * pointed at a directory for this session — that intent beats whatever * is installed. Exception: managed settings (enterprise policy) win over * --plugin-dir. Admin intent beats local dev convenience. * * Error collection: * - Non-fatal errors are collected and returned * - System continues loading other plugins on errors * - Errors include source information for debugging * * @returns Promise resolving to categorized plugin results: * - enabled: Array of enabled LoadedPlugin objects * - disabled: Array of disabled LoadedPlugin objects * - errors: Array of loading errors with source information */export const loadAllPlugins = memoize(async (): Promise<PluginLoadResult> => { const result = await assemblePluginLoadResult(() => loadPluginsFromMarketplaces({ cacheOnly: false }), ) // A fresh full-load result is strictly valid for cache-only callers // (both variants share assemblePluginLoadResult). Warm the separate // memoize so refreshActivePlugins()'s downstream getPluginCommands() / // getAgentDefinitionsWithOverrides() — which now call // loadAllPluginsCacheOnly — see just-cloned plugins instead of reading // an installed_plugins.json that nothing writes mid-session. loadAllPluginsCacheOnly.cache?.set(undefined, Promise.resolve(result)) return result})
/** * Cache-only variant of loadAllPlugins. * * Same merge/dependency/settings logic, but the marketplace loader never * hits the network (no cachePlugin, no copyPluginToVersionedCache). Reads * from installed_plugins.json's installPath. Plugins not on disk emit * 'plugin-cache-miss' and are skipped. * * Use this in startup consumers (getCommands, loadPluginAgents, MCP/LSP * config) so interactive startup never blocks on git clones for ref-tracked * plugins. Use loadAllPlugins() in explicit refresh paths (/plugins, * refresh.ts, headlessPluginInstall) where fresh source is the intent. * * CLAUDE_CODE_SYNC_PLUGIN_INSTALL=1 delegates to the full loader — that * mode explicitly opts into blocking install before first query, and * main.tsx's getClaudeCodeMcpConfigs()/getInitialSettings().agent run * BEFORE runHeadless() can warm this cache. First-run CCR/headless has * no installed_plugins.json, so cache-only would miss plugin MCP servers * and plugin settings (the agent key). The interactive startup win is * preserved since interactive mode doesn't set SYNC_PLUGIN_INSTALL. * * Separate memoize cache from loadAllPlugins — a cache-only result must * never satisfy a caller that wants fresh source. The reverse IS valid: * loadAllPlugins warms this cache on completion so refresh paths that run * the full loader don't get plugin-cache-miss from their downstream * cache-only consumers. */export const loadAllPluginsCacheOnly = memoize( async (): Promise<PluginLoadResult> => { if (isEnvTruthy(process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL)) { return loadAllPlugins() } return assemblePluginLoadResult(() => loadPluginsFromMarketplaces({ cacheOnly: true }), ) },)
/** * Shared body of loadAllPlugins and loadAllPluginsCacheOnly. * * The only difference between the two is which marketplace loader runs — * session plugins, builtins, merge, verifyAndDemote, and cachePluginSettings * are identical (invariants 1-3). */async function assemblePluginLoadResult( marketplaceLoader: () => Promise<{ plugins: LoadedPlugin[] errors: PluginError[] }>,): Promise<PluginLoadResult> { // Load marketplace plugins and session-only plugins in parallel. // getInlinePlugins() is a synchronous state read with no dependency on // marketplace loading, so these two sources can be fetched concurrently. const inlinePlugins = getInlinePlugins() const [marketplaceResult, sessionResult] = await Promise.all([ marketplaceLoader(), inlinePlugins.length > 0 ? loadSessionOnlyPlugins(inlinePlugins) : Promise.resolve({ plugins: [], errors: [] }), ]) // 3. Load built-in plugins that ship with the CLI const builtinResult = getBuiltinPlugins()
// Session plugins (--plugin-dir) override installed ones by name, // UNLESS the installed plugin is locked by managed settings // (policySettings). See mergePluginSources() for details. const { plugins: allPlugins, errors: mergeErrors } = mergePluginSources({ session: sessionResult.plugins, marketplace: marketplaceResult.plugins, builtin: [...builtinResult.enabled, ...builtinResult.disabled], managedNames: getManagedPluginNames(), }) const allErrors = [ ...marketplaceResult.errors, ...sessionResult.errors, ...mergeErrors, ]
// Verify dependencies. Runs AFTER the parallel load — deps are presence // checks, not load-order, so no topological sort needed. Demotion is // session-local: does NOT write settings (user fixes intent via /doctor). const { demoted, errors: depErrors } = verifyAndDemote(allPlugins) for (const p of allPlugins) { if (demoted.has(p.source)) p.enabled = false } allErrors.push(...depErrors)
const enabledPlugins = allPlugins.filter(p => p.enabled) logForDebugging( `Found ${allPlugins.length} plugins (${enabledPlugins.length} enabled, ${allPlugins.length - enabledPlugins.length} disabled)`, )
// 3. Cache plugin settings for synchronous access by the settings cascade cachePluginSettings(enabledPlugins)
return { enabled: enabledPlugins, disabled: allPlugins.filter(p => !p.enabled), errors: allErrors, }}
/** * Clears the memoized plugin cache. * * Call this when plugins are installed, removed, or settings change * to force a fresh scan on the next loadAllPlugins call. * * Use cases: * - After installing/uninstalling plugins * - After modifying .claude-plugin/ directory (for export) * - After changing enabledPlugins settings * - When debugging plugin loading issues */export function clearPluginCache(reason?: string): void { if (reason) { logForDebugging( `clearPluginCache: invalidating loadAllPlugins cache (${reason})`, ) } loadAllPlugins.cache?.clear?.() loadAllPluginsCacheOnly.cache?.clear?.() // If a plugin previously contributed settings, the session settings cache // holds a merged result that includes them. cachePluginSettings() on reload // won't bust the cache when the new base is empty (the startup perf win), // so bust it here to drop stale plugin overrides. When the base is already // undefined (startup, or no prior plugin settings) this is a no-op. if (getPluginSettingsBase() !== undefined) { resetSettingsCache() } clearPluginSettingsBase() // TODO: Clear installed plugins cache when installedPluginsManager is implemented}
/** * Merge settings from all enabled plugins into a single record. * Later plugins override earlier ones for the same key. * Only allowlisted keys are included (filtering happens at load time). */function mergePluginSettings( plugins: LoadedPlugin[],): Record<string, unknown> | undefined { let merged: Record<string, unknown> | undefined
for (const plugin of plugins) { if (!plugin.settings) { continue }
if (!merged) { merged = {} }
for (const [key, value] of Object.entries(plugin.settings)) { if (key in merged) { logForDebugging( `Plugin "${plugin.name}" overrides setting "${key}" (previously set by another plugin)`, ) } merged[key] = value } }
return merged}
/** * Store merged plugin settings in the synchronous cache. * Called after loadAllPlugins resolves. */export function cachePluginSettings(plugins: LoadedPlugin[]): void { const settings = mergePluginSettings(plugins) setPluginSettingsBase(settings) // Only bust the session settings cache if there are actually plugin settings // to merge. In the common case (no plugins, or plugins without settings) the // base layer is empty and loadSettingsFromDisk would produce the same result // anyway — resetting here would waste ~17ms on startup re-reading and // re-validating every settings file on the next getSettingsWithErrors() call. if (settings && Object.keys(settings).length > 0) { resetSettingsCache() logForDebugging( `Cached plugin settings with keys: ${Object.keys(settings).join(', ')}`, ) }}
/** * Type predicate: check if a value is a non-null, non-array object (i.e., a record). */function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value)}