File size: 6,669 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 176 177 178 179 | /**
* Centralized plugin directory configuration.
*
* This module provides the single source of truth for the plugins directory path.
* It supports switching between 'plugins' and 'cowork_plugins' directories via:
* - CLI flag: --cowork
* - Environment variable: CLAUDE_CODE_USE_COWORK_PLUGINS
*
* The base directory can be overridden via CLAUDE_CODE_PLUGIN_CACHE_DIR.
*/
import { mkdirSync } from 'fs'
import { readdir, rm, stat } from 'fs/promises'
import { delimiter, join } from 'path'
import { getUseCoworkPlugins } from '../../bootstrap/state.js'
import { logForDebugging } from '../debug.js'
import { getClaudeConfigHomeDir, isEnvTruthy } from '../envUtils.js'
import { errorMessage, isFsInaccessible } from '../errors.js'
import { formatFileSize } from '../format.js'
import { expandTilde } from '../permissions/pathValidation.js'
const PLUGINS_DIR = 'plugins'
const COWORK_PLUGINS_DIR = 'cowork_plugins'
/**
* Get the plugins directory name based on current mode.
* Uses session state (from --cowork flag) or env var.
*
* Priority:
* 1. Session state (set by CLI flag --cowork)
* 2. Environment variable CLAUDE_CODE_USE_COWORK_PLUGINS
* 3. Default: 'plugins'
*/
function getPluginsDirectoryName(): string {
// Session state takes precedence (set by CLI flag)
if (getUseCoworkPlugins()) {
return COWORK_PLUGINS_DIR
}
// Fall back to env var
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_COWORK_PLUGINS)) {
return COWORK_PLUGINS_DIR
}
return PLUGINS_DIR
}
/**
* Get the full path to the plugins directory.
*
* Priority:
* 1. CLAUDE_CODE_PLUGIN_CACHE_DIR env var (explicit override)
* 2. Default: ~/.claude/plugins or ~/.claude/cowork_plugins
*/
export function getPluginsDirectory(): string {
// expandTilde: when CLAUDE_CODE_PLUGIN_CACHE_DIR is set via settings.json
// `env` (not shell), ~ is not expanded by the shell. Without this, a value
// like "~/.claude/plugins" becomes a literal `~` directory created in the
// cwd of every project (gh-30794 / CC-212).
const envOverride = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR
if (envOverride) {
return expandTilde(envOverride)
}
return join(getClaudeConfigHomeDir(), getPluginsDirectoryName())
}
/**
* Get the read-only plugin seed directories, if configured.
*
* Customers can pre-bake a populated plugins directory into their container
* image and point CLAUDE_CODE_PLUGIN_SEED_DIR at it. CC will use it as a
* read-only fallback layer under the primary plugins directory β marketplaces
* and plugin caches found in the seed are used in place without re-cloning.
*
* Multiple seed directories can be layered using the platform path delimiter
* (':' on Unix, ';' on Windows), in PATH-like precedence order β the first
* seed that contains a given marketplace or plugin cache wins.
*
* Seed structure mirrors the primary plugins directory:
* $CLAUDE_CODE_PLUGIN_SEED_DIR/
* known_marketplaces.json
* marketplaces/<name>/...
* cache/<marketplace>/<plugin>/<version>/...
*
* @returns Absolute paths to seed dirs in precedence order (empty if unset)
*/
export function getPluginSeedDirs(): string[] {
// Same tilde-expansion rationale as getPluginsDirectory (gh-30794).
const raw = process.env.CLAUDE_CODE_PLUGIN_SEED_DIR
if (!raw) return []
return raw.split(delimiter).filter(Boolean).map(expandTilde)
}
function sanitizePluginId(pluginId: string): string {
// Same character class as the install-cache sanitizer (pluginLoader.ts)
return pluginId.replace(/[^a-zA-Z0-9\-_]/g, '-')
}
/** Pure path β no mkdir. For display (e.g. uninstall dialog). */
export function pluginDataDirPath(pluginId: string): string {
return join(getPluginsDirectory(), 'data', sanitizePluginId(pluginId))
}
/**
* Persistent per-plugin data directory, exposed to plugins as
* ${CLAUDE_PLUGIN_DATA}. Unlike the version-scoped install cache
* (${CLAUDE_PLUGIN_ROOT}, which is orphaned and GC'd on every update),
* this survives plugin updates β only removed on last-scope uninstall.
*
* Creates the directory on call (mkdir). The *lazy* behavior is at the
* substitutePluginVariables call site β the DATA pattern uses function-form
* .replace() so this isn't invoked unless ${CLAUDE_PLUGIN_DATA} is present
* (ROOT also uses function-form, but for $-pattern safety, not laziness).
* Env-var export sites (MCP/LSP server env, hook env) call this eagerly
* since subprocesses may expect the dir to exist before writing to it.
*
* Sync because it's called from substitutePluginVariables (sync, inside
* String.replace) β making this async would cascade through 6 call sites
* and their sync iteration loops. One mkdir in plugin-load path is cheap.
*/
export function getPluginDataDir(pluginId: string): string {
const dir = pluginDataDirPath(pluginId)
mkdirSync(dir, { recursive: true })
return dir
}
/**
* Size of the data dir for the uninstall confirmation prompt. Returns null
* when the dir is absent or empty so callers can skip the prompt entirely.
* Recursive walk β not hot-path (only on uninstall).
*/
export async function getPluginDataDirSize(
pluginId: string,
): Promise<{ bytes: number; human: string } | null> {
const dir = pluginDataDirPath(pluginId)
let bytes = 0
const walk = async (p: string) => {
for (const entry of await readdir(p, { withFileTypes: true })) {
const full = join(p, entry.name)
if (entry.isDirectory()) {
await walk(full)
} else {
// Per-entry catch: a broken symlink makes stat() throw ENOENT.
// Without this, one broken link bubbles to the outer catch β
// returns null β dialog skipped β data silently deleted.
try {
bytes += (await stat(full)).size
} catch {
// Broken symlink / raced delete β skip this entry, keep walking
}
}
}
}
try {
await walk(dir)
} catch (e) {
if (isFsInaccessible(e)) return null
throw e
}
if (bytes === 0) return null
return { bytes, human: formatFileSize(bytes) }
}
/**
* Best-effort cleanup on last-scope uninstall. Failure is logged but does
* not throw β the uninstall itself already succeeded; we don't want a
* cleanup side-effect surfacing as "uninstall failed". Same rationale as
* deletePluginOptions (pluginOptionsStorage.ts).
*/
export async function deletePluginDataDir(pluginId: string): Promise<void> {
const dir = pluginDataDirPath(pluginId)
try {
await rm(dir, { recursive: true, force: true })
} catch (e) {
logForDebugging(
`Failed to delete plugin data dir ${dir}: ${errorMessage(e)}`,
{ level: 'warn' },
)
}
}
|