source dump of claude code
at main 341 lines 11 kB view raw
1import { join } from 'path' 2import { getCwd } from '../cwd.js' 3import { logForDebugging } from '../debug.js' 4import { logError } from '../log.js' 5import type { SettingSource } from '../settings/constants.js' 6import { 7 getInitialSettings, 8 getSettingsForSource, 9 updateSettingsForSource, 10} from '../settings/settings.js' 11import { getAddDirEnabledPlugins } from './addDirPluginSettings.js' 12import { 13 getInMemoryInstalledPlugins, 14 migrateFromEnabledPlugins, 15} from './installedPluginsManager.js' 16import { getPluginById } from './marketplaceManager.js' 17import { 18 type ExtendedPluginScope, 19 type PersistablePluginScope, 20 SETTING_SOURCE_TO_SCOPE, 21 scopeToSettingSource, 22} from './pluginIdentifier.js' 23import { 24 cacheAndRegisterPlugin, 25 registerPluginInstallation, 26} from './pluginInstallationHelpers.js' 27import { isLocalPluginSource, type PluginScope } from './schemas.js' 28 29/** 30 * Checks for enabled plugins across all settings sources, including --add-dir. 31 * 32 * Uses getInitialSettings() which merges all sources with policy as 33 * highest priority, then layers --add-dir plugins underneath. This is the 34 * authoritative "is this plugin enabled?" check — don't delegate to 35 * getPluginEditableScopes() which serves a different purpose (scope tracking). 36 * 37 * @returns Array of plugin IDs (plugin@marketplace format) that are enabled 38 */ 39export async function checkEnabledPlugins(): Promise<string[]> { 40 const settings = getInitialSettings() 41 const enabledPlugins: string[] = [] 42 43 // Start with --add-dir plugins (lowest priority) 44 const addDirPlugins = getAddDirEnabledPlugins() 45 for (const [pluginId, value] of Object.entries(addDirPlugins)) { 46 if (pluginId.includes('@') && value) { 47 enabledPlugins.push(pluginId) 48 } 49 } 50 51 // Merged settings (policy > local > project > user) override --add-dir 52 if (settings.enabledPlugins) { 53 for (const [pluginId, value] of Object.entries(settings.enabledPlugins)) { 54 if (!pluginId.includes('@')) { 55 continue 56 } 57 const idx = enabledPlugins.indexOf(pluginId) 58 if (value) { 59 if (idx === -1) { 60 enabledPlugins.push(pluginId) 61 } 62 } else { 63 // Explicitly disabled — remove even if --add-dir enabled it 64 if (idx !== -1) { 65 enabledPlugins.splice(idx, 1) 66 } 67 } 68 } 69 } 70 71 return enabledPlugins 72} 73 74/** 75 * Gets the user-editable scope that "owns" each enabled plugin. 76 * 77 * Used for scope tracking: determining where to write back when a user 78 * enables/disables a plugin. Managed (policy) settings are processed first 79 * (lowest priority) because the user cannot edit them — the scope should 80 * resolve to the highest user-controllable source. 81 * 82 * NOTE: This is NOT the authoritative "is this plugin enabled?" check. 83 * Use checkEnabledPlugins() for that — it uses merged settings where 84 * policy has highest priority and can block user-enabled plugins. 85 * 86 * Precedence (lowest to highest): 87 * 0. addDir (--add-dir directories) - session-only, lowest priority 88 * 1. managed (policySettings) - not user-editable 89 * 2. user (userSettings) 90 * 3. project (projectSettings) 91 * 4. local (localSettings) 92 * 5. flag (flagSettings) - session-only, not persisted 93 * 94 * @returns Map of plugin ID to the user-editable scope that owns it 95 */ 96export function getPluginEditableScopes(): Map<string, ExtendedPluginScope> { 97 const result = new Map<string, ExtendedPluginScope>() 98 99 // Process --add-dir directories FIRST (lowest priority, overridden by all standard sources) 100 const addDirPlugins = getAddDirEnabledPlugins() 101 for (const [pluginId, value] of Object.entries(addDirPlugins)) { 102 if (!pluginId.includes('@')) { 103 continue 104 } 105 if (value === true) { 106 result.set(pluginId, 'flag') // 'flag' scope = session-only, no write-back 107 } else if (value === false) { 108 result.delete(pluginId) 109 } 110 } 111 112 // Process standard sources in precedence order (later overrides earlier) 113 const scopeSources: Array<{ 114 scope: ExtendedPluginScope 115 source: SettingSource 116 }> = [ 117 { scope: 'managed', source: 'policySettings' }, 118 { scope: 'user', source: 'userSettings' }, 119 { scope: 'project', source: 'projectSettings' }, 120 { scope: 'local', source: 'localSettings' }, 121 { scope: 'flag', source: 'flagSettings' }, 122 ] 123 124 for (const { scope, source } of scopeSources) { 125 const settings = getSettingsForSource(source) 126 if (!settings?.enabledPlugins) { 127 continue 128 } 129 130 for (const [pluginId, value] of Object.entries(settings.enabledPlugins)) { 131 // Skip invalid format 132 if (!pluginId.includes('@')) { 133 continue 134 } 135 136 // Log when a standard source overrides an --add-dir plugin 137 if (pluginId in addDirPlugins && addDirPlugins[pluginId] !== value) { 138 logForDebugging( 139 `Plugin ${pluginId} from --add-dir (${addDirPlugins[pluginId]}) overridden by ${source} (${value})`, 140 ) 141 } 142 143 if (value === true) { 144 // Plugin enabled at this scope 145 result.set(pluginId, scope) 146 } else if (value === false) { 147 // Explicitly disabled - remove from result 148 result.delete(pluginId) 149 } 150 // Note: Other values (like version strings for future P2) are ignored for now 151 } 152 } 153 154 logForDebugging( 155 `Found ${result.size} enabled plugins with scopes: ${Array.from( 156 result.entries(), 157 ) 158 .map(([id, scope]) => `${id}(${scope})`) 159 .join(', ')}`, 160 ) 161 162 return result 163} 164 165/** 166 * Check if a scope is persistable (not session-only). 167 * @param scope The scope to check 168 * @returns true if the scope should be persisted to installed_plugins.json 169 */ 170export function isPersistableScope( 171 scope: ExtendedPluginScope, 172): scope is PersistablePluginScope { 173 return scope !== 'flag' 174} 175 176/** 177 * Convert SettingSource to plugin scope. 178 * @param source The settings source 179 * @returns The corresponding plugin scope 180 */ 181export function settingSourceToScope( 182 source: SettingSource, 183): ExtendedPluginScope { 184 return SETTING_SOURCE_TO_SCOPE[source] 185} 186 187/** 188 * Gets the list of currently installed plugins 189 * Reads from installed_plugins.json which tracks global installation state. 190 * Automatically runs migration on first call if needed. 191 * 192 * Always uses V2 format and initializes the in-memory session state 193 * (which triggers V1→V2 migration if needed). 194 * 195 * @returns Array of installed plugin IDs 196 */ 197export async function getInstalledPlugins(): Promise<string[]> { 198 // Trigger sync in background (don't await - don't block startup) 199 // This syncs enabledPlugins from settings.json to installed_plugins.json 200 void migrateFromEnabledPlugins().catch(error => { 201 logError(error) 202 }) 203 204 // Always use V2 format - initializes in-memory session state and triggers V1→V2 migration 205 const v2Data = getInMemoryInstalledPlugins() 206 const installed = Object.keys(v2Data.plugins) 207 logForDebugging(`Found ${installed.length} installed plugins`) 208 return installed 209} 210 211/** 212 * Finds plugins that are enabled but not installed 213 * @param enabledPlugins Array of enabled plugin IDs 214 * @returns Array of missing plugin IDs 215 */ 216export async function findMissingPlugins( 217 enabledPlugins: string[], 218): Promise<string[]> { 219 try { 220 const installedPlugins = await getInstalledPlugins() 221 222 // Filter to not-installed synchronously, then look up all in parallel. 223 // Results are collected in original enabledPlugins order. 224 const notInstalled = enabledPlugins.filter( 225 id => !installedPlugins.includes(id), 226 ) 227 const lookups = await Promise.all( 228 notInstalled.map(async pluginId => { 229 try { 230 const plugin = await getPluginById(pluginId) 231 return { pluginId, found: plugin !== null && plugin !== undefined } 232 } catch (error) { 233 logForDebugging( 234 `Failed to check plugin ${pluginId} in marketplace: ${error}`, 235 ) 236 // Plugin doesn't exist in any marketplace, will be handled as an error 237 return { pluginId, found: false } 238 } 239 }), 240 ) 241 const missing = lookups 242 .filter(({ found }) => found) 243 .map(({ pluginId }) => pluginId) 244 245 return missing 246 } catch (error) { 247 logError(error) 248 return [] 249 } 250} 251 252/** 253 * Result of plugin installation attempt 254 */ 255export type PluginInstallResult = { 256 installed: string[] 257 failed: Array<{ name: string; error: string }> 258} 259 260/** 261 * Installation scope type for install functions (excludes 'managed' which is read-only) 262 */ 263type InstallableScope = Exclude<PluginScope, 'managed'> 264 265/** 266 * Installs the selected plugins 267 * @param pluginsToInstall Array of plugin IDs to install 268 * @param onProgress Optional callback for installation progress 269 * @param scope Installation scope: user, project, or local (defaults to 'user') 270 * @returns Installation results with succeeded and failed plugins 271 */ 272export async function installSelectedPlugins( 273 pluginsToInstall: string[], 274 onProgress?: (name: string, index: number, total: number) => void, 275 scope: InstallableScope = 'user', 276): Promise<PluginInstallResult> { 277 // Get projectPath for non-user scopes 278 const projectPath = scope !== 'user' ? getCwd() : undefined 279 280 // Get the correct settings source for this scope 281 const settingSource = scopeToSettingSource(scope) 282 const settings = getSettingsForSource(settingSource) 283 const updatedEnabledPlugins = { ...settings?.enabledPlugins } 284 const installed: string[] = [] 285 const failed: Array<{ name: string; error: string }> = [] 286 287 for (let i = 0; i < pluginsToInstall.length; i++) { 288 const pluginId = pluginsToInstall[i] 289 if (!pluginId) continue 290 291 if (onProgress) { 292 onProgress(pluginId, i + 1, pluginsToInstall.length) 293 } 294 295 try { 296 const pluginInfo = await getPluginById(pluginId) 297 if (!pluginInfo) { 298 failed.push({ 299 name: pluginId, 300 error: 'Plugin not found in any marketplace', 301 }) 302 continue 303 } 304 305 // Cache the plugin if it's from an external source 306 const { entry, marketplaceInstallLocation } = pluginInfo 307 if (!isLocalPluginSource(entry.source)) { 308 // External plugin - cache and register it with scope 309 await cacheAndRegisterPlugin(pluginId, entry, scope, projectPath) 310 } else { 311 // Local plugin - just register it with the install path and scope 312 registerPluginInstallation( 313 { 314 pluginId, 315 installPath: join(marketplaceInstallLocation, entry.source), 316 version: entry.version, 317 }, 318 scope, 319 projectPath, 320 ) 321 } 322 323 // Mark as enabled in settings 324 updatedEnabledPlugins[pluginId] = true 325 installed.push(pluginId) 326 } catch (error) { 327 const errorMessage = 328 error instanceof Error ? error.message : String(error) 329 failed.push({ name: pluginId, error: errorMessage }) 330 logError(error) 331 } 332 } 333 334 // Update settings with newly enabled plugins using the correct settings source 335 updateSettingsForSource(settingSource, { 336 ...settings, 337 enabledPlugins: updatedEnabledPlugins, 338 }) 339 340 return { installed, failed } 341}