source dump of claude code
at main 178 lines 6.7 kB view raw
1/** 2 * Centralized plugin directory configuration. 3 * 4 * This module provides the single source of truth for the plugins directory path. 5 * It supports switching between 'plugins' and 'cowork_plugins' directories via: 6 * - CLI flag: --cowork 7 * - Environment variable: CLAUDE_CODE_USE_COWORK_PLUGINS 8 * 9 * The base directory can be overridden via CLAUDE_CODE_PLUGIN_CACHE_DIR. 10 */ 11 12import { mkdirSync } from 'fs' 13import { readdir, rm, stat } from 'fs/promises' 14import { delimiter, join } from 'path' 15import { getUseCoworkPlugins } from '../../bootstrap/state.js' 16import { logForDebugging } from '../debug.js' 17import { getClaudeConfigHomeDir, isEnvTruthy } from '../envUtils.js' 18import { errorMessage, isFsInaccessible } from '../errors.js' 19import { formatFileSize } from '../format.js' 20import { expandTilde } from '../permissions/pathValidation.js' 21 22const PLUGINS_DIR = 'plugins' 23const COWORK_PLUGINS_DIR = 'cowork_plugins' 24 25/** 26 * Get the plugins directory name based on current mode. 27 * Uses session state (from --cowork flag) or env var. 28 * 29 * Priority: 30 * 1. Session state (set by CLI flag --cowork) 31 * 2. Environment variable CLAUDE_CODE_USE_COWORK_PLUGINS 32 * 3. Default: 'plugins' 33 */ 34function getPluginsDirectoryName(): string { 35 // Session state takes precedence (set by CLI flag) 36 if (getUseCoworkPlugins()) { 37 return COWORK_PLUGINS_DIR 38 } 39 // Fall back to env var 40 if (isEnvTruthy(process.env.CLAUDE_CODE_USE_COWORK_PLUGINS)) { 41 return COWORK_PLUGINS_DIR 42 } 43 return PLUGINS_DIR 44} 45 46/** 47 * Get the full path to the plugins directory. 48 * 49 * Priority: 50 * 1. CLAUDE_CODE_PLUGIN_CACHE_DIR env var (explicit override) 51 * 2. Default: ~/.claude/plugins or ~/.claude/cowork_plugins 52 */ 53export function getPluginsDirectory(): string { 54 // expandTilde: when CLAUDE_CODE_PLUGIN_CACHE_DIR is set via settings.json 55 // `env` (not shell), ~ is not expanded by the shell. Without this, a value 56 // like "~/.claude/plugins" becomes a literal `~` directory created in the 57 // cwd of every project (gh-30794 / CC-212). 58 const envOverride = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR 59 if (envOverride) { 60 return expandTilde(envOverride) 61 } 62 return join(getClaudeConfigHomeDir(), getPluginsDirectoryName()) 63} 64 65/** 66 * Get the read-only plugin seed directories, if configured. 67 * 68 * Customers can pre-bake a populated plugins directory into their container 69 * image and point CLAUDE_CODE_PLUGIN_SEED_DIR at it. CC will use it as a 70 * read-only fallback layer under the primary plugins directory — marketplaces 71 * and plugin caches found in the seed are used in place without re-cloning. 72 * 73 * Multiple seed directories can be layered using the platform path delimiter 74 * (':' on Unix, ';' on Windows), in PATH-like precedence order — the first 75 * seed that contains a given marketplace or plugin cache wins. 76 * 77 * Seed structure mirrors the primary plugins directory: 78 * $CLAUDE_CODE_PLUGIN_SEED_DIR/ 79 * known_marketplaces.json 80 * marketplaces/<name>/... 81 * cache/<marketplace>/<plugin>/<version>/... 82 * 83 * @returns Absolute paths to seed dirs in precedence order (empty if unset) 84 */ 85export function getPluginSeedDirs(): string[] { 86 // Same tilde-expansion rationale as getPluginsDirectory (gh-30794). 87 const raw = process.env.CLAUDE_CODE_PLUGIN_SEED_DIR 88 if (!raw) return [] 89 return raw.split(delimiter).filter(Boolean).map(expandTilde) 90} 91 92function sanitizePluginId(pluginId: string): string { 93 // Same character class as the install-cache sanitizer (pluginLoader.ts) 94 return pluginId.replace(/[^a-zA-Z0-9\-_]/g, '-') 95} 96 97/** Pure path — no mkdir. For display (e.g. uninstall dialog). */ 98export function pluginDataDirPath(pluginId: string): string { 99 return join(getPluginsDirectory(), 'data', sanitizePluginId(pluginId)) 100} 101 102/** 103 * Persistent per-plugin data directory, exposed to plugins as 104 * ${CLAUDE_PLUGIN_DATA}. Unlike the version-scoped install cache 105 * (${CLAUDE_PLUGIN_ROOT}, which is orphaned and GC'd on every update), 106 * this survives plugin updates — only removed on last-scope uninstall. 107 * 108 * Creates the directory on call (mkdir). The *lazy* behavior is at the 109 * substitutePluginVariables call site — the DATA pattern uses function-form 110 * .replace() so this isn't invoked unless ${CLAUDE_PLUGIN_DATA} is present 111 * (ROOT also uses function-form, but for $-pattern safety, not laziness). 112 * Env-var export sites (MCP/LSP server env, hook env) call this eagerly 113 * since subprocesses may expect the dir to exist before writing to it. 114 * 115 * Sync because it's called from substitutePluginVariables (sync, inside 116 * String.replace) — making this async would cascade through 6 call sites 117 * and their sync iteration loops. One mkdir in plugin-load path is cheap. 118 */ 119export function getPluginDataDir(pluginId: string): string { 120 const dir = pluginDataDirPath(pluginId) 121 mkdirSync(dir, { recursive: true }) 122 return dir 123} 124 125/** 126 * Size of the data dir for the uninstall confirmation prompt. Returns null 127 * when the dir is absent or empty so callers can skip the prompt entirely. 128 * Recursive walk — not hot-path (only on uninstall). 129 */ 130export async function getPluginDataDirSize( 131 pluginId: string, 132): Promise<{ bytes: number; human: string } | null> { 133 const dir = pluginDataDirPath(pluginId) 134 let bytes = 0 135 const walk = async (p: string) => { 136 for (const entry of await readdir(p, { withFileTypes: true })) { 137 const full = join(p, entry.name) 138 if (entry.isDirectory()) { 139 await walk(full) 140 } else { 141 // Per-entry catch: a broken symlink makes stat() throw ENOENT. 142 // Without this, one broken link bubbles to the outer catch → 143 // returns null → dialog skipped → data silently deleted. 144 try { 145 bytes += (await stat(full)).size 146 } catch { 147 // Broken symlink / raced delete — skip this entry, keep walking 148 } 149 } 150 } 151 } 152 try { 153 await walk(dir) 154 } catch (e) { 155 if (isFsInaccessible(e)) return null 156 throw e 157 } 158 if (bytes === 0) return null 159 return { bytes, human: formatFileSize(bytes) } 160} 161 162/** 163 * Best-effort cleanup on last-scope uninstall. Failure is logged but does 164 * not throw — the uninstall itself already succeeded; we don't want a 165 * cleanup side-effect surfacing as "uninstall failed". Same rationale as 166 * deletePluginOptions (pluginOptionsStorage.ts). 167 */ 168export async function deletePluginDataDir(pluginId: string): Promise<void> { 169 const dir = pluginDataDirPath(pluginId) 170 try { 171 await rm(dir, { recursive: true, force: true }) 172 } catch (e) { 173 logForDebugging( 174 `Failed to delete plugin data dir ${dir}: ${errorMessage(e)}`, 175 { level: 'warn' }, 176 ) 177 } 178}