/**
* 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)
}