/ utils / plugins / pluginDirectories.ts
pluginDirectories.ts
  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  
 12  import { mkdirSync } from 'fs'
 13  import { readdir, rm, stat } from 'fs/promises'
 14  import { delimiter, join } from 'path'
 15  import { getUseCoworkPlugins } from '../../bootstrap/state.js'
 16  import { logForDebugging } from '../debug.js'
 17  import { getClaudeConfigHomeDir, isEnvTruthy } from '../envUtils.js'
 18  import { errorMessage, isFsInaccessible } from '../errors.js'
 19  import { formatFileSize } from '../format.js'
 20  import { expandTilde } from '../permissions/pathValidation.js'
 21  
 22  const PLUGINS_DIR = 'plugins'
 23  const 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   */
 34  function 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   */
 53  export 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   */
 85  export 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  
 92  function 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). */
 98  export 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   */
119  export 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   */
130  export 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   */
168  export 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  }