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