3303 lines
108 KiB
TypeScript
3303 lines
108 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|