/ utils / plugins / marketplaceManager.ts
marketplaceManager.ts
   1  /**
   2   * Marketplace manager for Claude Code plugins
   3   *
   4   * This module provides functionality to:
   5   * - Manage known marketplace sources (URLs, GitHub repos, npm packages, local files)
   6   * - Cache marketplace manifests locally for offline access
   7   * - Install plugins from marketplace entries
   8   * - Track and update marketplace configurations
   9   *
  10   * File structure managed by this module:
  11   * ~/.claude/
  12   *   └── plugins/
  13   *       ├── known_marketplaces.json    # Configuration of all known marketplaces
  14   *       └── marketplaces/              # Cache directory for marketplace data
  15   *           ├── my-marketplace.json    # Cached marketplace from URL source
  16   *           └── github-marketplace/    # Cloned repository for GitHub source
  17   *               └── .claude-plugin/
  18   *                   └── marketplace.json
  19   */
  20  
  21  import axios from 'axios'
  22  import { writeFile } from 'fs/promises'
  23  import isEqual from 'lodash-es/isEqual.js'
  24  import memoize from 'lodash-es/memoize.js'
  25  import { basename, dirname, isAbsolute, join, resolve, sep } from 'path'
  26  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
  27  import { logForDebugging } from '../debug.js'
  28  import { isEnvTruthy } from '../envUtils.js'
  29  import {
  30    ConfigParseError,
  31    errorMessage,
  32    getErrnoCode,
  33    isENOENT,
  34    toError,
  35  } from '../errors.js'
  36  import { execFileNoThrow, execFileNoThrowWithCwd } from '../execFileNoThrow.js'
  37  import { getFsImplementation } from '../fsOperations.js'
  38  import { gitExe } from '../git.js'
  39  import { logError } from '../log.js'
  40  import {
  41    getInitialSettings,
  42    getSettingsForSource,
  43    updateSettingsForSource,
  44  } from '../settings/settings.js'
  45  import type { SettingsJson } from '../settings/types.js'
  46  import {
  47    jsonParse,
  48    jsonStringify,
  49    writeFileSync_DEPRECATED,
  50  } from '../slowOperations.js'
  51  import {
  52    getAddDirEnabledPlugins,
  53    getAddDirExtraMarketplaces,
  54  } from './addDirPluginSettings.js'
  55  import { markPluginVersionOrphaned } from './cacheUtils.js'
  56  import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
  57  import { removeAllPluginsForMarketplace } from './installedPluginsManager.js'
  58  import {
  59    extractHostFromSource,
  60    formatSourceForDisplay,
  61    getHostPatternsFromAllowlist,
  62    getStrictKnownMarketplaces,
  63    isSourceAllowedByPolicy,
  64    isSourceInBlocklist,
  65  } from './marketplaceHelpers.js'
  66  import {
  67    OFFICIAL_MARKETPLACE_NAME,
  68    OFFICIAL_MARKETPLACE_SOURCE,
  69  } from './officialMarketplace.js'
  70  import { fetchOfficialMarketplaceFromGcs } from './officialMarketplaceGcs.js'
  71  import {
  72    deletePluginDataDir,
  73    getPluginSeedDirs,
  74    getPluginsDirectory,
  75  } from './pluginDirectories.js'
  76  import { parsePluginIdentifier } from './pluginIdentifier.js'
  77  import { deletePluginOptions } from './pluginOptionsStorage.js'
  78  import {
  79    isLocalMarketplaceSource,
  80    type KnownMarketplace,
  81    type KnownMarketplacesFile,
  82    KnownMarketplacesFileSchema,
  83    type MarketplaceSource,
  84    type PluginMarketplace,
  85    type PluginMarketplaceEntry,
  86    PluginMarketplaceSchema,
  87    validateOfficialNameSource,
  88  } from './schemas.js'
  89  
  90  /**
  91   * Result of loading and caching a marketplace
  92   */
  93  type LoadedPluginMarketplace = {
  94    marketplace: PluginMarketplace
  95    cachePath: string
  96  }
  97  
  98  /**
  99   * Get the path to the known marketplaces configuration file
 100   * Using a function instead of a constant allows proper mocking in tests
 101   */
 102  function getKnownMarketplacesFile(): string {
 103    return join(getPluginsDirectory(), 'known_marketplaces.json')
 104  }
 105  
 106  /**
 107   * Get the path to the marketplaces cache directory
 108   * Using a function instead of a constant allows proper mocking in tests
 109   */
 110  export function getMarketplacesCacheDir(): string {
 111    return join(getPluginsDirectory(), 'marketplaces')
 112  }
 113  
 114  /**
 115   * Memoized inner function to get marketplace data.
 116   * This caches the marketplace in memory after loading from disk or network.
 117   */
 118  
 119  /**
 120   * Clear all cached marketplace data (for testing)
 121   */
 122  export function clearMarketplacesCache(): void {
 123    getMarketplace.cache?.clear?.()
 124  }
 125  
 126  /**
 127   * Configuration for known marketplaces
 128   */
 129  export type KnownMarketplacesConfig = KnownMarketplacesFile
 130  
 131  /**
 132   * Declared marketplace entry (intent layer).
 133   *
 134   * Structurally compatible with settings `extraKnownMarketplaces` entries, but
 135   * adds `sourceIsFallback` for implicit built-in declarations. This is NOT a
 136   * settings-schema field — it's only ever set in code (never parsed from JSON).
 137   */
 138  export type DeclaredMarketplace = {
 139    source: MarketplaceSource
 140    installLocation?: string
 141    autoUpdate?: boolean
 142    /**
 143     * Presence suffices. When set, diffMarketplaces treats an already-materialized
 144     * entry as upToDate regardless of source shape — never reports sourceChanged.
 145     *
 146     * Used for the implicit official-marketplace declaration: we want "clone from
 147     * GitHub if missing", not "replace with GitHub if present under a different
 148     * source". Without this, a seed dir that registers the official marketplace
 149     * under e.g. an internal-mirror source would be stomped by a GitHub re-clone.
 150     */
 151    sourceIsFallback?: boolean
 152  }
 153  
 154  /**
 155   * Get declared marketplace intent from merged settings and --add-dir sources.
 156   * This is what SHOULD exist — used by the reconciler to find gaps.
 157   *
 158   * The official marketplace is implicitly declared with `sourceIsFallback: true`
 159   * when any enabled plugin references it.
 160   */
 161  export function getDeclaredMarketplaces(): Record<string, DeclaredMarketplace> {
 162    const implicit: Record<string, DeclaredMarketplace> = {}
 163  
 164    // Only the official marketplace can be implicitly declared — it's the one
 165    // built-in source we know. Other marketplaces have no default source to inject.
 166    // Explicitly-disabled entries (value: false) don't count.
 167    const enabledPlugins = {
 168      ...getAddDirEnabledPlugins(),
 169      ...(getInitialSettings().enabledPlugins ?? {}),
 170    }
 171    for (const [pluginId, value] of Object.entries(enabledPlugins)) {
 172      if (
 173        value &&
 174        parsePluginIdentifier(pluginId).marketplace === OFFICIAL_MARKETPLACE_NAME
 175      ) {
 176        implicit[OFFICIAL_MARKETPLACE_NAME] = {
 177          source: OFFICIAL_MARKETPLACE_SOURCE,
 178          sourceIsFallback: true,
 179        }
 180        break
 181      }
 182    }
 183  
 184    // Lowest precedence: implicit < --add-dir < merged settings.
 185    // An explicit extraKnownMarketplaces entry for claude-plugins-official
 186    // in --add-dir or settings wins.
 187    return {
 188      ...implicit,
 189      ...getAddDirExtraMarketplaces(),
 190      ...(getInitialSettings().extraKnownMarketplaces ?? {}),
 191    }
 192  }
 193  
 194  /**
 195   * Find which editable settings source declared a marketplace.
 196   * Checks in reverse precedence order (highest priority last) so the
 197   * result is the source that "wins" in the merged view.
 198   * Returns null if the marketplace isn't declared in any editable source.
 199   */
 200  export function getMarketplaceDeclaringSource(
 201    name: string,
 202  ): 'userSettings' | 'projectSettings' | 'localSettings' | null {
 203    // Check highest-precedence editable sources first — the one that wins
 204    // in the merged view is the one we should write back to.
 205    const editableSources: Array<
 206      'localSettings' | 'projectSettings' | 'userSettings'
 207    > = ['localSettings', 'projectSettings', 'userSettings']
 208  
 209    for (const source of editableSources) {
 210      const settings = getSettingsForSource(source)
 211      if (settings?.extraKnownMarketplaces?.[name]) {
 212        return source
 213      }
 214    }
 215    return null
 216  }
 217  
 218  /**
 219   * Save a marketplace entry to settings (intent layer).
 220   * Does NOT touch known_marketplaces.json (state layer).
 221   *
 222   * @param name - The marketplace name
 223   * @param entry - The marketplace config
 224   * @param settingSource - Which settings source to write to (defaults to userSettings)
 225   */
 226  export function saveMarketplaceToSettings(
 227    name: string,
 228    entry: DeclaredMarketplace,
 229    settingSource:
 230      | 'userSettings'
 231      | 'projectSettings'
 232      | 'localSettings' = 'userSettings',
 233  ): void {
 234    const existing = getSettingsForSource(settingSource) ?? {}
 235    const current = { ...existing.extraKnownMarketplaces }
 236    current[name] = entry
 237    updateSettingsForSource(settingSource, { extraKnownMarketplaces: current })
 238  }
 239  
 240  /**
 241   * Load known marketplaces configuration from disk
 242   *
 243   * Reads the configuration file at ~/.claude/plugins/known_marketplaces.json
 244   * which contains a mapping of marketplace names to their sources and metadata.
 245   *
 246   * Example configuration file content:
 247   * ```json
 248   * {
 249   *   "official-marketplace": {
 250   *     "source": { "source": "url", "url": "https://example.com/marketplace.json" },
 251   *     "installLocation": "/Users/me/.claude/plugins/marketplaces/official-marketplace.json",
 252   *     "lastUpdated": "2024-01-15T10:30:00.000Z"
 253   *   },
 254   *   "company-plugins": {
 255   *     "source": { "source": "github", "repo": "mycompany/plugins" },
 256   *     "installLocation": "/Users/me/.claude/plugins/marketplaces/company-plugins",
 257   *     "lastUpdated": "2024-01-14T15:45:00.000Z"
 258   *   }
 259   * }
 260   * ```
 261   *
 262   * @returns Configuration object mapping marketplace names to their metadata
 263   */
 264  export async function loadKnownMarketplacesConfig(): Promise<KnownMarketplacesConfig> {
 265    const fs = getFsImplementation()
 266    const configFile = getKnownMarketplacesFile()
 267  
 268    try {
 269      const content = await fs.readFile(configFile, {
 270        encoding: 'utf-8',
 271      })
 272      const data = jsonParse(content)
 273      // Validate against schema
 274      const parsed = KnownMarketplacesFileSchema().safeParse(data)
 275      if (!parsed.success) {
 276        const errorMsg = `Marketplace configuration file is corrupted: ${parsed.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`
 277        logForDebugging(errorMsg, {
 278          level: 'error',
 279        })
 280        throw new ConfigParseError(errorMsg, configFile, data)
 281      }
 282      return parsed.data
 283    } catch (error) {
 284      if (isENOENT(error)) {
 285        return {}
 286      }
 287      // If it's already a ConfigParseError, re-throw it
 288      if (error instanceof ConfigParseError) {
 289        throw error
 290      }
 291      // For JSON parse errors or I/O errors, throw with helpful message
 292      const errorMsg = `Failed to load marketplace configuration: ${errorMessage(error)}`
 293      logForDebugging(errorMsg, {
 294        level: 'error',
 295      })
 296      throw new Error(errorMsg)
 297    }
 298  }
 299  
 300  /**
 301   * Load known marketplaces config, returning {} on any error instead of throwing.
 302   *
 303   * Use this on read-only paths (plugin loading, feature checks) where a corrupted
 304   * config should degrade gracefully rather than crash. DO NOT use on load→mutate→save
 305   * paths — returning {} there would cause the save to overwrite the corrupted file
 306   * with just the new entry, permanently destroying the user's other entries. The
 307   * throwing variant preserves the file so the user can fix the corruption and recover.
 308   */
 309  export async function loadKnownMarketplacesConfigSafe(): Promise<KnownMarketplacesConfig> {
 310    try {
 311      return await loadKnownMarketplacesConfig()
 312    } catch {
 313      // Inner function already logged via logForDebugging. Don't logError here —
 314      // corrupted user config isn't a Claude Code bug, shouldn't hit the error file.
 315      return {}
 316    }
 317  }
 318  
 319  /**
 320   * Save known marketplaces configuration to disk
 321   *
 322   * Writes the configuration to ~/.claude/plugins/known_marketplaces.json,
 323   * creating the directory structure if it doesn't exist.
 324   *
 325   * @param config - The marketplace configuration to save
 326   */
 327  export async function saveKnownMarketplacesConfig(
 328    config: KnownMarketplacesConfig,
 329  ): Promise<void> {
 330    // Validate before saving
 331    const parsed = KnownMarketplacesFileSchema().safeParse(config)
 332    const configFile = getKnownMarketplacesFile()
 333  
 334    if (!parsed.success) {
 335      throw new ConfigParseError(
 336        `Invalid marketplace config: ${parsed.error.message}`,
 337        configFile,
 338        config,
 339      )
 340    }
 341  
 342    const fs = getFsImplementation()
 343    // Get directory from config file path to ensure consistency
 344    const dir = join(configFile, '..')
 345    await fs.mkdir(dir)
 346    writeFileSync_DEPRECATED(configFile, jsonStringify(parsed.data, null, 2), {
 347      encoding: 'utf-8',
 348      flush: true,
 349    })
 350  }
 351  
 352  /**
 353   * Register marketplaces from the read-only seed directories into the primary
 354   * known_marketplaces.json.
 355   *
 356   * The seed's known_marketplaces.json contains installLocation paths pointing
 357   * into the seed dir itself. Registering those entries into the primary JSON
 358   * makes them visible to all marketplace readers (getMarketplaceCacheOnly,
 359   * getPluginByIdCacheOnly, etc.) without any loader changes — they just follow
 360   * the installLocation wherever it points.
 361   *
 362   * Seed entries always win for marketplaces declared in the seed — the seed is
 363   * admin-managed (baked into the container image). If admin updates the seed
 364   * in a new image, those changes propagate on next boot. Users opt out of seed
 365   * plugins via `plugin disable`, not by removing the marketplace.
 366   *
 367   * With multiple seed dirs (path-delimiter-separated), first-seed-wins: a
 368   * marketplace name claimed by an earlier seed is skipped by later seeds.
 369   *
 370   * autoUpdate is forced to false since the seed is read-only and git-pull would
 371   * fail. installLocation is computed from the runtime seedDir, not trusted from
 372   * the seed's JSON (handles multi-stage Docker mount-path drift).
 373   *
 374   * Idempotent: second call with unchanged seed writes nothing.
 375   *
 376   * @returns true if any marketplace entries were written/changed (caller should
 377   *   clear caches so earlier plugin-load passes don't keep stale "marketplace
 378   *   not found" state)
 379   */
 380  export async function registerSeedMarketplaces(): Promise<boolean> {
 381    const seedDirs = getPluginSeedDirs()
 382    if (seedDirs.length === 0) return false
 383  
 384    const primary = await loadKnownMarketplacesConfig()
 385    // First-seed-wins across this registration pass. Can't use the isEqual check
 386    // alone — two seeds with the same name will have different installLocations.
 387    const claimed = new Set<string>()
 388    let changed = 0
 389  
 390    for (const seedDir of seedDirs) {
 391      const seedConfig = await readSeedKnownMarketplaces(seedDir)
 392      if (!seedConfig) continue
 393  
 394      for (const [name, seedEntry] of Object.entries(seedConfig)) {
 395        if (claimed.has(name)) continue
 396  
 397        // Compute installLocation relative to THIS seedDir, not the build-time
 398        // path baked into the seed's JSON. Handles multi-stage Docker builds
 399        // where the seed is mounted at a different path than where it was built.
 400        const resolvedLocation = await findSeedMarketplaceLocation(seedDir, name)
 401        if (!resolvedLocation) {
 402          // Seed content missing (incomplete build) — leave primary alone, but
 403          // don't claim the name either: a later seed may have working content.
 404          logForDebugging(
 405            `Seed marketplace '${name}' not found under ${seedDir}/marketplaces/, skipping`,
 406            { level: 'warn' },
 407          )
 408          continue
 409        }
 410        claimed.add(name)
 411  
 412        const desired: KnownMarketplace = {
 413          source: seedEntry.source,
 414          installLocation: resolvedLocation,
 415          lastUpdated: seedEntry.lastUpdated,
 416          autoUpdate: false,
 417        }
 418  
 419        // Skip if primary already matches — idempotent no-op, no write.
 420        if (isEqual(primary[name], desired)) continue
 421  
 422        // Seed wins — admin-managed. Overwrite any existing primary entry.
 423        primary[name] = desired
 424        changed++
 425      }
 426    }
 427  
 428    if (changed > 0) {
 429      await saveKnownMarketplacesConfig(primary)
 430      logForDebugging(`Synced ${changed} marketplace(s) from seed dir(s)`)
 431      return true
 432    }
 433    return false
 434  }
 435  
 436  async function readSeedKnownMarketplaces(
 437    seedDir: string,
 438  ): Promise<KnownMarketplacesConfig | null> {
 439    const seedJsonPath = join(seedDir, 'known_marketplaces.json')
 440    try {
 441      const content = await getFsImplementation().readFile(seedJsonPath, {
 442        encoding: 'utf-8',
 443      })
 444      const parsed = KnownMarketplacesFileSchema().safeParse(jsonParse(content))
 445      if (!parsed.success) {
 446        logForDebugging(
 447          `Seed known_marketplaces.json invalid at ${seedDir}: ${parsed.error.message}`,
 448          { level: 'warn' },
 449        )
 450        return null
 451      }
 452      return parsed.data
 453    } catch (e) {
 454      if (!isENOENT(e)) {
 455        logForDebugging(
 456          `Failed to read seed known_marketplaces.json at ${seedDir}: ${e}`,
 457          { level: 'warn' },
 458        )
 459      }
 460      return null
 461    }
 462  }
 463  
 464  /**
 465   * Locate a marketplace in the seed directory by name.
 466   *
 467   * Probes the canonical locations under seedDir/marketplaces/ rather than
 468   * trusting the seed's stored installLocation (which may have a stale absolute
 469   * path from a different build-time mount point).
 470   *
 471   * @returns Readable location, or null if neither format exists/validates
 472   */
 473  async function findSeedMarketplaceLocation(
 474    seedDir: string,
 475    name: string,
 476  ): Promise<string | null> {
 477    const dirCandidate = join(seedDir, 'marketplaces', name)
 478    const jsonCandidate = join(seedDir, 'marketplaces', `${name}.json`)
 479    for (const candidate of [dirCandidate, jsonCandidate]) {
 480      try {
 481        await readCachedMarketplace(candidate)
 482        return candidate
 483      } catch {
 484        // Try next candidate
 485      }
 486    }
 487    return null
 488  }
 489  
 490  /**
 491   * If installLocation points into a configured seed directory, return that seed
 492   * directory. Seed-managed entries are admin-controlled — users can't
 493   * remove/refresh/modify them (they'd be overwritten by registerSeedMarketplaces
 494   * on next startup). Returning the specific seed lets error messages name it.
 495   */
 496  function seedDirFor(installLocation: string): string | undefined {
 497    return getPluginSeedDirs().find(
 498      d => installLocation === d || installLocation.startsWith(d + sep),
 499    )
 500  }
 501  
 502  /**
 503   * Git pull operation (exported for testing)
 504   *
 505   * Pulls latest changes with a configurable timeout (default 120s, override via CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS).
 506   * Provides helpful error messages for common failure scenarios.
 507   * If a ref is specified, fetches and checks out that specific branch or tag.
 508   */
 509  // Environment variables to prevent git from prompting for credentials
 510  const GIT_NO_PROMPT_ENV = {
 511    GIT_TERMINAL_PROMPT: '0', // Prevent terminal credential prompts
 512    GIT_ASKPASS: '', // Disable askpass GUI programs
 513  }
 514  
 515  const DEFAULT_PLUGIN_GIT_TIMEOUT_MS = 120 * 1000
 516  
 517  function getPluginGitTimeoutMs(): number {
 518    const envValue = process.env.CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS
 519    if (envValue) {
 520      const parsed = parseInt(envValue, 10)
 521      if (!isNaN(parsed) && parsed > 0) {
 522        return parsed
 523      }
 524    }
 525    return DEFAULT_PLUGIN_GIT_TIMEOUT_MS
 526  }
 527  
 528  export async function gitPull(
 529    cwd: string,
 530    ref?: string,
 531    options?: { disableCredentialHelper?: boolean; sparsePaths?: string[] },
 532  ): Promise<{ code: number; stderr: string }> {
 533    logForDebugging(`git pull: cwd=${cwd} ref=${ref ?? 'default'}`)
 534    const env = { ...process.env, ...GIT_NO_PROMPT_ENV }
 535    const credentialArgs = options?.disableCredentialHelper
 536      ? ['-c', 'credential.helper=']
 537      : []
 538  
 539    if (ref) {
 540      const fetchResult = await execFileNoThrowWithCwd(
 541        gitExe(),
 542        [...credentialArgs, 'fetch', 'origin', ref],
 543        { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env },
 544      )
 545  
 546      if (fetchResult.code !== 0) {
 547        return enhanceGitPullErrorMessages(fetchResult)
 548      }
 549  
 550      const checkoutResult = await execFileNoThrowWithCwd(
 551        gitExe(),
 552        [...credentialArgs, 'checkout', ref],
 553        { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env },
 554      )
 555  
 556      if (checkoutResult.code !== 0) {
 557        return enhanceGitPullErrorMessages(checkoutResult)
 558      }
 559  
 560      const pullResult = await execFileNoThrowWithCwd(
 561        gitExe(),
 562        [...credentialArgs, 'pull', 'origin', ref],
 563        { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env },
 564      )
 565      if (pullResult.code !== 0) {
 566        return enhanceGitPullErrorMessages(pullResult)
 567      }
 568      await gitSubmoduleUpdate(cwd, credentialArgs, env, options?.sparsePaths)
 569      return pullResult
 570    }
 571  
 572    const result = await execFileNoThrowWithCwd(
 573      gitExe(),
 574      [...credentialArgs, 'pull', 'origin', 'HEAD'],
 575      { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env },
 576    )
 577    if (result.code !== 0) {
 578      return enhanceGitPullErrorMessages(result)
 579    }
 580    await gitSubmoduleUpdate(cwd, credentialArgs, env, options?.sparsePaths)
 581    return result
 582  }
 583  
 584  /**
 585   * Sync submodule working dirs after a successful pull. gitClone() uses
 586   * --recurse-submodules, but gitPull() didn't — the parent repo's submodule
 587   * pointer would advance while the working dir stayed at the old commit,
 588   * making plugin sources in submodules unresolvable after marketplace update.
 589   * Non-fatal: a failed submodule update logs a warning; most marketplaces
 590   * don't use submodules at all. (gh-30696)
 591   *
 592   * Skipped for sparse clones — gitClone's sparse path intentionally omits
 593   * --recurse-submodules to preserve partial-clone bandwidth savings, and
 594   * .gitmodules is a root file that cone-mode sparse-checkout always
 595   * materializes, so the .gitmodules gate alone can't distinguish sparse repos.
 596   *
 597   * Perf: git-submodule is a bash script that spawns ~20 subprocesses (~35ms+)
 598   * even when no submodules exist. .gitmodules is a tracked file — pull
 599   * materializes it iff the repo has submodules — so gate on its presence to
 600   * skip the spawn for the common case.
 601   *
 602   * --init performs first-contact clone of newly-added submodules, so maintain
 603   * parity with gitClone's non-sparse path: StrictHostKeyChecking=yes for
 604   * fail-closed SSH (unknown hosts reject rather than silently populate
 605   * known_hosts), and --depth 1 for shallow clone (matching --shallow-submodules).
 606   * --depth only affects not-yet-initialized submodules; existing shallow
 607   * submodules are unaffected.
 608   */
 609  async function gitSubmoduleUpdate(
 610    cwd: string,
 611    credentialArgs: string[],
 612    env: NodeJS.ProcessEnv,
 613    sparsePaths: string[] | undefined,
 614  ): Promise<void> {
 615    if (sparsePaths && sparsePaths.length > 0) return
 616    const hasGitmodules = await getFsImplementation()
 617      .stat(join(cwd, '.gitmodules'))
 618      .then(
 619        () => true,
 620        () => false,
 621      )
 622    if (!hasGitmodules) return
 623    const result = await execFileNoThrowWithCwd(
 624      gitExe(),
 625      [
 626        '-c',
 627        'core.sshCommand=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
 628        ...credentialArgs,
 629        'submodule',
 630        'update',
 631        '--init',
 632        '--recursive',
 633        '--depth',
 634        '1',
 635      ],
 636      { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env },
 637    )
 638    if (result.code !== 0) {
 639      logForDebugging(
 640        `git submodule update failed (non-fatal): ${result.stderr}`,
 641        { level: 'warn' },
 642      )
 643    }
 644  }
 645  
 646  /**
 647   * Enhance error messages for git pull failures
 648   */
 649  function enhanceGitPullErrorMessages(result: {
 650    code: number
 651    stderr: string
 652    error?: string
 653  }): { code: number; stderr: string } {
 654    if (result.code === 0) {
 655      return result
 656    }
 657  
 658    // Detect execa timeout kills via the error field (stderr won't contain "timed out"
 659    // when the process is killed by SIGTERM — the timeout info is only in error)
 660    if (result.error?.includes('timed out')) {
 661      const timeoutSec = Math.round(getPluginGitTimeoutMs() / 1000)
 662      return {
 663        ...result,
 664        stderr: `Git pull timed out after ${timeoutSec}s. Try increasing the timeout via CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS environment variable.\n\nOriginal error: ${result.stderr}`,
 665      }
 666    }
 667  
 668    // Detect SSH host key verification failures (check before the generic
 669    // 'Could not read from remote' catch — that string appears in both cases).
 670    // OpenSSH emits "Host key verification failed" for BOTH host-not-in-known_hosts
 671    // and host-key-has-changed — the latter also includes the "REMOTE HOST
 672    // IDENTIFICATION HAS CHANGED" banner, which needs different remediation.
 673    if (result.stderr.includes('REMOTE HOST IDENTIFICATION HAS CHANGED')) {
 674      return {
 675        ...result,
 676        stderr: `SSH host key for this marketplace's git host has changed (server key rotation or possible MITM). Remove the stale entry with: ssh-keygen -R <host>\nThen connect once manually to accept the new key.\n\nOriginal error: ${result.stderr}`,
 677      }
 678    }
 679    if (result.stderr.includes('Host key verification failed')) {
 680      return {
 681        ...result,
 682        stderr: `SSH host key verification failed while updating marketplace. The host key is not in your known_hosts file. Connect once manually to add it (e.g., ssh -T git@<host>), or remove and re-add the marketplace with an HTTPS URL.\n\nOriginal error: ${result.stderr}`,
 683      }
 684    }
 685  
 686    // Detect SSH authentication failures
 687    if (
 688      result.stderr.includes('Permission denied (publickey)') ||
 689      result.stderr.includes('Could not read from remote repository')
 690    ) {
 691      return {
 692        ...result,
 693        stderr: `SSH authentication failed while updating marketplace. Please ensure your SSH keys are configured.\n\nOriginal error: ${result.stderr}`,
 694      }
 695    }
 696  
 697    // Detect network issues
 698    if (
 699      result.stderr.includes('timed out') ||
 700      result.stderr.includes('Could not resolve host')
 701    ) {
 702      return {
 703        ...result,
 704        stderr: `Network error while updating marketplace. Please check your internet connection.\n\nOriginal error: ${result.stderr}`,
 705      }
 706    }
 707  
 708    return result
 709  }
 710  
 711  /**
 712   * Check if SSH is likely to work for GitHub
 713   * This is a quick heuristic check that avoids the full clone timeout
 714   *
 715   * Uses StrictHostKeyChecking=yes (not accept-new) so an unknown github.com
 716   * host key fails closed rather than being silently added to known_hosts.
 717   * This prevents a network-level MITM from poisoning known_hosts on first
 718   * contact. Users who already have github.com in known_hosts see no change;
 719   * users who don't are routed to the HTTPS clone path.
 720   *
 721   * @returns true if SSH auth succeeds and github.com is already trusted
 722   */
 723  async function isGitHubSshLikelyConfigured(): Promise<boolean> {
 724    try {
 725      // Quick SSH connection test with 2 second timeout
 726      // This fails fast if SSH isn't configured
 727      const result = await execFileNoThrow(
 728        'ssh',
 729        [
 730          '-T',
 731          '-o',
 732          'BatchMode=yes',
 733          '-o',
 734          'ConnectTimeout=2',
 735          '-o',
 736          'StrictHostKeyChecking=yes',
 737          'git@github.com',
 738        ],
 739        {
 740          timeout: 3000, // 3 second total timeout
 741        },
 742      )
 743  
 744      // SSH to github.com always returns exit code 1 with "successfully authenticated"
 745      // or exit code 255 with "Permission denied" - we want the former
 746      const configured =
 747        result.code === 1 &&
 748        (result.stderr?.includes('successfully authenticated') ||
 749          result.stdout?.includes('successfully authenticated'))
 750      logForDebugging(
 751        `SSH config check: code=${result.code} configured=${configured}`,
 752      )
 753      return configured
 754    } catch (error) {
 755      // Any error means SSH isn't configured properly
 756      logForDebugging(`SSH configuration check failed: ${errorMessage(error)}`, {
 757        level: 'warn',
 758      })
 759      return false
 760    }
 761  }
 762  
 763  /**
 764   * Check if a git error indicates authentication failure.
 765   * Used to provide enhanced error messages for auth failures.
 766   */
 767  function isAuthenticationError(stderr: string): boolean {
 768    return (
 769      stderr.includes('Authentication failed') ||
 770      stderr.includes('could not read Username') ||
 771      stderr.includes('terminal prompts disabled') ||
 772      stderr.includes('403') ||
 773      stderr.includes('401')
 774    )
 775  }
 776  
 777  /**
 778   * Extract the SSH host from a git URL for error messaging.
 779   * Matches the SSH format user@host:path (e.g., git@github.com:owner/repo.git).
 780   */
 781  function extractSshHost(gitUrl: string): string | null {
 782    const match = gitUrl.match(/^[^@]+@([^:]+):/)
 783    return match?.[1] ?? null
 784  }
 785  
 786  /**
 787   * Git clone operation (exported for testing)
 788   *
 789   * Clones a git repository with a configurable timeout (default 120s, override via CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS)
 790   * and larger repositories. Provides helpful error messages for common failure scenarios.
 791   * Optionally checks out a specific branch or tag.
 792   *
 793   * Does NOT disable credential helpers — this allows the user's existing auth setup
 794   * (gh auth, keychain, git-credential-store, etc.) to work natively for private repos.
 795   * Interactive prompts are still prevented via GIT_TERMINAL_PROMPT=0, GIT_ASKPASS='',
 796   * stdin: 'ignore', and BatchMode=yes for SSH.
 797   *
 798   * Uses StrictHostKeyChecking=yes (not accept-new): unknown SSH hosts fail closed
 799   * with a clear message rather than being silently trusted on first contact. For
 800   * the github source type, the preflight check routes unknown-host users to HTTPS
 801   * automatically; for explicit git@host:… URLs, users see an actionable error.
 802   */
 803  export async function gitClone(
 804    gitUrl: string,
 805    targetPath: string,
 806    ref?: string,
 807    sparsePaths?: string[],
 808  ): Promise<{ code: number; stderr: string }> {
 809    const useSparse = sparsePaths && sparsePaths.length > 0
 810    const args = [
 811      '-c',
 812      'core.sshCommand=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
 813      'clone',
 814      '--depth',
 815      '1',
 816    ]
 817  
 818    if (useSparse) {
 819      // Partial clone: skip blob download until checkout, defer checkout until
 820      // after sparse-checkout is configured. Submodules are intentionally dropped
 821      // for sparse clones — sparse monorepos rarely need them, and recursing
 822      // submodules would defeat the partial-clone bandwidth savings.
 823      args.push('--filter=blob:none', '--no-checkout')
 824    } else {
 825      args.push('--recurse-submodules', '--shallow-submodules')
 826    }
 827  
 828    if (ref) {
 829      args.push('--branch', ref)
 830    }
 831  
 832    args.push(gitUrl, targetPath)
 833  
 834    const timeoutMs = getPluginGitTimeoutMs()
 835    logForDebugging(
 836      `git clone: url=${redactUrlCredentials(gitUrl)} ref=${ref ?? 'default'} timeout=${timeoutMs}ms`,
 837    )
 838  
 839    const result = await execFileNoThrowWithCwd(gitExe(), args, {
 840      timeout: timeoutMs,
 841      stdin: 'ignore',
 842      env: { ...process.env, ...GIT_NO_PROMPT_ENV },
 843    })
 844  
 845    // Scrub credentials from execa's error/stderr fields before any logging or
 846    // returning. execa's shortMessage embeds the full command line (including
 847    // the credentialed URL), and result.stderr may also contain it on some git
 848    // versions.
 849    const redacted = redactUrlCredentials(gitUrl)
 850    if (gitUrl !== redacted) {
 851      if (result.error) result.error = result.error.replaceAll(gitUrl, redacted)
 852      if (result.stderr)
 853        result.stderr = result.stderr.replaceAll(gitUrl, redacted)
 854    }
 855  
 856    if (result.code === 0) {
 857      if (useSparse) {
 858        // Configure the sparse cone, then materialize only those paths.
 859        // `sparse-checkout set --cone` handles both init and path selection
 860        // in a single step on git >= 2.25.
 861        const sparseResult = await execFileNoThrowWithCwd(
 862          gitExe(),
 863          ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths],
 864          {
 865            cwd: targetPath,
 866            timeout: timeoutMs,
 867            stdin: 'ignore',
 868            env: { ...process.env, ...GIT_NO_PROMPT_ENV },
 869          },
 870        )
 871        if (sparseResult.code !== 0) {
 872          return {
 873            code: sparseResult.code,
 874            stderr: `git sparse-checkout set failed: ${sparseResult.stderr}`,
 875          }
 876        }
 877  
 878        const checkoutResult = await execFileNoThrowWithCwd(
 879          gitExe(),
 880          // ref was already passed to clone via --branch, so HEAD points to it;
 881          // if no ref, HEAD points to the remote's default branch.
 882          ['checkout', 'HEAD'],
 883          {
 884            cwd: targetPath,
 885            timeout: timeoutMs,
 886            stdin: 'ignore',
 887            env: { ...process.env, ...GIT_NO_PROMPT_ENV },
 888          },
 889        )
 890        if (checkoutResult.code !== 0) {
 891          return {
 892            code: checkoutResult.code,
 893            stderr: `git checkout after sparse-checkout failed: ${checkoutResult.stderr}`,
 894          }
 895        }
 896      }
 897      logForDebugging(`git clone succeeded: ${redactUrlCredentials(gitUrl)}`)
 898      return result
 899    }
 900  
 901    logForDebugging(
 902      `git clone failed: url=${redactUrlCredentials(gitUrl)} code=${result.code} error=${result.error ?? 'none'} stderr=${result.stderr}`,
 903      { level: 'warn' },
 904    )
 905  
 906    // Detect timeout kills — when execFileNoThrowWithCwd kills the process via SIGTERM,
 907    // stderr may only contain partial output (e.g. "Cloning into '...'") with no
 908    // "timed out" string. Check the error field from execa which contains the
 909    // timeout message.
 910    if (result.error?.includes('timed out')) {
 911      return {
 912        ...result,
 913        stderr: `Git clone timed out after ${Math.round(timeoutMs / 1000)}s. The repository may be too large for the current timeout. Set CLAUDE_CODE_PLUGIN_GIT_TIMEOUT_MS to increase it (e.g., 300000 for 5 minutes).\n\nOriginal error: ${result.stderr}`,
 914      }
 915    }
 916  
 917    // Enhance error messages for common scenarios
 918    if (result.stderr) {
 919      // Host key verification failure — check FIRST, before the generic
 920      // 'Could not read from remote repository' catch (that string appears
 921      // in both stderr outputs, so order matters). OpenSSH emits
 922      // "Host key verification failed" for BOTH host-not-in-known_hosts and
 923      // host-key-has-changed; distinguish them by the key-change banner.
 924      if (result.stderr.includes('REMOTE HOST IDENTIFICATION HAS CHANGED')) {
 925        const host = extractSshHost(gitUrl)
 926        const removeHint = host ? `ssh-keygen -R ${host}` : 'ssh-keygen -R <host>'
 927        return {
 928          ...result,
 929          stderr: `SSH host key has changed (server key rotation or possible MITM). Remove the stale known_hosts entry:\n  ${removeHint}\nThen connect once manually to verify and accept the new key.\n\nOriginal error: ${result.stderr}`,
 930        }
 931      }
 932      if (result.stderr.includes('Host key verification failed')) {
 933        const host = extractSshHost(gitUrl)
 934        const connectHint = host ? `ssh -T git@${host}` : 'ssh -T git@<host>'
 935        return {
 936          ...result,
 937          stderr: `SSH host key is not in your known_hosts file. To add it, connect once manually (this will show the fingerprint for you to verify):\n  ${connectHint}\n\nOr use an HTTPS URL instead (recommended for public repos).\n\nOriginal error: ${result.stderr}`,
 938        }
 939      }
 940  
 941      if (
 942        result.stderr.includes('Permission denied (publickey)') ||
 943        result.stderr.includes('Could not read from remote repository')
 944      ) {
 945        return {
 946          ...result,
 947          stderr: `SSH authentication failed. Please ensure your SSH keys are configured for GitHub, or use an HTTPS URL instead.\n\nOriginal error: ${result.stderr}`,
 948        }
 949      }
 950  
 951      if (isAuthenticationError(result.stderr)) {
 952        return {
 953          ...result,
 954          stderr: `HTTPS authentication failed. Please ensure your credential helper is configured (e.g., gh auth login).\n\nOriginal error: ${result.stderr}`,
 955        }
 956      }
 957  
 958      if (
 959        result.stderr.includes('timed out') ||
 960        result.stderr.includes('timeout') ||
 961        result.stderr.includes('Could not resolve host')
 962      ) {
 963        return {
 964          ...result,
 965          stderr: `Network error or timeout while cloning repository. Please check your internet connection and try again.\n\nOriginal error: ${result.stderr}`,
 966        }
 967      }
 968    }
 969  
 970    // Fallback for empty stderr — gh-28373: user saw "Failed to clone
 971    // marketplace repository:" with nothing after the colon. Git CAN fail
 972    // without writing to stderr (stdout instead, or output swallowed by
 973    // credential helper / signal). execa's error field has the execa-level
 974    // message (command, exit code, signal); exit code is the minimum.
 975    if (!result.stderr) {
 976      return {
 977        code: result.code,
 978        stderr:
 979          result.error ||
 980          `git clone exited with code ${result.code} (no stderr output). Run with --debug to see the full command.`,
 981      }
 982    }
 983  
 984    return result
 985  }
 986  
 987  /**
 988   * Progress callback for marketplace operations.
 989   *
 990   * This callback is invoked at various stages during marketplace operations
 991   * (downloading, git operations, validation, etc.) to provide user feedback.
 992   *
 993   * IMPORTANT: Implementations should handle errors internally and not throw exceptions.
 994   * If a callback throws, it will be caught and logged but won't abort the operation.
 995   *
 996   * @param message - Human-readable progress message to display to the user
 997   */
 998  export type MarketplaceProgressCallback = (message: string) => void
 999  
