/ utils / plugins / headlessPluginInstall.ts
headlessPluginInstall.ts
  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  
 12  import { logEvent } from '../../services/analytics/index.js'
 13  import { registerCleanup } from '../cleanupRegistry.js'
 14  import { logForDebugging } from '../debug.js'
 15  import { withDiagnosticsTiming } from '../diagLogs.js'
 16  import { getFsImplementation } from '../fsOperations.js'
 17  import { logError } from '../log.js'
 18  import {
 19    clearMarketplacesCache,
 20    getDeclaredMarketplaces,
 21    registerSeedMarketplaces,
 22  } from './marketplaceManager.js'
 23  import { detectAndUninstallDelistedPlugins } from './pluginBlocklist.js'
 24  import { clearPluginCache } from './pluginLoader.js'
 25  import { reconcileMarketplaces } from './reconciler.js'
 26  import {
 27    cleanupSessionPluginCache,
 28    getZipCacheMarketplacesDir,
 29    getZipCachePluginsDir,
 30    isMarketplaceSourceSupportedByZipCache,
 31    isPluginZipCacheEnabled,
 32  } from './zipCache.js'
 33  import { 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   */
 43  export 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  }