source dump of claude code
at main 174 lines 6.8 kB view raw
1/** 2 * Plugin installation for headless/CCR mode. 3 * 4 * This module provides plugin installation without AppState updates, 5 * suitable for non-interactive environments like CCR. 6 * 7 * When CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE is enabled, plugins are stored as 8 * ZIPs on a mounted volume. The storage layer (pluginLoader.ts) handles 9 * ZIP creation on install and extraction on load transparently. 10 */ 11 12import { logEvent } from '../../services/analytics/index.js' 13import { registerCleanup } from '../cleanupRegistry.js' 14import { logForDebugging } from '../debug.js' 15import { withDiagnosticsTiming } from '../diagLogs.js' 16import { getFsImplementation } from '../fsOperations.js' 17import { logError } from '../log.js' 18import { 19 clearMarketplacesCache, 20 getDeclaredMarketplaces, 21 registerSeedMarketplaces, 22} from './marketplaceManager.js' 23import { detectAndUninstallDelistedPlugins } from './pluginBlocklist.js' 24import { clearPluginCache } from './pluginLoader.js' 25import { reconcileMarketplaces } from './reconciler.js' 26import { 27 cleanupSessionPluginCache, 28 getZipCacheMarketplacesDir, 29 getZipCachePluginsDir, 30 isMarketplaceSourceSupportedByZipCache, 31 isPluginZipCacheEnabled, 32} from './zipCache.js' 33import { syncMarketplacesToZipCache } from './zipCacheAdapters.js' 34 35/** 36 * Install plugins for headless/CCR mode. 37 * 38 * This is the headless equivalent of performBackgroundPluginInstallations(), 39 * but without AppState updates (no UI to update in headless mode). 40 * 41 * @returns true if any plugins were installed (caller should refresh MCP) 42 */ 43export async function installPluginsForHeadless(): Promise<boolean> { 44 const zipCacheMode = isPluginZipCacheEnabled() 45 logForDebugging( 46 `installPluginsForHeadless: starting${zipCacheMode ? ' (zip cache mode)' : ''}`, 47 ) 48 49 // Register seed marketplaces (CLAUDE_CODE_PLUGIN_SEED_DIR) before diffing. 50 // Idempotent; no-op if seed not configured. Without this, findMissingMarketplaces 51 // would see seed entries as missing → clone → defeats seed's purpose. 52 // 53 // If registration changed state, clear caches so the early plugin-load pass 54 // (which runs during CLI startup before this function) doesn't keep stale 55 // "marketplace not found" results. Without this clear, a first-boot headless 56 // run with a seed-cached plugin would show 0 plugin commands/agents/skills 57 // in the init message even though the seed has everything. 58 const seedChanged = await registerSeedMarketplaces() 59 if (seedChanged) { 60 clearMarketplacesCache() 61 clearPluginCache('headlessPluginInstall: seed marketplaces registered') 62 } 63 64 // Ensure zip cache directory structure exists 65 if (zipCacheMode) { 66 await getFsImplementation().mkdir(getZipCacheMarketplacesDir()) 67 await getFsImplementation().mkdir(getZipCachePluginsDir()) 68 } 69 70 // Declared now includes an implicit claude-plugins-official entry when any 71 // enabled plugin references it (see getDeclaredMarketplaces). This routes 72 // the official marketplace through the same reconciler path as any other — 73 // which composes correctly with CLAUDE_CODE_PLUGIN_SEED_DIR: seed registers 74 // it in known_marketplaces.json, reconciler diff sees it as upToDate, no clone. 75 const declaredCount = Object.keys(getDeclaredMarketplaces()).length 76 77 const metrics = { 78 marketplaces_installed: 0, 79 delisted_count: 0, 80 } 81 82 // Initialize from seedChanged so the caller (print.ts) calls 83 // refreshPluginState() → clearCommandsCache/clearAgentDefinitionsCache 84 // when seed registration added marketplaces. Without this, the caller 85 // only refreshes when an actual plugin install happened. 86 let pluginsChanged = seedChanged 87 88 try { 89 if (declaredCount === 0) { 90 logForDebugging('installPluginsForHeadless: no marketplaces declared') 91 } else { 92 // Reconcile declared marketplaces (settings intent + implicit official) 93 // with materialized state. Zip cache: skip unsupported source types. 94 const reconcileResult = await withDiagnosticsTiming( 95 'headless_marketplace_reconcile', 96 () => 97 reconcileMarketplaces({ 98 skip: zipCacheMode 99 ? (_name, source) => 100 !isMarketplaceSourceSupportedByZipCache(source) 101 : undefined, 102 onProgress: event => { 103 if (event.type === 'installed') { 104 logForDebugging( 105 `installPluginsForHeadless: installed marketplace ${event.name}`, 106 ) 107 } else if (event.type === 'failed') { 108 logForDebugging( 109 `installPluginsForHeadless: failed to install marketplace ${event.name}: ${event.error}`, 110 ) 111 } 112 }, 113 }), 114 r => ({ 115 installed_count: r.installed.length, 116 updated_count: r.updated.length, 117 failed_count: r.failed.length, 118 skipped_count: r.skipped.length, 119 }), 120 ) 121 122 if (reconcileResult.skipped.length > 0) { 123 logForDebugging( 124 `installPluginsForHeadless: skipped ${reconcileResult.skipped.length} marketplace(s) unsupported by zip cache: ${reconcileResult.skipped.join(', ')}`, 125 ) 126 } 127 128 const marketplacesChanged = 129 reconcileResult.installed.length + reconcileResult.updated.length 130 131 // Clear caches so newly-installed marketplace plugins are discoverable. 132 // Plugin caching is the loader's job — after caches clear, the caller's 133 // refreshPluginState() → loadAllPlugins() will cache any missing plugins 134 // from the newly-materialized marketplaces. 135 if (marketplacesChanged > 0) { 136 clearMarketplacesCache() 137 clearPluginCache('headlessPluginInstall: marketplaces reconciled') 138 pluginsChanged = true 139 } 140 141 metrics.marketplaces_installed = marketplacesChanged 142 } 143 144 // Zip cache: save marketplace JSONs for offline access on ephemeral containers. 145 // Runs unconditionally so that steady-state containers (all plugins installed) 146 // still sync marketplace data that may have been cloned in a previous run. 147 if (zipCacheMode) { 148 await syncMarketplacesToZipCache() 149 } 150 151 // Delisting enforcement 152 const newlyDelisted = await detectAndUninstallDelistedPlugins() 153 metrics.delisted_count = newlyDelisted.length 154 if (newlyDelisted.length > 0) { 155 pluginsChanged = true 156 } 157 158 if (pluginsChanged) { 159 clearPluginCache('headlessPluginInstall: plugins changed') 160 } 161 162 // Zip cache: register session cleanup for extracted plugin temp dirs 163 if (zipCacheMode) { 164 registerCleanup(cleanupSessionPluginCache) 165 } 166 167 return pluginsChanged 168 } catch (error) { 169 logError(error) 170 return false 171 } finally { 172 logEvent('tengu_headless_plugin_install', metrics) 173 } 174}