1000  /**
1001   * Safely invoke a progress callback, catching and logging any errors.
1002   * Prevents callback errors from aborting marketplace operations.
1003   *
1004   * @param onProgress - The progress callback to invoke
1005   * @param message - Progress message to pass to the callback
1006   */
1007  function safeCallProgress(
1008    onProgress: MarketplaceProgressCallback | undefined,
1009    message: string,
1010  ): void {
1011    if (!onProgress) return
1012    try {
1013      onProgress(message)
1014    } catch (callbackError) {
1015      logForDebugging(`Progress callback error: ${errorMessage(callbackError)}`, {
1016        level: 'warn',
1017      })
1018    }
1019  }
1020  
1021  /**
1022   * Reconcile the on-disk sparse-checkout state with the desired config.
1023   *
1024   * Runs before gitPull to handle transitions:
1025   * - Full→Sparse or SparseA→SparseB: run `sparse-checkout set --cone` (idempotent)
1026   * - Sparse→Full: return non-zero so caller falls back to rm+reclone. Avoids
1027   *   `sparse-checkout disable` on a --filter=blob:none partial clone, which would
1028   *   trigger a lazy fetch of every blob in the monorepo.
1029   * - Full→Full (common case): single local `git config --get` check, no-op.
1030   *
1031   * Failures here (ENOENT, not a repo) are harmless — gitPull will also fail and
1032   * trigger the clone path, which establishes the correct state from scratch.
1033   */
1034  export async function reconcileSparseCheckout(
1035    cwd: string,
1036    sparsePaths: string[] | undefined,
1037  ): Promise<{ code: number; stderr: string }> {
1038    const env = { ...process.env, ...GIT_NO_PROMPT_ENV }
1039  
1040    if (sparsePaths && sparsePaths.length > 0) {
1041      return execFileNoThrowWithCwd(
1042        gitExe(),
1043        ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths],
1044        { cwd, timeout: getPluginGitTimeoutMs(), stdin: 'ignore', env },
1045      )
1046    }
1047  
1048    const check = await execFileNoThrowWithCwd(
1049      gitExe(),
1050      ['config', '--get', 'core.sparseCheckout'],
1051      { cwd, stdin: 'ignore', env },
1052    )
1053    if (check.code === 0 && check.stdout.trim() === 'true') {
1054      return {
1055        code: 1,
1056        stderr:
1057          'sparsePaths removed from config but repository is sparse; re-cloning for full checkout',
1058      }
1059    }
1060    return { code: 0, stderr: '' }
1061  }
1062  
1063  /**
1064   * Cache a marketplace from a git repository
1065   *
1066   * Clones or updates a git repository containing marketplace data.
1067   * If the repository already exists at cachePath, pulls the latest changes.
1068   * If pulling fails, removes the directory and re-clones.
1069   *
1070   * Example repository structure:
1071   * ```
1072   * my-marketplace/
1073   *   ├── .claude-plugin/
1074   *   │   └── marketplace.json    # Default location for marketplace manifest
1075   *   ├── plugins/                # Plugin implementations
1076   *   └── README.md
1077   * ```
1078   *
1079   * @param gitUrl - The git URL to clone (https or ssh)
1080   * @param cachePath - Local directory path to clone/update the repository
1081   * @param ref - Optional git branch or tag to checkout
1082   * @param onProgress - Optional callback to report progress
1083   */
1084  async function cacheMarketplaceFromGit(
1085    gitUrl: string,
1086    cachePath: string,
1087    ref?: string,
1088    sparsePaths?: string[],
1089    onProgress?: MarketplaceProgressCallback,
1090    options?: { disableCredentialHelper?: boolean },
1091  ): Promise<void> {
1092    const fs = getFsImplementation()
1093  
1094    // Attempt incremental update; fall back to re-clone if the repo is absent,
1095    // stale, or otherwise not updatable. Using pull-first avoids a stat-before-operate
1096    // TOCTOU check: gitPull returns non-zero when cachePath is missing or has no .git.
1097    const timeoutSec = Math.round(getPluginGitTimeoutMs() / 1000)
1098    safeCallProgress(
1099      onProgress,
1100      `Refreshing marketplace cache (timeout: ${timeoutSec}s)…`,
1101    )
1102  
1103    // Reconcile sparse-checkout config before pulling. If this requires a re-clone
1104    // (Sparse→Full transition) or fails (missing dir, not a repo), skip straight
1105    // to the rm+clone fallback.
1106    const reconcileResult = await reconcileSparseCheckout(cachePath, sparsePaths)
1107    if (reconcileResult.code === 0) {
1108      const pullStarted = performance.now()
1109      const pullResult = await gitPull(cachePath, ref, {
1110        disableCredentialHelper: options?.disableCredentialHelper,
1111        sparsePaths,
1112      })
1113      logPluginFetch(
1114        'marketplace_pull',
1115        gitUrl,
1116        pullResult.code === 0 ? 'success' : 'failure',
1117        performance.now() - pullStarted,
1118        pullResult.code === 0 ? undefined : classifyFetchError(pullResult.stderr),
1119      )
1120      if (pullResult.code === 0) return
1121      logForDebugging(`git pull failed, will re-clone: ${pullResult.stderr}`, {
1122        level: 'warn',
1123      })
1124    } else {
1125      logForDebugging(
1126        `sparse-checkout reconcile requires re-clone: ${reconcileResult.stderr}`,
1127      )
1128    }
1129  
1130    try {
1131      await fs.rm(cachePath, { recursive: true })
1132      // rm succeeded — a stale or partially-cloned directory existed; log for diagnostics
1133      logForDebugging(
1134        `Found stale marketplace directory at ${cachePath}, cleaning up to allow re-clone`,
1135        { level: 'warn' },
1136      )
1137      safeCallProgress(
1138        onProgress,
1139        'Found stale directory, cleaning up and re-cloning…',
1140      )
1141    } catch (rmError) {
1142      if (!isENOENT(rmError)) {
1143        const rmErrorMsg = errorMessage(rmError)
1144        throw new Error(
1145          `Failed to clean up existing marketplace directory. Please manually delete the directory at ${cachePath} and try again.\n\nTechnical details: ${rmErrorMsg}`,
1146        )
1147      }
1148      // ENOENT — cachePath didn't exist, this is a fresh install, nothing to clean up
1149    }
1150  
1151    // Clone the repository (one attempt — no internal retry loop)
1152    const refMessage = ref ? ` (ref: ${ref})` : ''
1153    safeCallProgress(
1154      onProgress,
1155      `Cloning repository (timeout: ${timeoutSec}s): ${redactUrlCredentials(gitUrl)}${refMessage}`,
1156    )
1157    const cloneStarted = performance.now()
1158    const result = await gitClone(gitUrl, cachePath, ref, sparsePaths)
1159    logPluginFetch(
1160      'marketplace_clone',
1161      gitUrl,
1162      result.code === 0 ? 'success' : 'failure',
1163      performance.now() - cloneStarted,
1164      result.code === 0 ? undefined : classifyFetchError(result.stderr),
1165    )
1166    if (result.code !== 0) {
1167      // Clean up any partial directory created by the failed clone so the next
1168      // attempt starts fresh. Best-effort: if this fails, the stale dir will be
1169      // auto-detected and removed at the top of the next call.
1170      try {
1171        await fs.rm(cachePath, { recursive: true, force: true })
1172      } catch {
1173        // ignore
1174      }
1175      throw new Error(`Failed to clone marketplace repository: ${result.stderr}`)
1176    }
1177    safeCallProgress(onProgress, 'Clone complete, validating marketplace…')
1178  }
1179  
1180  /**
1181   * Redact header values for safe logging
1182   *
1183   * @param headers - Headers to redact
1184   * @returns Headers with values replaced by '***REDACTED***'
1185   */
1186  function redactHeaders(
1187    headers: Record<string, string>,
1188  ): Record<string, string> {
1189    return Object.fromEntries(
1190      Object.entries(headers).map(([key]) => [key, '***REDACTED***']),
1191    )
1192  }
1193  
1194  /**
1195   * Redact userinfo (username:password) in a URL to avoid logging credentials.
1196   *
1197   * Marketplace URLs may embed credentials (e.g. GitHub PATs in
1198   * `https://user:token@github.com/org/repo`). Debug logs and progress output
1199   * are written to disk and may be included in bug reports, so credentials must
1200   * be redacted before logging.
1201   *
1202   * Redacts all credentials from http(s) URLs:
1203   *   https://user:token@github.com/repo → https://***:***@github.com/repo
1204   *   https://:token@github.com/repo     → https://:***@github.com/repo
1205   *   https://token@github.com/repo      → https://***@github.com/repo
1206   *
1207   * Both username and password are redacted unconditionally on http(s) because
1208   * it is impossible to distinguish `placeholder:secret` (e.g. x-access-token:ghp_...)
1209   * from `secret:placeholder` (e.g. ghp_...:x-oauth-basic) by parsing alone.
1210   * Non-http(s) schemes (ssh://git@...) and non-URL inputs (`owner/repo` shorthand)
1211   * pass through unchanged.
1212   */
1213  function redactUrlCredentials(urlString: string): string {
1214    try {
1215      const parsed = new URL(urlString)
1216      const isHttp = parsed.protocol === 'http:' || parsed.protocol === 'https:'
1217      if (isHttp && (parsed.username || parsed.password)) {
1218        if (parsed.username) parsed.username = '***'
1219        if (parsed.password) parsed.password = '***'
1220        return parsed.toString()
1221      }
1222    } catch {
1223      // Not a valid URL — safe as-is
1224    }
1225    return urlString
1226  }
1227  
1228  /**
1229   * Cache a marketplace from a URL
1230   *
1231   * Downloads a marketplace.json file from a URL and saves it locally.
1232   * Creates the cache directory structure if it doesn't exist.
1233   *
1234   * Example marketplace.json structure:
1235   * ```json
1236   * {
1237   *   "name": "my-marketplace",
1238   *   "owner": { "name": "John Doe", "email": "john@example.com" },
1239   *   "plugins": [
1240   *     {
1241   *       "id": "my-plugin",
1242   *       "name": "My Plugin",
1243   *       "source": "./plugins/my-plugin.json",
1244   *       "category": "productivity",
1245   *       "description": "A helpful plugin"
1246   *     }
1247   *   ]
1248   * }
1249   * ```
1250   *
1251   * @param url - The URL to download the marketplace.json from
1252   * @param cachePath - Local file path to save the downloaded marketplace
1253   * @param customHeaders - Optional custom HTTP headers for authentication
1254   * @param onProgress - Optional callback to report progress
1255   */
1256  async function cacheMarketplaceFromUrl(
1257    url: string,
1258    cachePath: string,
1259    customHeaders?: Record<string, string>,
1260    onProgress?: MarketplaceProgressCallback,
1261  ): Promise<void> {
1262    const fs = getFsImplementation()
1263  
1264    const redactedUrl = redactUrlCredentials(url)
1265    safeCallProgress(onProgress, `Downloading marketplace from ${redactedUrl}`)
1266    logForDebugging(`Downloading marketplace from URL: ${redactedUrl}`)
1267    if (customHeaders && Object.keys(customHeaders).length > 0) {
1268      logForDebugging(
1269        `Using custom headers: ${jsonStringify(redactHeaders(customHeaders))}`,
1270      )
1271    }
1272  
1273    const headers = {
1274      ...customHeaders,
1275      // User-Agent must come last to prevent override (for consistency with WebFetch)
1276      'User-Agent': 'Claude-Code-Plugin-Manager',
1277    }
1278  
1279    let response
1280    const fetchStarted = performance.now()
1281    try {
1282      response = await axios.get(url, {
1283        timeout: 10000,
1284        headers,
1285      })
1286    } catch (error) {
1287      logPluginFetch(
1288        'marketplace_url',
1289        url,
1290        'failure',
1291        performance.now() - fetchStarted,
1292        classifyFetchError(error),
1293      )
1294      if (axios.isAxiosError(error)) {
1295        if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
1296          throw new Error(
1297            `Could not connect to ${redactedUrl}. Please check your internet connection and verify the URL is correct.\n\nTechnical details: ${error.message}`,
1298          )
1299        }
1300        if (error.code === 'ETIMEDOUT') {
1301          throw new Error(
1302            `Request timed out while downloading marketplace from ${redactedUrl}. The server may be slow or unreachable.\n\nTechnical details: ${error.message}`,
1303          )
1304        }
1305        if (error.response) {
1306          throw new Error(
1307            `HTTP ${error.response.status} error while downloading marketplace from ${redactedUrl}. The marketplace file may not exist at this URL.\n\nTechnical details: ${error.message}`,
1308          )
1309        }
1310      }
1311      throw new Error(
1312        `Failed to download marketplace from ${redactedUrl}: ${errorMessage(error)}`,
1313      )
1314    }
1315  
1316    safeCallProgress(onProgress, 'Validating marketplace data')
1317    // Validate the response is a valid marketplace
1318    const result = PluginMarketplaceSchema().safeParse(response.data)
1319    if (!result.success) {
1320      logPluginFetch(
1321        'marketplace_url',
1322        url,
1323        'failure',
1324        performance.now() - fetchStarted,
1325        'invalid_schema',
1326      )
1327      throw new ConfigParseError(
1328        `Invalid marketplace schema from URL: ${result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
1329        redactedUrl,
1330        response.data,
1331      )
1332    }
1333    logPluginFetch(
1334      'marketplace_url',
1335      url,
1336      'success',
1337      performance.now() - fetchStarted,
1338    )
1339  
1340    safeCallProgress(onProgress, 'Saving marketplace to cache')
1341    // Ensure cache directory exists
1342    const cacheDir = join(cachePath, '..')
1343    await fs.mkdir(cacheDir)
1344  
1345    // Write the validated marketplace file
1346    writeFileSync_DEPRECATED(cachePath, jsonStringify(result.data, null, 2), {
1347      encoding: 'utf-8',
1348      flush: true,
1349    })
1350  }
1351  
1352  /**
1353   * Generate a cache path for a marketplace source
1354   */
1355  function getCachePathForSource(source: MarketplaceSource): string {
1356    const tempName =
1357      source.source === 'github'
1358        ? source.repo.replace('/', '-')
1359        : source.source === 'npm'
1360          ? source.package.replace('@', '').replace('/', '-')
1361          : source.source === 'file'
1362            ? basename(source.path).replace('.json', '')
1363            : source.source === 'directory'
1364              ? basename(source.path)
1365              : 'temp_' + Date.now()
1366    return tempName
1367  }
1368  
1369  /**
1370   * Parse and validate JSON file with a Zod schema
1371   */
1372  async function parseFileWithSchema<T>(
1373    filePath: string,
1374    schema: {
1375      safeParse: (data: unknown) => {
1376        success: boolean
1377        data?: T
1378        error?: {
1379          issues: Array<{ path: PropertyKey[]; message: string }>
1380        }
1381      }
1382    },
1383  ): Promise<T> {
1384    const fs = getFsImplementation()
1385    const content = await fs.readFile(filePath, { encoding: 'utf-8' })
1386    let data: unknown
1387    try {
1388      data = jsonParse(content)
1389    } catch (error) {
1390      throw new ConfigParseError(
1391        `Invalid JSON in ${filePath}: ${errorMessage(error)}`,
1392        filePath,
1393        content,
1394      )
1395    }
1396    const result = schema.safeParse(data)
1397    if (!result.success) {
1398      throw new ConfigParseError(
1399        `Invalid schema: ${filePath} ${result.error?.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
1400        filePath,
1401        data,
1402      )
1403    }
1404    return result.data!
1405  }
1406  
1407  /**
1408   * Load and cache a marketplace from its source
1409   *
1410   * Handles different source types:
1411   * - URL: Downloads marketplace.json directly
1412   * - GitHub: Clones repo and looks for .claude-plugin/marketplace.json
1413   * - Git: Clones repository from git URL
1414   * - NPM: (Not yet implemented) Would fetch from npm package
1415   * - File: Reads from local filesystem
1416   *
1417   * After loading, validates the marketplace schema and renames the cache
1418   * to match the marketplace's actual name from the manifest.
1419   *
1420   * Cache structure:
1421   * ~/.claude/plugins/marketplaces/
1422   *   ├── official-marketplace.json     # From URL source
1423   *   ├── github-marketplace/          # From GitHub/Git source
1424   *   │   └── .claude-plugin/
1425   *   │       └── marketplace.json
1426   *   └── local-marketplace.json       # From file source
1427   *
1428   * @param source - The marketplace source to load from
1429   * @param onProgress - Optional callback to report progress
1430   * @returns Object containing the validated marketplace and its cache path
1431   * @throws If marketplace file not found or validation fails
1432   */
1433  async function loadAndCacheMarketplace(
1434    source: MarketplaceSource,
1435    onProgress?: MarketplaceProgressCallback,
1436  ): Promise<LoadedPluginMarketplace> {
1437    const fs = getFsImplementation()
1438    const cacheDir = getMarketplacesCacheDir()
1439  
1440    // Ensure cache directory exists
1441    await fs.mkdir(cacheDir)
1442  
1443    let temporaryCachePath: string
1444    let marketplacePath: string
1445    let cleanupNeeded = false
1446  
1447    // Generate a temp name for the cache path
1448    const tempName = getCachePathForSource(source)
1449  
1450    try {
1451      switch (source.source) {
1452        case 'url': {
1453          // Direct URL to marketplace.json
1454          temporaryCachePath = join(cacheDir, `${tempName}.json`)
1455          cleanupNeeded = true
1456          await cacheMarketplaceFromUrl(
1457            source.url,
1458            temporaryCachePath,
1459            source.headers,
1460            onProgress,
1461          )
1462          marketplacePath = temporaryCachePath
1463          break
1464        }
1465  
1466        case 'github': {
1467          // Smart SSH/HTTPS selection: check if SSH is configured before trying it
1468          // This avoids waiting for timeout on SSH when it's not configured
1469          const sshUrl = `git@github.com:${source.repo}.git`
1470          const httpsUrl = `https://github.com/${source.repo}.git`
1471          temporaryCachePath = join(cacheDir, tempName)
1472          cleanupNeeded = true
1473  
1474          let lastError: Error | null = null
1475  
1476          // Quick check if SSH is likely to work
1477          const sshConfigured = await isGitHubSshLikelyConfigured()
1478  
1479          if (sshConfigured) {
1480            // SSH looks good, try it first
1481            safeCallProgress(onProgress, `Cloning via SSH: ${sshUrl}`)
1482            try {
1483              await cacheMarketplaceFromGit(
1484                sshUrl,
1485                temporaryCachePath,
1486                source.ref,
1487                source.sparsePaths,
1488                onProgress,
1489              )
1490            } catch (err) {
1491              lastError = toError(err)
1492  
1493              // Log SSH failure for monitoring
1494              logError(lastError)
1495  
1496              // SSH failed despite being configured, try HTTPS fallback
1497              safeCallProgress(
1498                onProgress,
1499                `SSH clone failed, retrying with HTTPS: ${httpsUrl}`,
1500              )
1501  
1502              logForDebugging(
1503                `SSH clone failed for ${source.repo} despite SSH being configured, falling back to HTTPS`,
1504                { level: 'info' },
1505              )
1506  
1507              // Clean up failed SSH attempt if it created anything
1508              await fs.rm(temporaryCachePath, { recursive: true, force: true })
1509  
1510              // Try HTTPS
1511              try {
1512                await cacheMarketplaceFromGit(
1513                  httpsUrl,
1514                  temporaryCachePath,
1515                  source.ref,
1516                  source.sparsePaths,
1517                  onProgress,
1518                )
1519                lastError = null // Success!
1520              } catch (httpsErr) {
1521                // HTTPS also failed - use HTTPS error as the final error
1522                lastError = toError(httpsErr)
1523  
1524                // Log HTTPS failure for monitoring (both SSH and HTTPS failed)
1525                logError(lastError)
1526              }
1527            }
1528          } else {
1529            // SSH not configured, go straight to HTTPS
1530            safeCallProgress(
1531              onProgress,
1532              `SSH not configured, cloning via HTTPS: ${httpsUrl}`,
1533            )
1534  
1535            logForDebugging(
1536              `SSH not configured for GitHub, using HTTPS for ${source.repo}`,
1537              { level: 'info' },
1538            )
1539  
1540            try {
1541              await cacheMarketplaceFromGit(
1542                httpsUrl,
1543                temporaryCachePath,
1544                source.ref,
1545                source.sparsePaths,
1546                onProgress,
1547              )
1548            } catch (err) {
1549              lastError = toError(err)
1550  
1551              // Always try SSH as fallback for ANY HTTPS failure
1552              // Log HTTPS failure for monitoring
1553              logError(lastError)
1554  
1555              // HTTPS failed, try SSH as fallback
1556              safeCallProgress(
1557                onProgress,
1558                `HTTPS clone failed, retrying with SSH: ${sshUrl}`,
1559              )
1560  
1561              logForDebugging(
1562                `HTTPS clone failed for ${source.repo} (${lastError.message}), falling back to SSH`,
1563                { level: 'info' },
1564              )
1565  
1566              // Clean up failed HTTPS attempt if it created anything
1567              await fs.rm(temporaryCachePath, { recursive: true, force: true })
1568  
1569              // Try SSH
1570              try {
1571                await cacheMarketplaceFromGit(
1572                  sshUrl,
1573                  temporaryCachePath,
1574                  source.ref,
1575                  source.sparsePaths,
1576                  onProgress,
1577                )
1578                lastError = null // Success!
1579              } catch (sshErr) {
1580                // SSH also failed - use SSH error as the final error
1581                lastError = toError(sshErr)
1582  
1583                // Log SSH failure for monitoring (both HTTPS and SSH failed)
1584                logError(lastError)
1585              }
1586            }
1587          }
1588  
1589          // If we still have an error, throw it
1590          if (lastError) {
1591            throw lastError
1592          }
1593  
1594          marketplacePath = join(
1595            temporaryCachePath,
1596            source.path || '.claude-plugin/marketplace.json',
1597          )
1598          break
1599        }
1600  
1601        case 'git': {
1602          temporaryCachePath = join(cacheDir, tempName)
1603          cleanupNeeded = true
1604          await cacheMarketplaceFromGit(
1605            source.url,
1606            temporaryCachePath,
1607            source.ref,
1608            source.sparsePaths,
1609            onProgress,
1610          )
1611          marketplacePath = join(
1612            temporaryCachePath,
1613            source.path || '.claude-plugin/marketplace.json',
1614          )
1615          break
1616        }
1617  
1618        case 'npm': {
1619          // TODO: Implement npm package support
1620          throw new Error('NPM marketplace sources not yet implemented')
1621        }
1622  
1623        case 'file': {
1624          // For local files, resolve paths relative to marketplace root directory
1625          // File sources point to .claude-plugin/marketplace.json, so the marketplace
1626          // root is two directories up (parent of .claude-plugin/)
1627          // Resolve to absolute so error messages show the actual path checked
1628          // (legacy known_marketplaces.json entries may have relative paths)
1629          const absPath = resolve(source.path)
1630          marketplacePath = absPath
1631          temporaryCachePath = dirname(dirname(absPath))
1632          cleanupNeeded = false
1633          break
1634        }
1635  
1636        case 'directory': {
1637          // For directories, look for .claude-plugin/marketplace.json
1638          // Resolve to absolute so error messages show the actual path checked
1639          // (legacy known_marketplaces.json entries may have relative paths)
1640          const absPath = resolve(source.path)
1641          marketplacePath = join(absPath, '.claude-plugin', 'marketplace.json')
1642          temporaryCachePath = absPath
1643          cleanupNeeded = false
1644          break
1645        }
1646  
1647        case 'settings': {
1648          // Inline manifest from settings.json — no fetch. Synthesize the
1649          // marketplace.json on disk so getMarketplaceCacheOnly reads it
1650          // like any other source. The plugins array already passed
1651          // PluginMarketplaceEntrySchema validation when settings were parsed;
1652          // the post-switch parseFileWithSchema re-validates the full
1653          // PluginMarketplaceSchema (catches schema drift between the two).
1654          //
1655          // Writing to source.name up front means the rename below is a no-op
1656          // (temporaryCachePath === finalCachePath). known_marketplaces.json
1657          // stores this source object including the plugins array, so
1658          // diffMarketplaces detects settings edits via isEqual — no special
1659          // dirty-tracking needed.
1660          temporaryCachePath = join(cacheDir, source.name)
1661          marketplacePath = join(
1662            temporaryCachePath,
1663            '.claude-plugin',
1664            'marketplace.json',
1665          )
1666          cleanupNeeded = false
1667          await fs.mkdir(dirname(marketplacePath))
1668          // No `satisfies PluginMarketplace` here: source.plugins is the narrow
1669          // SettingsMarketplacePlugin type (no strict/.default(), no manifest
1670          // fields). The parseFileWithSchema(PluginMarketplaceSchema()) call
1671          // below widens and validates — that's the real check.
1672          await writeFile(
1673            marketplacePath,
1674            jsonStringify(
1675              {
1676                name: source.name,
1677                owner: source.owner ?? { name: 'settings' },
1678                plugins: source.plugins,
1679              },
1680              null,
1681              2,
1682            ),
1683          )
1684          break
1685        }
1686  
1687        default:
1688          throw new Error(`Unsupported marketplace source type`)
1689      }
1690  
1691      // Load and validate the marketplace
1692      logForDebugging(`Reading marketplace from ${marketplacePath}`)
1693      let marketplace: PluginMarketplace
1694      try {
1695        marketplace = await parseFileWithSchema(
1696          marketplacePath,
1697          PluginMarketplaceSchema(),
1698        )
1699      } catch (e) {
1700        if (isENOENT(e)) {
1701          throw new Error(`Marketplace file not found at ${marketplacePath}`)
1702        }
1703        throw new Error(
1704          `Failed to parse marketplace file at ${marketplacePath}: ${errorMessage(e)}`,
1705        )
1706      }
1707  
1708      // Now rename the cache path to use the marketplace's actual name
1709      const finalCachePath = join(cacheDir, marketplace.name)
1710      // Defense-in-depth: the schema rejects path separators, .., and . in marketplace.name,
1711      // but verify the computed path is a strict subdirectory of cacheDir before fs.rm.
1712      // A malicious marketplace.json with a crafted name must never cause us to rm outside
1713      // cacheDir, nor rm cacheDir itself (e.g. name "." → join normalizes to cacheDir).
1714      const resolvedFinal = resolve(finalCachePath)
1715      const resolvedCacheDir = resolve(cacheDir)
1716      if (!resolvedFinal.startsWith(resolvedCacheDir + sep)) {
1717        throw new Error(
1718          `Marketplace name '${marketplace.name}' resolves to a path outside the cache directory`,
1719        )
1720      }
1721      // Don't rename if it's a local file or directory, or already has the right name
1722      if (
1723        temporaryCachePath !== finalCachePath &&
1724        !isLocalMarketplaceSource(source)
1725      ) {
1726        try {
1727          // Remove the destination if it already exists, then rename
1728          try {
1729            onProgress?.('Cleaning up old marketplace cache…')
1730          } catch (callbackError) {
1731            logForDebugging(
1732              `Progress callback error: ${errorMessage(callbackError)}`,
1733              { level: 'warn' },
1734            )
1735          }
1736          await fs.rm(finalCachePath, { recursive: true, force: true })
1737          // Rename temp cache to final name
1738          await fs.rename(temporaryCachePath, finalCachePath)
1739          temporaryCachePath = finalCachePath
1740          cleanupNeeded = false // Successfully renamed, no cleanup needed
1741        } catch (error) {
1742          const errorMsg = errorMessage(error)
1743          throw new Error(
1744            `Failed to finalize marketplace cache. Please manually delete the directory at ${finalCachePath} if it exists and try again.\n\nTechnical details: ${errorMsg}`,
1745          )
1746        }
1747      }
1748  
1749      return { marketplace, cachePath: temporaryCachePath }
1750    } catch (error) {
1751      // Clean up any temporary files/directories on error
1752      if (
1753        cleanupNeeded &&
1754        temporaryCachePath! &&
1755        !isLocalMarketplaceSource(source)
1756      ) {
1757        try {
1758          await fs.rm(temporaryCachePath!, { recursive: true, force: true })
1759        } catch (cleanupError) {
1760          logForDebugging(
1761            `Warning: Failed to clean up temporary marketplace cache at ${temporaryCachePath}: ${errorMessage(cleanupError)}`,
1762            { level: 'warn' },
1763          )
1764        }
1765      }
1766      throw error
1767    }
1768  }
1769  
1770  /**
1771   * Add a marketplace source to the known marketplaces
1772   *
1773   * The marketplace is fetched, validated, and cached locally.
1774   * The configuration is saved to ~/.claude/plugins/known_marketplaces.json.
1775   *
1776   * @param source - MarketplaceSource object representing the marketplace source.
1777   *                 Callers should parse user input into MarketplaceSource format
1778   *                 (see AddMarketplace.parseMarketplaceInput for handling shortcuts like "owner/repo").
1779   * @param onProgress - Optional callback for progress updates during marketplace installation
1780   * @throws If source format is invalid or marketplace cannot be loaded
1781   */
1782  export async function addMarketplaceSource(
1783    source: MarketplaceSource,
1784    onProgress?: MarketplaceProgressCallback,
1785  ): Promise<{
1786    name: string
1787    alreadyMaterialized: boolean
1788    resolvedSource: MarketplaceSource
1789  }> {
1790    // Resolve relative directory/file paths to absolute so state is cwd-independent
1791    let resolvedSource = source
1792    if (isLocalMarketplaceSource(source) && !isAbsolute(source.path)) {
1793      resolvedSource = { ...source, path: resolve(source.path) }
1794    }
1795  
1796    // Check policy FIRST, before any network/filesystem operations
1797    // This prevents downloading/cloning when the source is blocked
1798    if (!isSourceAllowedByPolicy(resolvedSource)) {
1799      // Check if explicitly blocked vs not in allowlist for better error messages
1800      if (isSourceInBlocklist(resolvedSource)) {
1801        throw new Error(
1802          `Marketplace source '${formatSourceForDisplay(resolvedSource)}' is blocked by enterprise policy.`,
1803        )
1804      }
1805      // Not in allowlist - build helpful error message
1806      const allowlist = getStrictKnownMarketplaces() || []
1807      const hostPatterns = getHostPatternsFromAllowlist()
1808      const sourceHost = extractHostFromSource(resolvedSource)
1809  
1810      let errorMessage = `Marketplace source '${formatSourceForDisplay(resolvedSource)}'`
1811      if (sourceHost) {
1812        errorMessage += ` (${sourceHost})`
1813      }
1814      errorMessage += ' is blocked by enterprise policy.'
1815  
1816      if (allowlist.length > 0) {
1817        errorMessage += ` Allowed sources: ${allowlist.map(s => formatSourceForDisplay(s)).join(', ')}`
1818      } else {
1819        errorMessage += ' No external marketplaces are allowed.'
1820      }
1821  
1822      // If source is a github shorthand and there are hostPatterns, suggest using full URL
1823      if (resolvedSource.source === 'github' && hostPatterns.length > 0) {
1824        errorMessage +=
1825          `\n\nTip: The shorthand "${resolvedSource.repo}" assumes github.com. ` +
1826          `For internal GitHub Enterprise, use the full URL:\n` +
1827          `  git@your-github-host.com:${resolvedSource.repo}.git`
1828      }
1829  
1830      throw new Error(errorMessage)
1831    }
1832  
1833    // Source-idempotency: if this exact source already exists, skip clone
1834    const existingConfig = await loadKnownMarketplacesConfig()
1835    for (const [existingName, existingEntry] of Object.entries(existingConfig)) {
1836      if (isEqual(existingEntry.source, resolvedSource)) {
1837        logForDebugging(
1838          `Source already materialized as '${existingName}', skipping clone`,
1839        )
1840        return { name: existingName, alreadyMaterialized: true, resolvedSource }
1841      }
1842    }
1843  
1844    // Load and cache the marketplace to validate it and get its name
1845    const { marketplace, cachePath } = await loadAndCacheMarketplace(
1846      resolvedSource,
1847      onProgress,
1848    )
1849  
1850    // Validate that reserved names come from official sources
1851    const sourceValidationError = validateOfficialNameSource(
1852      marketplace.name,
1853      resolvedSource,
1854    )
1855    if (sourceValidationError) {
1856      throw new Error(sourceValidationError)
1857    }
1858  
1859    // Name collision with different source: overwrite (settings intent wins).
1860    // Seed-managed entries are admin-controlled and cannot be overwritten.
1861    // Re-read config after clone (may take a while; another process may have written).
1862    const config = await loadKnownMarketplacesConfig()
1863    const oldEntry = config[marketplace.name]
1864    if (oldEntry) {
1865      const seedDir = seedDirFor(oldEntry.installLocation)
1866      if (seedDir) {
1867        throw new Error(
1868          `Marketplace '${marketplace.name}' is seed-managed (${seedDir}). ` +
1869            `To use a different source, ask your admin to update the seed, ` +
1870            `or use a different marketplace name.`,
1871        )
1872      }
1873      logForDebugging(
1874        `Marketplace '${marketplace.name}' exists with different source — overwriting`,
1875      )
1876      // Clean up the old cache if it's not a user-owned local path AND it
1877      // actually differs from the new cachePath. loadAndCacheMarketplace writes
1878      // to cachePath BEFORE we get here — rm-ing the same dir deletes the fresh
1879      // write. Settings sources always land on the same dir (name → path);
1880      // git sources hit this latently when the source repo changes but the
1881      // fetched marketplace.json declares the same name. Only rm when locations
1882      // genuinely differ (the only case where there's a stale dir to clean).
1883      //
1884      // Defensively validate the stored path before rm: a corrupted
1885      // installLocation (gh-32793, gh-32661) could point at the user's project
1886      // dir. If it's outside the cache dir, skip cleanup — the stale dir (if
1887      // any) is harmless, and blocking the re-add would prevent the user from
1888      // fixing the corruption.
1889      if (!isLocalMarketplaceSource(oldEntry.source)) {
1890        const cacheDir = resolve(getMarketplacesCacheDir())
1891        const resolvedOld = resolve(oldEntry.installLocation)
1892        const resolvedNew = resolve(cachePath)
1893        if (resolvedOld === resolvedNew) {
1894          // Same dir — loadAndCacheMarketplace already overwrote in place.
1895          // Nothing to clean.
1896        } else if (
1897          resolvedOld === cacheDir ||
1898          resolvedOld.startsWith(cacheDir + sep)
1899        ) {
1900          const fs = getFsImplementation()
1901          await fs.rm(oldEntry.installLocation, { recursive: true, force: true })
1902        } else {
1903          logForDebugging(
1904            `Skipping cleanup of old installLocation (${oldEntry.installLocation}) — ` +
1905              `outside ${cacheDir}. The path is corrupted; leaving it alone and ` +
1906              `overwriting the config entry.`,
1907            { level: 'warn' },
1908          )
1909        }
1910      }
1911    }
1912  
1913    // Update config using the marketplace's actual name
1914    config[marketplace.name] = {
1915      source: resolvedSource,
1916      installLocation: cachePath,
1917      lastUpdated: new Date().toISOString(),
1918    }
1919    await saveKnownMarketplacesConfig(config)
1920  
1921    logForDebugging(`Added marketplace source: ${marketplace.name}`)
1922  
1923    return { name: marketplace.name, alreadyMaterialized: false, resolvedSource }
1924  }
1925  
1926  /**
1927   * Remove a marketplace source from known marketplaces
1928   *
1929   * Removes the marketplace configuration and cleans up cached files.
1930   * Deletes both directory caches (for git sources) and file caches (for URL sources).
1931   * Also cleans up the marketplace from settings.json (extraKnownMarketplaces) and
1932   * removes related plugin entries from enabledPlugins.
1933   *
1934   * @param name - The marketplace name to remove
1935   * @throws If marketplace with given name is not found
1936   */
1937  export async function removeMarketplaceSource(name: string): Promise<void> {
1938    const config = await loadKnownMarketplacesConfig()
1939  
1940    if (!config[name]) {
1941      throw new Error(`Marketplace '${name}' not found`)
1942    }
1943  
1944    // Seed-registered marketplaces are admin-baked into the container — removing
1945    // them is a category error. They'd resurrect on next startup anyway. Guide
1946    // the user to the right action instead.
1947    const entry = config[name]
1948    const seedDir = seedDirFor(entry.installLocation)
1949    if (seedDir) {
1950      throw new Error(
1951        `Marketplace '${name}' is registered from the read-only seed directory ` +
1952          `(${seedDir}) and will be re-registered on next startup. ` +
1953          `To stop using its plugins: claude plugin disable <plugin>@${name}`,
1954      )
1955    }
1956  
1957    // Remove from config
1958    delete config[name]
1959    await saveKnownMarketplacesConfig(config)
1960  
1961    // Clean up cached files (both directory and JSON formats)
1962    const fs = getFsImplementation()
1963    const cacheDir = getMarketplacesCacheDir()
1964    const cachePath = join(cacheDir, name)
1965    await fs.rm(cachePath, { recursive: true, force: true })
1966    const jsonCachePath = join(cacheDir, `${name}.json`)
1967    await fs.rm(jsonCachePath, { force: true })
1968  
1969    // Clean up settings.json - remove marketplace from extraKnownMarketplaces
1970    // and remove related plugin entries from enabledPlugins
1971  
1972    // Check each editable settings source
1973    const editableSources: Array<
1974      'userSettings' | 'projectSettings' | 'localSettings'
1975    > = ['userSettings', 'projectSettings', 'localSettings']
1976  
1977    for (const source of editableSources) {
1978      const settings = getSettingsForSource(source)
1979      if (!settings) continue
1980  
1981      let needsUpdate = false
1982      const updates: {
1983        extraKnownMarketplaces?: typeof settings.extraKnownMarketplaces
1984        enabledPlugins?: typeof settings.enabledPlugins
1985      } = {}
1986  
1987      // Remove from extraKnownMarketplaces if present
1988      if (settings.extraKnownMarketplaces?.[name]) {
1989        const updatedMarketplaces: Partial<
1990          SettingsJson['extraKnownMarketplaces']
1991        > = { ...settings.extraKnownMarketplaces }
1992        // Use undefined values (NOT delete) to signal key removal via mergeWith
1993        updatedMarketplaces[name] = undefined
1994        updates.extraKnownMarketplaces =
1995          updatedMarketplaces as SettingsJson['extraKnownMarketplaces']
1996        needsUpdate = true
1997      }
1998  
1999      // Remove related plugins from enabledPlugins (format: "plugin@marketplace")
2000      if (settings.enabledPlugins) {
2001        const marketplaceSuffix = `@${name}`
2002        const updatedPlugins = { ...settings.enabledPlugins }
2003        let removedPlugins = false
2004  
2005        for (const pluginId in updatedPlugins) {
2006          if (pluginId.endsWith(marketplaceSuffix)) {
2007            updatedPlugins[pluginId] = undefined
2008            removedPlugins = true
2009          }
2010        }
2011  
2012        if (removedPlugins) {
2013          updates.enabledPlugins = updatedPlugins
2014          needsUpdate = true
2015        }
2016      }
2017  
2018      // Update settings if changes were made
2019      if (needsUpdate) {
2020        const result = updateSettingsForSource(source, updates)
2021        if (result.error) {
2022          logError(result.error)
2023          logForDebugging(
2024            `Failed to clean up marketplace '${name}' from ${source} settings: ${result.error.message}`,
2025          )
2026        } else {
2027          logForDebugging(
2028            `Cleaned up marketplace '${name}' from ${source} settings`,
2029          )
2030        }
2031      }
2032    }
2033  
2034    // Remove plugins from installed_plugins.json and mark orphaned paths.
2035    // Also wipe their stored options/secrets — after marketplace removal
2036    // zero installations remain, same "last scope gone" condition as
2037    // uninstallPluginOp.
2038    const { orphanedPaths, removedPluginIds } =
2039      removeAllPluginsForMarketplace(name)
2040    for (const installPath of orphanedPaths) {
2041      await markPluginVersionOrphaned(installPath)
2042    }
2043    for (const pluginId of removedPluginIds) {
2044      deletePluginOptions(pluginId)
2045      await deletePluginDataDir(pluginId)
2046    }
2047  
2048    logForDebugging(`Removed marketplace source: ${name}`)
2049  }
2050  
2051  /**
2052   * Read a cached marketplace from disk without updating it
2053   *
2054   * @param installLocation - Path to the cached marketplace
2055   * @returns The marketplace object
2056   * @throws If marketplace file not found or invalid
2057   */
2058  async function readCachedMarketplace(
2059    installLocation: string,
2060  ): Promise<PluginMarketplace> {
2061    // For git-sourced directories, the manifest lives at .claude-plugin/marketplace.json.
2062    // For url/file/directory sources it is the installLocation itself.
2063    // Try the nested path first; fall back to installLocation when it is a plain file
2064    // (ENOTDIR) or the nested file is simply missing (ENOENT).
2065    const nestedPath = join(installLocation, '.claude-plugin', 'marketplace.json')
2066    try {
2067      return await parseFileWithSchema(nestedPath, PluginMarketplaceSchema())
2068    } catch (e) {
2069      if (e instanceof ConfigParseError) throw e
2070      const code = getErrnoCode(e)
2071      if (code !== 'ENOENT' && code !== 'ENOTDIR') throw e
2072    }
2073    return await parseFileWithSchema(installLocation, PluginMarketplaceSchema())
2074  }
2075  
2076  /**
2077   * Get a specific marketplace by name from cache only (no network).
2078   * Returns null if cache is missing or corrupted.
2079   * Use this for startup paths that should never block on network.
2080   */
2081  export async function getMarketplaceCacheOnly(
2082    name: string,
2083  ): Promise<PluginMarketplace | null> {
2084    const fs = getFsImplementation()
2085    const configFile = getKnownMarketplacesFile()
2086  
2087    try {
2088      const content = await fs.readFile(configFile, { encoding: 'utf-8' })
2089      const config = jsonParse(content) as KnownMarketplacesConfig
2090      const entry = config[name]
2091  
2092      if (!entry) {
2093        return null
2094      }
2095  
2096      return await readCachedMarketplace(entry.installLocation)
2097    } catch (error) {
2098      if (isENOENT(error)) {
2099        return null
2100      }
2101      logForDebugging(
2102        `Failed to read cached marketplace ${name}: ${errorMessage(error)}`,
2103        { level: 'warn' },
2104      )
2105      return null
2106    }
2107  }
2108  
2109  /**
2110   * Get a specific marketplace by name
2111   *
2112   * First attempts to read from cache. Only fetches from source if:
2113   * - No cached version exists
2114   * - Cache is invalid/corrupted
2115   *
2116   * This avoids unnecessary network/git operations on every access.
2117   * Use refreshMarketplace() to explicitly update from source.
2118   *
2119   * @param name - The marketplace name to fetch
2120   * @returns The marketplace object or null if not found/failed
2121   */
2122  export const getMarketplace = memoize(
2123    async (name: string): Promise<PluginMarketplace> => {
2124      const config = await loadKnownMarketplacesConfig()
2125      const entry = config[name]
2126  
2127      if (!entry) {
2128        throw new Error(
2129          `Marketplace '${name}' not found in configuration. Available marketplaces: ${Object.keys(config).join(', ')}`,
2130        )
2131      }
2132  
2133      // Legacy entries (pre-#19708) may have relative paths in global config.
2134      // These are meaningless outside the project that wrote them — resolving
2135      // against process.cwd() produces the wrong path. Give actionable guidance
2136      // instead of a misleading ENOENT.
2137      if (
2138        isLocalMarketplaceSource(entry.source) &&
2139        !isAbsolute(entry.source.path)
2140      ) {
2141        throw new Error(
2142          `Marketplace "${name}" has a relative source path (${entry.source.path}) ` +
2143            `in known_marketplaces.json — this is stale state from an older ` +
2144            `Claude Code version. Run 'claude marketplace remove ${name}' and ` +
2145            `re-add it from the original project directory.`,
2146        )
2147      }
2148  
2149      // Try to read from disk cache
2150      try {
2151        return await readCachedMarketplace(entry.installLocation)
2152      } catch (error) {
2153        // Log cache corruption before re-fetching
2154        logForDebugging(
2155          `Cache corrupted or missing for marketplace ${name}, re-fetching from source: ${errorMessage(error)}`,
2156          {
2157            level: 'warn',
2158          },
2159        )
2160      }
2161  
2162      // Cache doesn't exist or is invalid, fetch from source
2163      let marketplace: PluginMarketplace
2164      try {
2165        ;({ marketplace } = await loadAndCacheMarketplace(entry.source))
2166      } catch (error) {
2167        throw new Error(
2168          `Failed to load marketplace "${name}" from source (${entry.source.source}): ${errorMessage(error)}`,
2169        )
2170      }
2171  
2172      // Update lastUpdated only when we actually fetch
2173      config[name]!.lastUpdated = new Date().toISOString()
2174      await saveKnownMarketplacesConfig(config)
2175  
2176      return marketplace
2177    },
2178  )
2179  
2180  /**
2181   * Get plugin by ID from cache only (no network calls).
2182   * Returns null if marketplace cache is missing or corrupted.
2183   * Use this for startup paths that should never block on network.
2184   *
2185   * @param pluginId - The plugin ID in format "name@marketplace"
2186   * @returns The plugin entry or null if not found/cache missing
2187   */
2188  export async function getPluginByIdCacheOnly(pluginId: string): Promise<{
2189    entry: PluginMarketplaceEntry
2190    marketplaceInstallLocation: string
2191  } | null> {
2192    const { name: pluginName, marketplace: marketplaceName } =
2193      parsePluginIdentifier(pluginId)
2194    if (!pluginName || !marketplaceName) {
2195      return null
2196    }
2197  
2198    const fs = getFsImplementation()
2199    const configFile = getKnownMarketplacesFile()
2200  
2201    try {
2202      const content = await fs.readFile(configFile, { encoding: 'utf-8' })
2203      const config = jsonParse(content) as KnownMarketplacesConfig
2204      const marketplaceConfig = config[marketplaceName]
2205  
2206      if (!marketplaceConfig) {
2207        return null
2208      }
2209  
2210      const marketplace = await getMarketplaceCacheOnly(marketplaceName)
2211      if (!marketplace) {
2212        return null
2213      }
2214  
2215      const plugin = marketplace.plugins.find(p => p.name === pluginName)
2216      if (!plugin) {
2217        return null
2218      }
2219  
2220      return {
2221        entry: plugin,
2222        marketplaceInstallLocation: marketplaceConfig.installLocation,
2223      }
2224    } catch {
2225      return null
2226    }
2227  }
2228  
2229  /**
2230   * Get plugin by ID from a specific marketplace
2231   *
2232   * First tries cache-only lookup. If cache is missing/corrupted,
2233   * falls back to fetching from source.
2234   *
2235   * @param pluginId - The plugin ID in format "name@marketplace"
2236   * @returns The plugin entry or null if not found
2237   */
2238  export async function getPluginById(pluginId: string): Promise<{
2239    entry: PluginMarketplaceEntry
2240    marketplaceInstallLocation: string
2241  } | null> {
2242    // Try cache-only first (fast path)
2243    const cached = await getPluginByIdCacheOnly(pluginId)
2244    if (cached) {
2245      return cached
2246    }
2247  
2248    // Cache miss - try fetching from source
2249    const { name: pluginName, marketplace: marketplaceName } =
2250      parsePluginIdentifier(pluginId)
2251    if (!pluginName || !marketplaceName) {
2252      return null
2253    }
2254  
2255    try {
2256      const config = await loadKnownMarketplacesConfig()
2257      const marketplaceConfig = config[marketplaceName]
2258      if (!marketplaceConfig) {
2259        return null
2260      }
2261  
2262      const marketplace = await getMarketplace(marketplaceName)
2263      const plugin = marketplace.plugins.find(p => p.name === pluginName)
2264  
2265      if (!plugin) {
2266        return null
2267      }
2268  
2269      return {
2270        entry: plugin,
2271        marketplaceInstallLocation: marketplaceConfig.installLocation,
2272      }
2273    } catch (error) {
2274      logForDebugging(
2275        `Could not find plugin ${pluginId}: ${errorMessage(error)}`,
2276        { level: 'debug' },
2277      )
2278      return null
2279    }
2280  }
2281  
2282  /**
2283   * Refresh all marketplace caches
2284   *
2285   * Updates all configured marketplaces from their sources.
2286   * Continues refreshing even if some marketplaces fail.
2287   * Updates lastUpdated timestamps for successful refreshes.
2288   *
2289   * This is useful for:
2290   * - Periodic updates to get new plugins
2291   * - Syncing after network connectivity is restored
2292   * - Ensuring caches are up-to-date before browsing
2293   *
2294   * @returns Promise that resolves when all refresh attempts complete
2295   */
2296  export async function refreshAllMarketplaces(): Promise<void> {
2297    const config = await loadKnownMarketplacesConfig()
2298  
2299    for (const [name, entry] of Object.entries(config)) {
2300      // Seed-managed marketplaces are controlled by the seed image — refreshing
2301      // them is pointless (registerSeedMarketplaces overwrites on next startup).
2302      if (seedDirFor(entry.installLocation)) {
2303        logForDebugging(
2304          `Skipping seed-managed marketplace '${name}' in bulk refresh`,
2305        )
2306        continue
2307      }
2308      // settings-sourced marketplaces have no upstream — see refreshMarketplace.
2309      if (entry.source.source === 'settings') {
2310        continue
2311      }
2312      // inc-5046: same GCS intercept as refreshMarketplace() — bulk update
2313      // hits this path on `claude plugin marketplace update` (no name arg).
2314      if (name === OFFICIAL_MARKETPLACE_NAME) {
2315        const sha = await fetchOfficialMarketplaceFromGcs(
2316          entry.installLocation,
2317          getMarketplacesCacheDir(),
2318        )
2319        if (sha !== null) {
2320          config[name]!.lastUpdated = new Date().toISOString()
2321          continue
2322        }
2323        if (
2324          !getFeatureValue_CACHED_MAY_BE_STALE(
2325            'tengu_plugin_official_mkt_git_fallback',
2326            true,
2327          )
2328        ) {
2329          logForDebugging(
2330            `Skipping official marketplace bulk refresh: GCS failed, git fallback disabled`,
2331          )
2332          continue
2333        }
2334        // fall through to git
2335      }
2336      try {
2337        const { cachePath } = await loadAndCacheMarketplace(entry.source)
2338        config[name]!.lastUpdated = new Date().toISOString()
2339        config[name]!.installLocation = cachePath
2340      } catch (error) {
2341        logForDebugging(
2342          `Failed to refresh marketplace ${name}: ${errorMessage(error)}`,
2343          {
2344            level: 'error',
2345          },
2346        )
2347      }
2348    }
2349  
2350    await saveKnownMarketplacesConfig(config)
2351  }
2352  
2353  /**
2354   * Refresh a single marketplace cache
2355   *
2356   * Updates a specific marketplace from its source by doing an in-place update.
2357   * For git sources, runs git pull in the existing directory.
2358   * For URL sources, re-downloads to the existing file.
2359   * Clears the memoization cache and updates the lastUpdated timestamp.
2360   *
2361   * @param name - The name of the marketplace to refresh
2362   * @param onProgress - Optional callback to report progress
2363   * @throws If marketplace not found or refresh fails
2364   */
2365  export async function refreshMarketplace(
2366    name: string,
2367    onProgress?: MarketplaceProgressCallback,
2368    options?: { disableCredentialHelper?: boolean },
2369  ): Promise<void> {
2370    const config = await loadKnownMarketplacesConfig()
2371    const entry = config[name]
2372  
2373    if (!entry) {
2374      throw new Error(
2375        `Marketplace '${name}' not found. Available marketplaces: ${Object.keys(config).join(', ')}`,
2376      )
2377    }
2378  
2379    // Clear the memoization cache for this specific marketplace
2380    getMarketplace.cache?.delete?.(name)
2381  
2382    // settings-sourced marketplaces have no upstream to pull. Edits to the
2383    // inline plugins array surface as sourceChanged in the reconciler, which
2384    // re-materializes via addMarketplaceSource — refresh is not the vehicle.
2385    if (entry.source.source === 'settings') {
2386      logForDebugging(
2387        `Skipping refresh for settings-sourced marketplace '${name}' — no upstream`,
2388      )
2389      return
2390    }
2391  
2392    try {
2393      // For updates, use the existing installLocation directly (in-place update)
2394      const installLocation = entry.installLocation
2395      const source = entry.source
2396  
2397      // Seed-managed marketplaces are controlled by the seed image. Refreshing
2398      // would be pointless — registerSeedMarketplaces() overwrites installLocation
2399      // back to seed on next startup. Error with guidance instead.
2400      const seedDir = seedDirFor(installLocation)
2401      if (seedDir) {
2402        throw new Error(
2403          `Marketplace '${name}' is seed-managed (${seedDir}) and its content is ` +
2404            `controlled by the seed image. To update: ask your admin to update the seed.`,
2405        )
2406      }
2407  
2408      // For remote sources (github/git/url), installLocation must be inside the
2409      // marketplaces cache dir. A corrupted value (gh-32793, gh-32661 — e.g.
2410      // Windows path read on WSL, literal tilde, manual edit) can point at the
2411      // user's project. cacheMarketplaceFromGit would then run git ops with that
2412      // cwd (git walks up to the user's .git) and fs.rm it on pull failure.
2413      // Refuse instead of auto-fixing so the user knows their state is corrupted.
2414      if (!isLocalMarketplaceSource(source)) {
2415        const cacheDir = resolve(getMarketplacesCacheDir())
2416        const resolvedLoc = resolve(installLocation)
2417        if (resolvedLoc !== cacheDir && !resolvedLoc.startsWith(cacheDir + sep)) {
2418          throw new Error(
2419            `Marketplace '${name}' has a corrupted installLocation ` +
2420              `(${installLocation}) — expected a path inside ${cacheDir}. ` +
2421              `This can happen after cross-platform path writes or manual edits ` +
2422              `to known_marketplaces.json. ` +
2423              `Run: claude plugin marketplace remove "${name}" and re-add it.`,
2424          )
2425        }
2426      }
2427  
2428      // inc-5046: official marketplace fetches from a GCS mirror instead of
2429      // git-cloning GitHub. Special-cased by NAME (not a new source type) so
2430      // no data migration is needed — existing known_marketplaces.json entries
2431      // still say source:'github', which is true (GCS is a mirror).
2432      if (name === OFFICIAL_MARKETPLACE_NAME) {
2433        const sha = await fetchOfficialMarketplaceFromGcs(
2434          installLocation,
2435          getMarketplacesCacheDir(),
2436        )
2437        if (sha !== null) {
2438          config[name] = { ...entry, lastUpdated: new Date().toISOString() }
2439          await saveKnownMarketplacesConfig(config)
2440          return
2441        }
2442        // GCS failed — fall through to git ONLY if the kill-switch allows.
2443        // Default true (backend write perms are pending as of inc-5046); flip
2444        // to false via GrowthBook once the backend is confirmed live so new
2445        // clients NEVER hit GitHub for the official marketplace.
2446        if (
2447          !getFeatureValue_CACHED_MAY_BE_STALE(
2448            'tengu_plugin_official_mkt_git_fallback',
2449            true,
2450          )
2451        ) {
2452          // Throw, don't return — every other failure path in this function
2453          // throws, and callers like ManageMarketplaces.tsx:259 increment
2454          // updatedCount on any non-throwing return. A silent return would
2455          // report "Updated 1 marketplace" when nothing was refreshed.
2456          throw new Error(
2457            'Official marketplace GCS fetch failed and git fallback is disabled',
2458          )
2459        }
2460        logForDebugging('Official marketplace GCS failed; falling back to git', {
2461          level: 'warn',
2462        })
2463        // ...falls through to source.source === 'github' branch below
2464      }
2465  
2466      // Update based on source type
2467      if (source.source === 'github' || source.source === 'git') {
2468        // Git sources: do in-place git pull
2469        if (source.source === 'github') {
2470          // Same SSH/HTTPS fallback as loadAndCacheMarketplace: if the pull
2471          // succeeds the remote URL in .git/config is used, but a re-clone
2472          // needs a URL — pick the right protocol up-front and fall back.
2473          const sshUrl = `git@github.com:${source.repo}.git`
2474          const httpsUrl = `https://github.com/${source.repo}.git`
2475  
2476          if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) {
2477            // CCR: always HTTPS (no SSH keys available)
2478            await cacheMarketplaceFromGit(
2479              httpsUrl,
2480              installLocation,
2481              source.ref,
2482              source.sparsePaths,
2483              onProgress,
2484              options,
2485            )
2486          } else {
2487            const sshConfigured = await isGitHubSshLikelyConfigured()
2488            const primaryUrl = sshConfigured ? sshUrl : httpsUrl
2489            const fallbackUrl = sshConfigured ? httpsUrl : sshUrl
2490  
2491            try {
2492              await cacheMarketplaceFromGit(
2493                primaryUrl,
2494                installLocation,
2495                source.ref,
2496                source.sparsePaths,
2497                onProgress,
2498                options,
2499              )
2500            } catch {
2501              logForDebugging(
2502                `Marketplace refresh failed with ${sshConfigured ? 'SSH' : 'HTTPS'} for ${source.repo}, falling back to ${sshConfigured ? 'HTTPS' : 'SSH'}`,
2503                { level: 'info' },
2504              )
2505              await cacheMarketplaceFromGit(
2506                fallbackUrl,
2507                installLocation,
2508                source.ref,
2509                source.sparsePaths,
2510                onProgress,
2511                options,
2512              )
2513            }
2514          }
2515        } else {
2516          // Explicit git URL: use as-is (no fallback available)
2517          await cacheMarketplaceFromGit(
2518            source.url,
2519            installLocation,
2520            source.ref,
2521            source.sparsePaths,
2522            onProgress,
2523            options,
2524          )
2525        }
2526        // Validate that marketplace.json still exists after update
2527        // The repo may have been restructured or deprecated
2528        try {
2529          await readCachedMarketplace(installLocation)
2530        } catch {
2531          const sourceDisplay =
2532            source.source === 'github'
2533              ? source.repo
2534              : redactUrlCredentials(source.url)
2535          const reason =
2536            name === 'claude-code-plugins'
2537              ? `We've deprecated "claude-code-plugins" in favor of "claude-plugins-official".`
2538              : `This marketplace may have been deprecated or moved to a new location.`
2539          throw new Error(
2540            `The marketplace.json file is no longer present in this repository.\n\n` +
2541              `${reason}\n` +
2542              `Source: ${sourceDisplay}\n\n` +
2543              `You can remove this marketplace with: claude plugin marketplace remove "${name}"`,
2544          )
2545        }
2546      } else if (source.source === 'url') {
2547        // URL sources: re-download to existing file
2548        await cacheMarketplaceFromUrl(
2549          source.url,
2550          installLocation,
2551          source.headers,
2552          onProgress,
2553        )
2554      } else if (isLocalMarketplaceSource(source)) {
2555        // Local sources: no remote to update from, but validate the file still exists and is valid
2556        safeCallProgress(onProgress, 'Validating local marketplace')
2557        // Read and validate to ensure the marketplace file is still valid
2558        await readCachedMarketplace(installLocation)
2559      } else {
2560        throw new Error(`Unsupported marketplace source type for refresh`)
2561      }
2562  
2563      // Update lastUpdated timestamp
2564      config[name]!.lastUpdated = new Date().toISOString()
2565      await saveKnownMarketplacesConfig(config)
2566  
2567      logForDebugging(`Successfully refreshed marketplace: ${name}`)
2568    } catch (error) {
2569      const errorMessage = error instanceof Error ? error.message : String(error)
2570      logForDebugging(`Failed to refresh marketplace ${name}: ${errorMessage}`, {
2571        level: 'error',
2572      })
2573      throw new Error(`Failed to refresh marketplace '${name}': ${errorMessage}`)
2574    }
2575  }
2576  
2577  /**
2578   * Set the autoUpdate flag for a marketplace
2579   *
2580   * When autoUpdate is enabled, the marketplace and its installed plugins
2581   * will be automatically updated on startup.
2582   *
2583   * @param name - The name of the marketplace to update
2584   * @param autoUpdate - Whether to enable auto-update
2585   * @throws If marketplace not found
2586   */
2587  export async function setMarketplaceAutoUpdate(
2588    name: string,
2589    autoUpdate: boolean,
2590  ): Promise<void> {
2591    const config = await loadKnownMarketplacesConfig()
2592    const entry = config[name]
2593  
2594    if (!entry) {
2595      throw new Error(
2596        `Marketplace '${name}' not found. Available marketplaces: ${Object.keys(config).join(', ')}`,
2597      )
2598    }
2599  
2600    // Seed-managed marketplaces always have autoUpdate: false (read-only, git-pull
2601    // would fail). Toggle appears to work but registerSeedMarketplaces overwrites
2602    // it on next startup. Error with guidance instead of silent revert.
2603    const seedDir = seedDirFor(entry.installLocation)
2604    if (seedDir) {
2605      throw new Error(
2606        `Marketplace '${name}' is seed-managed (${seedDir}) and ` +
2607          `auto-update is always disabled for seed content. ` +
2608          `To update: ask your admin to update the seed.`,
2609      )
2610    }
2611  
2612    // Only update if the value is actually changing
2613    if (entry.autoUpdate === autoUpdate) {
2614      return
2615    }
2616  
2617    config[name] = {
2618      ...entry,
2619      autoUpdate,
2620    }
2621    await saveKnownMarketplacesConfig(config)
2622  
2623    // Also update intent in settings if declared there — write to the SAME
2624    // source that declared it to avoid creating duplicates at wrong scope
2625    const declaringSource = getMarketplaceDeclaringSource(name)
2626    if (declaringSource) {
2627      const declared =
2628        getSettingsForSource(declaringSource)?.extraKnownMarketplaces?.[name]
2629      if (declared) {
2630        saveMarketplaceToSettings(
2631          name,
2632          { source: declared.source, autoUpdate },
2633          declaringSource,
2634        )
2635      }
2636    }
2637  
2638    logForDebugging(`Set autoUpdate=${autoUpdate} for marketplace: ${name}`)
2639  }
2640  
2641  export const _test = {
2642    redactUrlCredentials,
2643  }