π File detail
utils/plugins/pluginDirectories.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes getPluginsDirectory, getPluginSeedDirs, pluginDataDirPath, getPluginDataDir, and getPluginDataDirSize (and more) β mainly functions, hooks, or classes. Dependencies touch Node filesystem and Node path helpers. It composes internal code from bootstrap, debug, envUtils, errors, and format (relative imports). What the file header says: 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 Th.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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.
π€ Exports (heuristic)
getPluginsDirectorygetPluginSeedDirspluginDataDirPathgetPluginDataDirgetPluginDataDirSizedeletePluginDataDir
π External import roots
Package roots from from "β¦" (relative paths omitted).
fspath
π₯οΈ Source preview
/**
* 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' },
)
}
}