Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
12 kB · 305 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306/** * Plugin dependency resolution — pure functions, no I/O. * * Semantics are `apt`-style: a dependency is a *presence guarantee*, not a * module graph. Plugin A depending on Plugin B means "B's namespaced * components (MCP servers, commands, agents) must be available when A runs." * * Two entry points: * - `resolveDependencyClosure` — install-time DFS walk, cycle detection * - `verifyAndDemote` — load-time fixed-point check, demotes plugins with * unsatisfied deps (session-local, does NOT write settings) */
import type { LoadedPlugin, PluginError } from '../../types/plugin.js'import type { EditableSettingSource } from '../settings/constants.js'import { getSettingsForSource } from '../settings/settings.js'import { parsePluginIdentifier } from './pluginIdentifier.js'import type { PluginId } from './schemas.js'
/** * Synthetic marketplace sentinel for `--plugin-dir` plugins (pluginLoader.ts * sets `source = "{name}@inline"`). Not a real marketplace — bare deps from * these plugins cannot meaningfully inherit it. */const INLINE_MARKETPLACE = 'inline'
/** * Normalize a dependency reference to fully-qualified "name@marketplace" form. * Bare names (no @) inherit the marketplace of the plugin declaring them — * cross-marketplace deps are blocked anyway, so the @-suffix is boilerplate * in the common case. * * EXCEPTION: if the declaring plugin is @inline (loaded via --plugin-dir), * bare deps are returned unchanged. `inline` is a synthetic sentinel, not a * real marketplace — fabricating "dep@inline" would never match anything. * verifyAndDemote handles bare deps via name-only matching. */export function qualifyDependency( dep: string, declaringPluginId: string,): string { if (parsePluginIdentifier(dep).marketplace) return dep const mkt = parsePluginIdentifier(declaringPluginId).marketplace if (!mkt || mkt === INLINE_MARKETPLACE) return dep return `${dep}@${mkt}`}
/** * Minimal shape the resolver needs from a marketplace lookup. Keeping this * narrow means the resolver stays testable without constructing full * PluginMarketplaceEntry objects. */export type DependencyLookupResult = { // Entries may be bare names; qualifyDependency normalizes them. dependencies?: string[]}
export type ResolutionResult = | { ok: true; closure: PluginId[] } | { ok: false; reason: 'cycle'; chain: PluginId[] } | { ok: false; reason: 'not-found'; missing: PluginId; requiredBy: PluginId } | { ok: false reason: 'cross-marketplace' dependency: PluginId requiredBy: PluginId }
/** * Walk the transitive dependency closure of `rootId` via DFS. * * The returned `closure` ALWAYS contains `rootId`, plus every transitive * dependency that is NOT in `alreadyEnabled`. Already-enabled deps are * skipped (not recursed into) — this avoids surprise settings writes when a * dep is already installed at a different scope. The root is never skipped, * even if already enabled, so re-installing a plugin always re-caches it. * * Cross-marketplace dependencies are BLOCKED by default: a plugin in * marketplace A cannot auto-install a plugin from marketplace B. This is * a security boundary — installing from a trusted marketplace shouldn't * silently pull from an untrusted one. Two escapes: (1) install the * cross-mkt dep yourself first (already-enabled deps are skipped, so the * closure won't touch it), or (2) the ROOT marketplace's * `allowCrossMarketplaceDependenciesOn` allowlist — only the root's list * applies for the whole walk (no transitive trust: if A allows B, B's * plugin depending on C is still blocked unless A also allows C). * * @param rootId Root plugin to resolve from (format: "name@marketplace") * @param lookup Async lookup returning `{dependencies}` or `null` if not found * @param alreadyEnabled Plugin IDs to skip (deps only, root is never skipped) * @param allowedCrossMarketplaces Marketplace names the root trusts for * auto-install (from the root marketplace's manifest) * @returns Closure to install, or a cycle/not-found/cross-marketplace error */export async function resolveDependencyClosure( rootId: PluginId, lookup: (id: PluginId) => Promise<DependencyLookupResult | null>, alreadyEnabled: ReadonlySet<PluginId>, allowedCrossMarketplaces: ReadonlySet<string> = new Set(),): Promise<ResolutionResult> { const rootMarketplace = parsePluginIdentifier(rootId).marketplace const closure: PluginId[] = [] const visited = new Set<PluginId>() const stack: PluginId[] = []
async function walk( id: PluginId, requiredBy: PluginId, ): Promise<ResolutionResult | null> { // Skip already-enabled DEPENDENCIES (avoids surprise settings writes), // but NEVER skip the root: installing an already-enabled plugin must // still cache/register it. Without this guard, re-installing a plugin // that's in settings but missing from disk (e.g., cache cleared, // installed_plugins.json stale) would return an empty closure and // `cacheAndRegisterPlugin` would never fire — user sees // "✔ Successfully installed" but nothing materializes. if (id !== rootId && alreadyEnabled.has(id)) return null // Security: block auto-install across marketplace boundaries. Runs AFTER // the alreadyEnabled check — if the user manually installed a cross-mkt // dep, it's in alreadyEnabled and we never reach this. const idMarketplace = parsePluginIdentifier(id).marketplace if ( idMarketplace !== rootMarketplace && !(idMarketplace && allowedCrossMarketplaces.has(idMarketplace)) ) { return { ok: false, reason: 'cross-marketplace', dependency: id, requiredBy, } } if (stack.includes(id)) { return { ok: false, reason: 'cycle', chain: [...stack, id] } } if (visited.has(id)) return null visited.add(id)
const entry = await lookup(id) if (!entry) { return { ok: false, reason: 'not-found', missing: id, requiredBy } }
stack.push(id) for (const rawDep of entry.dependencies ?? []) { const dep = qualifyDependency(rawDep, id) const err = await walk(dep, id) if (err) return err } stack.pop()
closure.push(id) return null }
const err = await walk(rootId, rootId) if (err) return err return { ok: true, closure }}
/** * Load-time safety net: for each enabled plugin, verify all manifest * dependencies are also in the enabled set. Demote any that fail. * * Fixed-point loop: demoting plugin A may break plugin B that depends on A, * so we iterate until nothing changes. * * The `reason` field distinguishes: * - `'not-enabled'` — dep exists in the loaded set but is disabled * - `'not-found'` — dep is entirely absent (not in any marketplace) * * Does NOT mutate input. Returns the set of plugin IDs (sources) to demote. * * @param plugins All loaded plugins (enabled + disabled) * @returns Set of pluginIds to demote, plus errors for `/doctor` */export function verifyAndDemote(plugins: readonly LoadedPlugin[]): { demoted: Set<string> errors: PluginError[]} { const known = new Set(plugins.map(p => p.source)) const enabled = new Set(plugins.filter(p => p.enabled).map(p => p.source)) // Name-only indexes for bare deps from --plugin-dir (@inline) plugins: // the real marketplace is unknown, so match "B" against any enabled "B@*". // enabledByName is a multiset: if B@epic AND B@other are both enabled, // demoting one mustn't make "B" disappear from the index. const knownByName = new Set( plugins.map(p => parsePluginIdentifier(p.source).name), ) const enabledByName = new Map<string, number>() for (const id of enabled) { const n = parsePluginIdentifier(id).name enabledByName.set(n, (enabledByName.get(n) ?? 0) + 1) } const errors: PluginError[] = []
let changed = true while (changed) { changed = false for (const p of plugins) { if (!enabled.has(p.source)) continue for (const rawDep of p.manifest.dependencies ?? []) { const dep = qualifyDependency(rawDep, p.source) // Bare dep ← @inline plugin: match by name only (see enabledByName) const isBare = !parsePluginIdentifier(dep).marketplace const satisfied = isBare ? (enabledByName.get(dep) ?? 0) > 0 : enabled.has(dep) if (!satisfied) { enabled.delete(p.source) const count = enabledByName.get(p.name) ?? 0 if (count <= 1) enabledByName.delete(p.name) else enabledByName.set(p.name, count - 1) errors.push({ type: 'dependency-unsatisfied', source: p.source, plugin: p.name, dependency: dep, reason: (isBare ? knownByName.has(dep) : known.has(dep)) ? 'not-enabled' : 'not-found', }) changed = true break } } } }
const demoted = new Set( plugins.filter(p => p.enabled && !enabled.has(p.source)).map(p => p.source), ) return { demoted, errors }}
/** * Find all enabled plugins that declare `pluginId` as a dependency. * Used to warn on uninstall/disable ("required by: X, Y"). * * @param pluginId The plugin being removed/disabled * @param plugins All loaded plugins (only enabled ones are checked) * @returns Names of plugins that will break if `pluginId` goes away */export function findReverseDependents( pluginId: PluginId, plugins: readonly LoadedPlugin[],): string[] { const { name: targetName } = parsePluginIdentifier(pluginId) return plugins .filter( p => p.enabled && p.source !== pluginId && (p.manifest.dependencies ?? []).some(d => { const qualified = qualifyDependency(d, p.source) // Bare dep (from @inline plugin): match by name only return parsePluginIdentifier(qualified).marketplace ? qualified === pluginId : qualified === targetName }), ) .map(p => p.name)}
/** * Build the set of plugin IDs currently enabled at a given settings scope. * Used by install-time resolution to skip already-enabled deps and avoid * surprise settings writes. * * Matches `true` (plain enable) AND array values (version constraints per * settings/types.ts:455-463 — a plugin at `"foo@bar": ["^1.0.0"]` IS enabled). * Without the array check, a version-pinned dep would be re-added to the * closure and the settings write would clobber the constraint with `true`. */export function getEnabledPluginIdsForScope( settingSource: EditableSettingSource,): Set<PluginId> { return new Set( Object.entries(getSettingsForSource(settingSource)?.enabledPlugins ?? {}) .filter(([, v]) => v === true || Array.isArray(v)) .map(([k]) => k), )}
/** * Format the "(+ N dependencies)" suffix for install success messages. * Returns empty string when `installedDeps` is empty. */export function formatDependencyCountSuffix(installedDeps: string[]): string { if (installedDeps.length === 0) return '' const n = installedDeps.length return ` (+ ${n} ${n === 1 ? 'dependency' : 'dependencies'})`}
/** * Format the "warning: required by X, Y" suffix for uninstall/disable * results. Em-dash style for CLI result messages (not the middot style * used in the notification UI). Returns empty string when no dependents. */export function formatReverseDependentsSuffix( rdeps: string[] | undefined,): string { if (!rdeps || rdeps.length === 0) return '' return ` — warning: required by ${rdeps.join(', ')}`}