| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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' |
|
|
| |
| |
| |
| export function getPluginCachePath(): string { |
| return join(getPluginsDirectory(), 'cache') |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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, |
| '-', |
| ) |
| |
| const sanitizedVersion = version.replace(/[^a-zA-Z0-9\-_.]/g, '-') |
| return join( |
| baseDir, |
| 'cache', |
| sanitizedMarketplace, |
| sanitizedPlugin, |
| sanitizedVersion, |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function getVersionedCachePath( |
| pluginId: string, |
| version: string, |
| ): string { |
| return getVersionedCachePathIn(getPluginsDirectory(), pluginId, version) |
| } |
|
|
| |
| |
| |
| |
| export function getVersionedZipCachePath( |
| pluginId: string, |
| version: string, |
| ): string { |
| return `${getVersionedCachePath(pluginId, version)}.zip` |
| } |
|
|
| |
| |
| |
| |
| |
| 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 { |
| |
| } |
| } |
| return null |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function probeSeedCacheAnyVersion( |
| pluginId: string, |
| ): Promise<string | null> { |
| for (const seedDir of getPluginSeedDirs()) { |
| |
| |
| 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 { |
| |
| } |
| } |
| return null |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function getLegacyCachePath(pluginName: string): string { |
| const cachePath = getPluginCachePath() |
| return join(cachePath, pluginName.replace(/[^a-zA-Z0-9\-_]/g, '-')) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function resolvePluginPath( |
| pluginId: string, |
| version?: string, |
| ): Promise<string> { |
| |
| if (version) { |
| const versionedPath = getVersionedCachePath(pluginId, version) |
| if (await pathExists(versionedPath)) { |
| return versionedPath |
| } |
| } |
|
|
| |
| const pluginName = parsePluginIdentifier(pluginId).name || pluginId |
| const legacyPath = getLegacyCachePath(pluginName) |
| if (await pathExists(legacyPath)) { |
| return legacyPath |
| } |
|
|
| |
| return version ? getVersionedCachePath(pluginId, version) : legacyPath |
| } |
|
|
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| let resolvedTarget: string |
| try { |
| resolvedTarget = await realpath(srcPath) |
| } catch { |
| |
| await symlink(linkTarget, destPath) |
| continue |
| } |
|
|
| |
| let resolvedSrc: string |
| try { |
| resolvedSrc = await realpath(src) |
| } catch { |
| resolvedSrc = src |
| } |
|
|
| |
| const srcPrefix = resolvedSrc.endsWith(sep) |
| ? resolvedSrc |
| : resolvedSrc + sep |
| if ( |
| resolvedTarget.startsWith(srcPrefix) || |
| resolvedTarget === resolvedSrc |
| ) { |
| |
| |
| const targetRelativeToSrc = relative(resolvedSrc, resolvedTarget) |
| const destTargetPath = join(dest, targetRelativeToSrc) |
| const relativeLinkPath = relative(dirname(destPath), destTargetPath) |
| await symlink(relativeLinkPath, destPath) |
| } else { |
| |
| await symlink(resolvedTarget, destPath) |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function copyPluginToVersionedCache( |
| sourcePath: string, |
| pluginId: string, |
| version: string, |
| entry?: PluginMarketplaceEntry, |
| marketplaceDir?: string, |
| ): Promise<string> { |
| |
| const zipCacheMode = isPluginZipCacheEnabled() |
| const cachePath = getVersionedCachePath(pluginId, version) |
| const zipPath = getVersionedZipCachePath(pluginId, version) |
|
|
| |
| 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 |
| } |
| |
| logForDebugging( |
| `Removing empty cache directory for ${pluginId} at ${cachePath}`, |
| ) |
| await rmdir(cachePath) |
| } |
|
|
| |
| |
| const seedPath = await probeSeedCache(pluginId, version) |
| if (seedPath) { |
| logForDebugging( |
| `Using seed cache for ${pluginId}@${version} at ${seedPath}`, |
| ) |
| return seedPath |
| } |
|
|
| |
| await getFsImplementation().mkdir(dirname(cachePath)) |
|
|
| |
| |
| 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) { |
| |
| |
| |
| if (isENOENT(e) && getErrnoPath(e) === sourceDir) { |
| throw new Error( |
| `Plugin source directory not found: ${sourceDir} (from entry.source: ${entry.source})`, |
| ) |
| } |
| throw e |
| } |
| } else { |
| |
| logForDebugging( |
| `Copying plugin ${pluginId} to versioned cache (fallback to full copy)`, |
| ) |
| await copyDir(sourcePath, cachePath) |
| } |
|
|
| |
| const gitPath = join(cachePath, '.git') |
| await rm(gitPath, { recursive: true, force: true }) |
|
|
| |
| 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`, |
| ) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
| 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}`) |
| } |
| } |
|
|
| |
| |
| |
| 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}`, |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function gitClone( |
| gitUrl: string, |
| targetPath: string, |
| ref?: string, |
| sha?: string, |
| ): Promise<void> { |
| |
| |
| const args = [ |
| 'clone', |
| '--depth', |
| '1', |
| '--recurse-submodules', |
| '--shallow-submodules', |
| ] |
|
|
| |
| if (ref) { |
| args.push('--branch', ref) |
| } |
|
|
| |
| 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) { |
| |
| const shallowFetchResult = await execFileNoThrowWithCwd( |
| gitExe(), |
| ['fetch', '--depth', '1', 'origin', sha], |
| { cwd: targetPath }, |
| ) |
|
|
| if (shallowFetchResult.code !== 0) { |
| |
| |
| 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}`, |
| ) |
| } |
| } |
|
|
| |
| 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}`, |
| ) |
| } |
| } |
|
|
| |
| |
| logPluginFetch( |
| 'plugin_clone', |
| gitUrl, |
| 'success', |
| performance.now() - cloneStarted, |
| ) |
| } |
|
|
| |
| |
| |
| 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}`, |
| ) |
| } |
|
|
| |
| |
| |
| 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`, |
| ) |
| } |
| |
| const gitUrl = isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) |
| ? `https://github.com/${repo}.git` |
| : `git@github.com:${repo}.git` |
| return installFromGit(gitUrl, targetPath, ref, sha) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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) |
| |
| 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}`, |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| 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 { |
| |
| |
| |
| |
| |
| 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() |
| } |
| } |
|
|
| |
| |
| |
| 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 }) |
| } |
| } |
|
|
| |
| |
| |
| 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 }) |
| } |
|
|
| |
| |
| |
| 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}` |
| } |
|
|
| |
| |
| |
| 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 { |
| |
| 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) { |
| |
| if ( |
| error instanceof Error && |
| error.message.includes('invalid manifest file') |
| ) { |
| throw 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 { |
| |
| 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) { |
| |
| if ( |
| error instanceof Error && |
| error.message.includes('invalid manifest file') |
| ) { |
| throw 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 }), |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function loadPluginManifest( |
| manifestPath: string, |
| pluginName: string, |
| source: string, |
| ): Promise<PluginManifest> { |
| |
| |
| if (!(await pathExists(manifestPath))) { |
| |
| return { |
| name: pluginName, |
| description: `Plugin from ${source}`, |
| } |
| } |
|
|
| try { |
| |
| const content = await readFile(manifestPath, { encoding: 'utf-8' }) |
| const parsedJson = jsonParse(content) |
|
|
| |
| const result = PluginManifestSchema().safeParse(parsedJson) |
|
|
| if (result.success) { |
| |
| return result.data |
| } |
|
|
| |
| 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) { |
| |
| if ( |
| error instanceof Error && |
| error.message.includes('invalid manifest file') |
| ) { |
| throw 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}`, |
| ) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| const validatedPluginHooks = PluginHooksSchema().parse(rawHooksConfig) |
|
|
| return validatedPluginHooks.hooks as HooksSettings |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function validatePluginPaths( |
| relPaths: string[], |
| pluginPath: string, |
| pluginName: string, |
| source: string, |
| component: PluginComponent, |
| componentLabel: string, |
| contextLabel: string, |
| errors: PluginError[], |
| ): Promise<string[]> { |
| |
| const checks = await Promise.all( |
| relPaths.map(async relPath => { |
| const fullPath = join(pluginPath, relPath) |
| return { relPath, fullPath, exists: await pathExists(fullPath) } |
| }), |
| ) |
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function createPluginFromPath( |
| pluginPath: string, |
| source: string, |
| enabled: boolean, |
| fallbackName: string, |
| strict = true, |
| ): Promise<{ plugin: LoadedPlugin; errors: PluginError[] }> { |
| const errors: PluginError[] = [] |
|
|
| |
| |
| const manifestPath = join(pluginPath, '.claude-plugin', 'plugin.json') |
| const manifest = await loadPluginManifest(manifestPath, fallbackName, source) |
|
|
| |
| |
| const plugin: LoadedPlugin = { |
| name: manifest.name, |
| manifest, |
| path: pluginPath, |
| source, |
| repository: source, |
| enabled, |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| if (manifest.commands) { |
| |
| 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) |
| ) { |
| |
| const commandsMetadata: Record<string, CommandMetadata> = {} |
| const validPaths: string[] = [] |
|
|
| |
| |
| 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') { |
| |
| commandsMetadata[check.commandName] = check.metadata |
| continue |
| } |
| |
| 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', |
| }) |
| } |
| } |
|
|
| |
| if (validPaths.length > 0) { |
| plugin.commandsPaths = validPaths |
| } |
| |
| if (Object.keys(commandsMetadata).length > 0) { |
| plugin.commandsMetadata = commandsMetadata |
| } |
| } else { |
| |
| const commandPaths = Array.isArray(manifest.commands) |
| ? manifest.commands |
| : [manifest.commands] |
|
|
| |
| 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 |
| } |
| } |
| } |
|
|
| |
| const agentsPath = join(pluginPath, 'agents') |
| if (agentsDirExists) { |
| plugin.agentsPath = agentsPath |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| const skillsPath = join(pluginPath, 'skills') |
| if (skillsDirExists) { |
| plugin.skillsPath = skillsPath |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| const outputStylesPath = join(pluginPath, 'output-styles') |
| if (outputStylesDirExists) { |
| plugin.outputStylesPath = outputStylesPath |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| let mergedHooks: HooksSettings | undefined |
| const loadedHookPaths = new Set<string>() |
|
|
| |
| const standardHooksPath = join(pluginPath, 'hooks', 'hooks.json') |
| if (await pathExists(standardHooksPath)) { |
| try { |
| mergedHooks = await loadPluginHooks(standardHooksPath, manifest.name) |
| |
| try { |
| loadedHookPaths.add(await realpath(standardHooksPath)) |
| } catch { |
| |
| 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, |
| }) |
| } |
| } |
|
|
| |
| if (manifest.hooks) { |
| const manifestHooksArray = Array.isArray(manifest.hooks) |
| ? manifest.hooks |
| : [manifest.hooks] |
|
|
| for (const hookSpec of manifestHooksArray) { |
| if (typeof hookSpec === 'string') { |
| |
| 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 |
| } |
|
|
| |
| let normalizedPath: string |
| try { |
| normalizedPath = await realpath(hookFilePath) |
| } catch { |
| |
| 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') { |
| |
| mergedHooks = mergeHooksSettings(mergedHooks, hookSpec as HooksSettings) |
| } |
| } |
| } |
|
|
| if (mergedHooks) { |
| plugin.hooksConfig = mergedHooks |
| } |
|
|
| |
| |
| |
| const pluginSettings = await loadPluginSettings(pluginPath, manifest) |
| if (pluginSettings) { |
| plugin.settings = pluginSettings |
| } |
|
|
| return { plugin, errors } |
| } |
|
|
| |
| |
| |
| |
| const PluginSettingsSchema = lazySchema(() => |
| SettingsSchema() |
| .pick({ |
| agent: true, |
| }) |
| .strip(), |
| ) |
|
|
| |
| |
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| async function loadPluginSettings( |
| pluginPath: string, |
| manifest: PluginManifest, |
| ): Promise<Record<string, unknown> | undefined> { |
| |
| 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) { |
| |
| if (!isFsInaccessible(e)) { |
| logForDebugging( |
| `Failed to parse settings.json for plugin ${manifest.name}: ${e}`, |
| { level: 'warn' }, |
| ) |
| } |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
| 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 { |
| |
| merged[event as keyof HooksSettings] = [ |
| ...(merged[event as keyof HooksSettings] || []), |
| ...matchers, |
| ] |
| } |
| } |
|
|
| return merged |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function loadPluginsFromMarketplaces({ |
| cacheOnly, |
| }: { |
| cacheOnly: boolean |
| }): Promise<{ |
| plugins: LoadedPlugin[] |
| errors: PluginError[] |
| }> { |
| const settings = getSettings_DEPRECATED() |
| |
| const enabledPlugins = { |
| ...getAddDirEnabledPlugins(), |
| ...(settings.enabledPlugins || {}), |
| } |
| const plugins: LoadedPlugin[] = [] |
| const errors: PluginError[] = [] |
|
|
| |
| const marketplacePluginEntries = Object.entries(enabledPlugins).filter( |
| ([key, value]) => { |
| |
| const isValidFormat = PluginIdSchema().safeParse(key).success |
| if (!isValidFormat || value === undefined) return false |
| |
| const { marketplace } = parsePluginIdentifier(key) |
| return marketplace !== BUILTIN_MARKETPLACE_NAME |
| }, |
| ) |
|
|
| |
| |
| |
| const knownMarketplaces = await loadKnownMarketplacesConfigSafe() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const strictAllowlist = getStrictKnownMarketplaces() |
| const blocklist = getBlockedMarketplaces() |
| const hasEnterprisePolicy = |
| strictAllowlist !== null || (blocklist !== null && blocklist.length > 0) |
|
|
| |
| |
| |
| |
| 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)) |
| }), |
| ) |
|
|
| |
| |
| const installedPluginsData = getInMemoryInstalledPlugins() |
|
|
| |
| const results = await Promise.allSettled( |
| marketplacePluginEntries.map(async ([pluginId, enabledValue]) => { |
| const { name: pluginName, marketplace: marketplaceName } = |
| parsePluginIdentifier(pluginId) |
|
|
| |
| const marketplaceConfig = knownMarketplaces[marketplaceName!] |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (!marketplaceConfig && hasEnterprisePolicy) { |
| |
| |
| |
| |
| |
| |
| 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) |
| ) { |
| |
| 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 |
| } |
|
|
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
| 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 } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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') { |
| |
| |
| 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) |
| |
| |
| } else { |
| |
| if (!installPath || !(await pathExists(installPath))) { |
| errorsOut.push({ |
| type: 'plugin-cache-miss', |
| source: pluginId, |
| plugin: entry.name, |
| installPath: installPath ?? '(not recorded)', |
| }) |
| return null |
| } |
| pluginPath = installPath |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| return finishLoadingPluginFromPath( |
| entry, |
| pluginId, |
| enabled, |
| errorsOut, |
| pluginPath, |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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') { |
| |
| 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 |
| } |
|
|
| |
| try { |
| |
| const manifestPath = join( |
| sourcePluginPath, |
| '.claude-plugin', |
| 'plugin.json', |
| ) |
| let pluginManifest: PluginManifest | undefined |
| try { |
| pluginManifest = await loadPluginManifest( |
| manifestPath, |
| entry.name, |
| entry.source, |
| ) |
| } catch { |
| |
| } |
|
|
| |
| |
| const version = await calculatePluginVersion( |
| pluginId, |
| entry.source, |
| pluginManifest, |
| marketplaceDir, |
| entry.version, |
| ) |
|
|
| |
| pluginPath = await copyPluginToVersionedCache( |
| sourcePluginPath, |
| pluginId, |
| version, |
| entry, |
| marketplaceDir, |
| ) |
|
|
| logForDebugging( |
| `Resolved local plugin ${entry.name} to versioned cache: ${pluginPath}`, |
| ) |
| } catch (error) { |
| |
| const errorMsg = errorMessage(error) |
| logForDebugging( |
| `Failed to copy plugin ${entry.name} to versioned cache: ${errorMsg}. Using marketplace path.`, |
| { level: 'warn' }, |
| ) |
| pluginPath = sourcePluginPath |
| } |
| } else { |
| |
| try { |
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| 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 { |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| const cached = await cachePlugin(entry.source, { |
| manifest: { name: entry.name }, |
| }) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const actualVersion = |
| version !== 'unknown' |
| ? version |
| : await calculatePluginVersion( |
| pluginId, |
| entry.source, |
| cached.manifest, |
| cached.path, |
| installedVersion ?? entry.version, |
| cached.gitCommitSha, |
| ) |
|
|
| |
| |
| pluginPath = await copyPluginToVersionedCache( |
| cached.path, |
| pluginId, |
| actualVersion, |
| entry, |
| undefined, |
| ) |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| 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) { |
| |
| 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, |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async function finishLoadingPluginFromPath( |
| entry: PluginMarketplaceEntry, |
| pluginId: string, |
| enabled: boolean, |
| errorsOut: PluginError[], |
| pluginPath: string, |
| ): Promise<LoadedPlugin | null> { |
| const errors: PluginError[] = [] |
|
|
| |
| 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, |
| ) |
| errors.push(...pluginErrors) |
|
|
| |
| if ( |
| typeof entry.source === 'object' && |
| 'sha' in entry.source && |
| entry.source.sha |
| ) { |
| plugin.sha = entry.source.sha |
| } |
|
|
| |
| if (!hasManifest) { |
| plugin.manifest = { |
| ...entry, |
| id: undefined, |
| source: undefined, |
| strict: undefined, |
| } as PluginManifest |
| plugin.name = plugin.manifest.name |
|
|
| |
| if (entry.commands) { |
| |
| 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) |
| ) { |
| |
| const commandsMetadata: Record<string, CommandMetadata> = {} |
| const validPaths: string[] = [] |
|
|
| |
| 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 { |
| |
| const commandPaths = Array.isArray(entry.commands) |
| ? entry.commands |
| : [entry.commands] |
|
|
| |
| 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 |
| } |
| } |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| 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] |
|
|
| |
| |
| |
| 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`) |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| if (entry.hooks) { |
| plugin.hooksConfig = entry.hooks as HooksSettings |
| } |
| } else if ( |
| !entry.strict && |
| hasManifest && |
| (entry.commands || |
| entry.agents || |
| entry.skills || |
| entry.hooks || |
| entry.outputStyles) |
| ) { |
| |
| 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) { |
| |
|
|
| |
| if (entry.commands) { |
| |
| 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) |
| ) { |
| |
| const commandsMetadata: Record<string, CommandMetadata> = { |
| ...(plugin.commandsMetadata || {}), |
| } |
| const validPaths: string[] = [] |
|
|
| |
| 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 { |
| |
| const commandPaths = Array.isArray(entry.commands) |
| ? entry.commands |
| : [entry.commands] |
|
|
| |
| 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, |
| ] |
| } |
| } |
| } |
|
|
| |
| 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] |
| } |
| } |
|
|
| |
| 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] |
| } |
| } |
|
|
| |
| 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, |
| ] |
| } |
| } |
|
|
| |
| if (entry.hooks) { |
| plugin.hooksConfig = { |
| ...(plugin.hooksConfig || {}), |
| ...(entry.hooks as HooksSettings), |
| } |
| } |
| } |
|
|
| errorsOut.push(...errors) |
| return plugin |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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`, |
| true, |
| dirName, |
| ) |
|
|
| |
| 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 } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 |
| }) |
| |
| |
| |
| return { |
| plugins: [...sessionPlugins, ...marketplacePlugins, ...sources.builtin], |
| errors, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const loadAllPlugins = memoize(async (): Promise<PluginLoadResult> => { |
| const result = await assemblePluginLoadResult(() => |
| loadPluginsFromMarketplaces({ cacheOnly: false }), |
| ) |
| |
| |
| |
| |
| |
| |
| loadAllPluginsCacheOnly.cache?.set(undefined, Promise.resolve(result)) |
| return result |
| }) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const loadAllPluginsCacheOnly = memoize( |
| async (): Promise<PluginLoadResult> => { |
| if (isEnvTruthy(process.env.CLAUDE_CODE_SYNC_PLUGIN_INSTALL)) { |
| return loadAllPlugins() |
| } |
| return assemblePluginLoadResult(() => |
| loadPluginsFromMarketplaces({ cacheOnly: true }), |
| ) |
| }, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function assemblePluginLoadResult( |
| marketplaceLoader: () => Promise<{ |
| plugins: LoadedPlugin[] |
| errors: PluginError[] |
| }>, |
| ): Promise<PluginLoadResult> { |
| |
| |
| |
| const inlinePlugins = getInlinePlugins() |
| const [marketplaceResult, sessionResult] = await Promise.all([ |
| marketplaceLoader(), |
| inlinePlugins.length > 0 |
| ? loadSessionOnlyPlugins(inlinePlugins) |
| : Promise.resolve({ plugins: [], errors: [] }), |
| ]) |
| |
| const builtinResult = getBuiltinPlugins() |
|
|
| |
| |
| |
| 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, |
| ] |
|
|
| |
| |
| |
| 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)`, |
| ) |
|
|
| |
| cachePluginSettings(enabledPlugins) |
|
|
| return { |
| enabled: enabledPlugins, |
| disabled: allPlugins.filter(p => !p.enabled), |
| errors: allErrors, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function clearPluginCache(reason?: string): void { |
| if (reason) { |
| logForDebugging( |
| `clearPluginCache: invalidating loadAllPlugins cache (${reason})`, |
| ) |
| } |
| loadAllPlugins.cache?.clear?.() |
| loadAllPluginsCacheOnly.cache?.clear?.() |
| |
| |
| |
| |
| |
| if (getPluginSettingsBase() !== undefined) { |
| resetSettingsCache() |
| } |
| clearPluginSettingsBase() |
| |
| } |
|
|
| |
| |
| |
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| export function cachePluginSettings(plugins: LoadedPlugin[]): void { |
| const settings = mergePluginSettings(plugins) |
| setPluginSettingsBase(settings) |
| |
| |
| |
| |
| |
| if (settings && Object.keys(settings).length > 0) { |
| resetSettingsCache() |
| logForDebugging( |
| `Cached plugin settings with keys: ${Object.keys(settings).join(', ')}`, |
| ) |
| } |
| } |
|
|
| |
| |
| |
| function isRecord(value: unknown): value is Record<string, unknown> { |
| return typeof value === 'object' && value !== null && !Array.isArray(value) |
| } |
|
|