/ utils / plugins / schemas.ts
schemas.ts
   1  import { z } from 'zod/v4'
   2  import { HooksSchema } from '../../schemas/hooks.js'
   3  import { McpServerConfigSchema } from '../../services/mcp/types.js'
   4  import { lazySchema } from '../lazySchema.js'
   5  
   6  /**
   7   * First-layer defense against official marketplace impersonation.
   8   *
   9   * This validation blocks direct impersonation attempts like "anthropic-official",
  10   * "claude-marketplace", etc. Indirect variations (e.g., "my-claude-marketplace")
  11   * are not blocked intentionally to avoid false positives on legitimate names.
  12   * Source org verification provides additional protection at registration/install time.
  13   */
  14  
  15  /**
  16   * Official marketplace names that are reserved for Anthropic/Claude official use.
  17   * These names are allowed ONLY for official marketplaces and blocked for third parties.
  18   */
  19  export const ALLOWED_OFFICIAL_MARKETPLACE_NAMES = new Set([
  20    'claude-code-marketplace',
  21    'claude-code-plugins',
  22    'claude-plugins-official',
  23    'anthropic-marketplace',
  24    'anthropic-plugins',
  25    'agent-skills',
  26    'life-sciences',
  27    'knowledge-work-plugins',
  28  ])
  29  
  30  /**
  31   * Official marketplaces that should NOT auto-update by default.
  32   * These are still reserved/allowed names, but opt out of the auto-update
  33   * default that other official marketplaces receive.
  34   */
  35  const NO_AUTO_UPDATE_OFFICIAL_MARKETPLACES = new Set(['knowledge-work-plugins'])
  36  
  37  /**
  38   * Check if auto-update is enabled for a marketplace.
  39   * Uses the stored value if set, otherwise defaults based on whether
  40   * it's an official Anthropic marketplace (true) or not (false).
  41   * Official marketplaces in NO_AUTO_UPDATE_OFFICIAL_MARKETPLACES are excluded
  42   * from the auto-update default.
  43   *
  44   * @param marketplaceName - The name of the marketplace
  45   * @param entry - The marketplace entry (may have autoUpdate set)
  46   * @returns Whether auto-update is enabled for this marketplace
  47   */
  48  export function isMarketplaceAutoUpdate(
  49    marketplaceName: string,
  50    entry: { autoUpdate?: boolean },
  51  ): boolean {
  52    const normalizedName = marketplaceName.toLowerCase()
  53    return (
  54      entry.autoUpdate ??
  55      (ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(normalizedName) &&
  56        !NO_AUTO_UPDATE_OFFICIAL_MARKETPLACES.has(normalizedName))
  57    )
  58  }
  59  
  60  /**
  61   * Pattern to detect names that impersonate official Anthropic/Claude marketplaces.
  62   *
  63   * Matches names containing variations like:
  64   * - "official" combined with "anthropic" or "claude" (e.g., "official-claude-plugins")
  65   * - "anthropic" or "claude" combined with "official" (e.g., "claude-official")
  66   * - Names starting with "anthropic" or "claude" followed by official-sounding terms
  67   *   like "marketplace", "plugins" (e.g., "anthropic-marketplace-new", "claude-plugins-v2")
  68   *
  69   * The pattern is case-insensitive.
  70   */
  71  export const BLOCKED_OFFICIAL_NAME_PATTERN =
  72    /(?:official[^a-z0-9]*(anthropic|claude)|(?:anthropic|claude)[^a-z0-9]*official|^(?:anthropic|claude)[^a-z0-9]*(marketplace|plugins|official))/i
  73  
  74  /**
  75   * Pattern to detect non-ASCII characters that could be used for homograph attacks.
  76   * Marketplace names should only contain ASCII characters to prevent impersonation
  77   * via lookalike Unicode characters (e.g., Cyrillic 'а' instead of Latin 'a').
  78   */
  79  const NON_ASCII_PATTERN = /[^\u0020-\u007E]/
  80  
  81  /**
  82   * Check if a marketplace name impersonates an official Anthropic/Claude marketplace.
  83   *
  84   * @param name - The marketplace name to check
  85   * @returns true if the name is blocked (impersonates official), false if allowed
  86   */
  87  export function isBlockedOfficialName(name: string): boolean {
  88    // If it's in the allowed list, it's not blocked
  89    if (ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(name.toLowerCase())) {
  90      return false
  91    }
  92  
  93    // Block names with non-ASCII characters to prevent homograph attacks
  94    // (e.g., using Cyrillic 'а' to impersonate 'anthropic')
  95    if (NON_ASCII_PATTERN.test(name)) {
  96      return true
  97    }
  98  
  99    // Check if it matches the blocked pattern
 100    return BLOCKED_OFFICIAL_NAME_PATTERN.test(name)
 101  }
 102  
 103  /**
 104   * The official GitHub organization for Anthropic marketplaces.
 105   * Reserved names must come from this org.
 106   */
 107  export const OFFICIAL_GITHUB_ORG = 'anthropics'
 108  
 109  /**
 110   * Validate that a marketplace with a reserved name comes from the official source.
 111   *
 112   * Reserved names (in ALLOWED_OFFICIAL_MARKETPLACE_NAMES) can only be used by
 113   * marketplaces from the official Anthropic GitHub organization.
 114   *
 115   * @param name - The marketplace name
 116   * @param source - The marketplace source configuration
 117   * @returns An error message if validation fails, or null if valid
 118   */
 119  export function validateOfficialNameSource(
 120    name: string,
 121    source: { source: string; repo?: string; url?: string },
 122  ): string | null {
 123    const normalizedName = name.toLowerCase()
 124  
 125    // Only validate reserved names
 126    if (!ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(normalizedName)) {
 127      return null // Not a reserved name, no source validation needed
 128    }
 129  
 130    // Check for GitHub source type
 131    if (source.source === 'github') {
 132      // Verify the repo is from the official org
 133      const repo = source.repo || ''
 134      if (!repo.toLowerCase().startsWith(`${OFFICIAL_GITHUB_ORG}/`)) {
 135        return `The name '${name}' is reserved for official Anthropic marketplaces. Only repositories from 'github.com/${OFFICIAL_GITHUB_ORG}/' can use this name.`
 136      }
 137      return null // Valid: reserved name from official GitHub source
 138    }
 139  
 140    // Check for git URL source type
 141    if (source.source === 'git' && source.url) {
 142      const url = source.url.toLowerCase()
 143      // Check for HTTPS URL format: https://github.com/anthropics/...
 144      // or SSH format: git@github.com:anthropics/...
 145      const isHttpsAnthropics = url.includes('github.com/anthropics/')
 146      const isSshAnthropics = url.includes('git@github.com:anthropics/')
 147  
 148      if (isHttpsAnthropics || isSshAnthropics) {
 149        return null // Valid: reserved name from official git URL
 150      }
 151  
 152      return `The name '${name}' is reserved for official Anthropic marketplaces. Only repositories from 'github.com/${OFFICIAL_GITHUB_ORG}/' can use this name.`
 153    }
 154  
 155    // Reserved names must come from GitHub (either 'github' or 'git' source)
 156    return `The name '${name}' is reserved for official Anthropic marketplaces and can only be used with GitHub sources from the '${OFFICIAL_GITHUB_ORG}' organization.`
 157  }
 158  
 159  /**
 160   * Schema for relative file paths that must start with './'
 161   */
 162  const RelativePath = lazySchema(() => z.string().startsWith('./'))
 163  
 164  /**
 165   * Schema for relative paths to JSON files
 166   */
 167  const RelativeJSONPath = lazySchema(() => RelativePath().endsWith('.json'))
 168  
 169  /**
 170   * Schema for MCPB (MCP Bundle) file paths
 171   * Supports both local relative paths and remote URLs
 172   */
 173  const McpbPath = lazySchema(() =>
 174    z.union([
 175      RelativePath()
 176        .refine(path => path.endsWith('.mcpb') || path.endsWith('.dxt'), {
 177          message: 'MCPB file path must end with .mcpb or .dxt',
 178        })
 179        .describe('Path to MCPB file relative to plugin root'),
 180      z
 181        .string()
 182        .url()
 183        .refine(url => url.endsWith('.mcpb') || url.endsWith('.dxt'), {
 184          message: 'MCPB URL must end with .mcpb or .dxt',
 185        })
 186        .describe('URL to MCPB file'),
 187    ]),
 188  )
 189  
 190  /**
 191   * Schema for relative paths to Markdown files
 192   */
 193  const RelativeMarkdownPath = lazySchema(() => RelativePath().endsWith('.md'))
 194  
 195  /**
 196   * Schema for relative paths to command sources (markdown files or directories containing SKILL.md)
 197   */
 198  const RelativeCommandPath = lazySchema(() =>
 199    z.union([
 200      RelativeMarkdownPath(),
 201      RelativePath(), // Allow any relative path, including directories
 202    ]),
 203  )
 204  
 205  /**
 206   * Shared marketplace-name validation. Used by both PluginMarketplaceSchema
 207   * (validates fetched marketplace.json) and the settings arm of
 208   * MarketplaceSourceSchema (validates inline names in settings.json).
 209   *
 210   * The two must stay in sync: loadAndCacheMarketplace's case 'settings' writes
 211   * to join(cacheDir, source.name) BEFORE the post-write PluginMarketplaceSchema
 212   * validation runs. Any name that passes the settings arm but fails
 213   * PluginMarketplaceSchema leaves orphaned files in the cache (cleanupNeeded=false).
 214   * A single shared schema makes drift impossible.
 215   */
 216  const MarketplaceNameSchema = lazySchema(() =>
 217    z
 218      .string()
 219      .min(1, 'Marketplace must have a name')
 220      .refine(name => !name.includes(' '), {
 221        message:
 222          'Marketplace name cannot contain spaces. Use kebab-case (e.g., "my-marketplace")',
 223      })
 224      .refine(
 225        name =>
 226          !name.includes('/') &&
 227          !name.includes('\\') &&
 228          !name.includes('..') &&
 229          name !== '.',
 230        {
 231          message:
 232            'Marketplace name cannot contain path separators (/ or \\), ".." sequences, or be "."',
 233        },
 234      )
 235      .refine(name => !isBlockedOfficialName(name), {
 236        message:
 237          'Marketplace name impersonates an official Anthropic/Claude marketplace',
 238      })
 239      .refine(name => name.toLowerCase() !== 'inline', {
 240        message:
 241          'Marketplace name "inline" is reserved for --plugin-dir session plugins',
 242      })
 243      .refine(name => name.toLowerCase() !== 'builtin', {
 244        message: 'Marketplace name "builtin" is reserved for built-in plugins',
 245      }),
 246  )
 247  
 248  /**
 249   * Schema for plugin author information
 250   */
 251  export const PluginAuthorSchema = lazySchema(() =>
 252    z.object({
 253      name: z
 254        .string()
 255        .min(1, 'Author name cannot be empty')
 256        .describe('Display name of the plugin author or organization'),
 257      email: z
 258        .string()
 259        .optional()
 260        .describe('Contact email for support or feedback'),
 261      url: z
 262        .string()
 263        .optional()
 264        .describe('Website, GitHub profile, or organization URL'),
 265    }),
 266  )
 267  
 268  /**
 269   * Metadata part of the plugin manifest file (plugin.json)
 270   *
 271   * This schema validates the structure of plugin manifests and provides
 272   * runtime type checking when loading plugins from disk.
 273   */
 274  const PluginManifestMetadataSchema = lazySchema(() =>
 275    z.object({
 276      name: z
 277        .string()
 278        .min(1, 'Plugin name cannot be empty')
 279        .refine(name => !name.includes(' '), {
 280          message:
 281            'Plugin name cannot contain spaces. Use kebab-case (e.g., "my-plugin")',
 282        })
 283        .describe(
 284          'Unique identifier for the plugin, used for namespacing (prefer kebab-case)',
 285        ),
 286      version: z
 287        .string()
 288        .optional()
 289        .describe(
 290          'Semantic version (e.g., 1.2.3) following semver.org specification',
 291        ),
 292      description: z
 293        .string()
 294        .optional()
 295        .describe('Brief, user-facing explanation of what the plugin provides'),
 296      author: PluginAuthorSchema()
 297        .optional()
 298        .describe('Information about the plugin creator or maintainer'),
 299      homepage: z
 300        .string()
 301        .url()
 302        .optional()
 303        .describe('Plugin homepage or documentation URL'),
 304      repository: z.string().optional().describe('Source code repository URL'),
 305      license: z
 306        .string()
 307        .optional()
 308        .describe('SPDX license identifier (e.g., MIT, Apache-2.0)'),
 309      keywords: z
 310        .array(z.string())
 311        .optional()
 312        .describe('Tags for plugin discovery and categorization'),
 313      dependencies: z
 314        .array(DependencyRefSchema())
 315        .optional()
 316        .describe(
 317          'Plugins that must be enabled for this plugin to function. Bare names (no "@marketplace") are resolved against the declaring plugin\'s own marketplace.',
 318        ),
 319    }),
 320  )
 321  
 322  /**
 323   * Schema for plugin hooks configuration (hooks.json)
 324   *
 325   * Defines the hooks that a plugin can provide to intercept and modify
 326   * Claude Code behavior at various lifecycle events.
 327   */
 328  export const PluginHooksSchema = lazySchema(() =>
 329    z.object({
 330      description: z
 331        .string()
 332        .optional()
 333        .describe('Brief, user-facing explanation of what these hooks provide'),
 334      hooks: z
 335        .lazy(() => HooksSchema())
 336        .describe(
 337          'The hooks provided by the plugin, in the same format as the one used for settings',
 338        ),
 339    }),
 340  )
 341  
 342  /**
 343   * Schema for additional hooks configuration in plugin manifest
 344   *
 345   * Allows plugins to specify hooks either inline or via external files,
 346   * supplementing any hooks defined in the standard hooks/hooks.json location.
 347   */
 348  const PluginManifestHooksSchema = lazySchema(() =>
 349    z.object({
 350      hooks: z.union([
 351        RelativeJSONPath().describe(
 352          'Path to file with additional hooks (in addition to those in hooks/hooks.json, if it exists), relative to the plugin root',
 353        ),
 354        z
 355          .lazy(() => HooksSchema())
 356          .describe(
 357            'Additional hooks (in addition to those in hooks/hooks.json, if it exists)',
 358          ),
 359        z.array(
 360          z.union([
 361            RelativeJSONPath().describe(
 362              'Path to file with additional hooks (in addition to those in hooks/hooks.json, if it exists), relative to the plugin root',
 363            ),
 364            z
 365              .lazy(() => HooksSchema())
 366              .describe(
 367                'Additional hooks (in addition to those in hooks/hooks.json, if it exists)',
 368              ),
 369          ]),
 370        ),
 371      ]),
 372    }),
 373  )
 374  
 375  /**
 376   * Schema for command metadata when using object-mapping format
 377   *
 378   * Allows marketplace entries to provide rich metadata for commands including
 379   * custom descriptions and frontmatter overrides.
 380   *
 381   * Commands can be defined with either:
 382   * - source: Path to a markdown file
 383   * - content: Inline markdown content
 384   */
 385  export const CommandMetadataSchema = lazySchema(() =>
 386    z
 387      .object({
 388        source: RelativeCommandPath()
 389          .optional()
 390          .describe('Path to command markdown file, relative to plugin root'),
 391        content: z
 392          .string()
 393          .optional()
 394          .describe('Inline markdown content for the command'),
 395        description: z
 396          .string()
 397          .optional()
 398          .describe('Command description override'),
 399        argumentHint: z
 400          .string()
 401          .optional()
 402          .describe('Hint for command arguments (e.g., "[file]")'),
 403        model: z.string().optional().describe('Default model for this command'),
 404        allowedTools: z
 405          .array(z.string())
 406          .optional()
 407          .describe('Tools allowed when command runs'),
 408      })
 409      .refine(
 410        data => (data.source && !data.content) || (!data.source && data.content),
 411        {
 412          message:
 413            'Command must have either "source" (file path) or "content" (inline markdown), but not both',
 414        },
 415      ),
 416  )
 417  
 418  /**
 419   * Schema for additional command definitions in plugin manifest
 420   *
 421   * Allows plugins to specify extra command files or skill directories beyond those
 422   * in the standard commands/ directory.
 423   *
 424   * Supports three formats:
 425   * 1. Single path: "./README.md"
 426   * 2. Array of paths: ["./README.md", "./docs/guide.md"]
 427   * 3. Object mapping: { "about": { "source": "./README.md", "description": "..." } }
 428   */
 429  const PluginManifestCommandsSchema = lazySchema(() =>
 430    z.object({
 431      commands: z.union([
 432        // TODO (future work): allow globs?
 433        RelativeCommandPath().describe(
 434          'Path to additional command file or skill directory (in addition to those in the commands/ directory, if it exists), relative to the plugin root',
 435        ),
 436        z
 437          .array(
 438            RelativeCommandPath().describe(
 439              'Path to additional command file or skill directory (in addition to those in the commands/ directory, if it exists), relative to the plugin root',
 440            ),
 441          )
 442          .describe(
 443            'List of paths to additional command files or skill directories',
 444          ),
 445        z
 446          .record(z.string(), CommandMetadataSchema())
 447          .describe(
 448            'Object mapping of command names to their metadata and source files. Command name becomes the slash command name (e.g., "about" → "/plugin:about")',
 449          ),
 450      ]),
 451    }),
 452  )
 453  
 454  /**
 455   * Schema for additional agent definitions in plugin manifest
 456   *
 457   * Allows plugins to specify extra agent files beyond those in the
 458   * standard agents/ directory.
 459   */
 460  const PluginManifestAgentsSchema = lazySchema(() =>
 461    z.object({
 462      agents: z.union([
 463        // TODO (future work): allow globs?
 464        RelativeMarkdownPath().describe(
 465          'Path to additional agent file (in addition to those in the agents/ directory, if it exists), relative to the plugin root',
 466        ),
 467        z
 468          .array(
 469            RelativeMarkdownPath().describe(
 470              'Path to additional agent file (in addition to those in the agents/ directory, if it exists), relative to the plugin root',
 471            ),
 472          )
 473          .describe('List of paths to additional agent files'),
 474      ]),
 475    }),
 476  )
 477  
 478  /**
 479   * Schema for additional skill definitions in plugin manifest
 480   *
 481   * Allows plugins to specify extra skill directories beyond those in the
 482   * standard skills/ directory.
 483   */
 484  const PluginManifestSkillsSchema = lazySchema(() =>
 485    z.object({
 486      skills: z.union([
 487        RelativePath().describe(
 488          'Path to additional skill directory (in addition to those in the skills/ directory, if it exists), relative to the plugin root',
 489        ),
 490        z
 491          .array(
 492            RelativePath().describe(
 493              'Path to additional skill directory (in addition to those in the skills/ directory, if it exists), relative to the plugin root',
 494            ),
 495          )
 496          .describe('List of paths to additional skill directories'),
 497      ]),
 498    }),
 499  )
 500  
 501  /**
 502   * Schema for additional output style definitions in plugin manifest
 503   *
 504   * Allows plugins to specify extra output style files or directories beyond those in the
 505   * standard output-styles/ directory.
 506   */
 507  const PluginManifestOutputStylesSchema = lazySchema(() =>
 508    z.object({
 509      outputStyles: z.union([
 510        RelativePath().describe(
 511          'Path to additional output styles directory or file (in addition to those in the output-styles/ directory, if it exists), relative to the plugin root',
 512        ),
 513        z
 514          .array(
 515            RelativePath().describe(
 516              'Path to additional output styles directory or file (in addition to those in the output-styles/ directory, if it exists), relative to the plugin root',
 517            ),
 518          )
 519          .describe(
 520            'List of paths to additional output styles directories or files',
 521          ),
 522      ]),
 523    }),
 524  )
 525  
 526  // Helper validators for LSP config
 527  const nonEmptyString = lazySchema(() => z.string().min(1))
 528  const fileExtension = lazySchema(() =>
 529    z
 530      .string()
 531      .min(2)
 532      .refine(ext => ext.startsWith('.'), {
 533        message: 'File extensions must start with dot (e.g., ".ts", not "ts")',
 534      }),
 535  )
 536  
 537  /**
 538   * Schema for MCP server configurations in plugin manifest
 539   *
 540   * Allows plugins to provide MCP servers either inline or via external
 541   * configuration files, supplementing any servers in .mcp.json.
 542   */
 543  const PluginManifestMcpServerSchema = lazySchema(() =>
 544    z.object({
 545      mcpServers: z.union([
 546        RelativeJSONPath().describe(
 547          'MCP servers to include in the plugin (in addition to those in the .mcp.json file, if it exists)',
 548        ),
 549        McpbPath().describe(
 550          'Path or URL to MCPB file containing MCP server configuration',
 551        ),
 552        z
 553          .record(z.string(), McpServerConfigSchema())
 554          .describe('MCP server configurations keyed by server name'),
 555        z
 556          .array(
 557            z.union([
 558              RelativeJSONPath().describe(
 559                'Path to MCP servers configuration file',
 560              ),
 561              McpbPath().describe('Path or URL to MCPB file'),
 562              z
 563                .record(z.string(), McpServerConfigSchema())
 564                .describe('Inline MCP server configurations'),
 565            ]),
 566          )
 567          .describe(
 568            'Array of MCP server configurations (paths, MCPB files, or inline definitions)',
 569          ),
 570      ]),
 571    }),
 572  )
 573  
 574  /**
 575   * Schema for a single user-configurable option in plugin manifest userConfig.
 576   *
 577   * Shape intentionally matches `McpbUserConfigurationOption` from
 578   * `@anthropic-ai/mcpb` so the parsed result is structurally assignable to
 579   * `UserConfigSchema` in mcpbHandler.ts — this lets us reuse
 580   * `validateUserConfig` and the config dialog without modification.
 581   * `title` and `description` are required (not optional) because the upstream
 582   * type requires them and the config dialog renders them.
 583   *
 584   * Used by both the top-level manifest.userConfig and the per-channel
 585   * channels[].userConfig (assistant-mode channels).
 586   */
 587  const PluginUserConfigOptionSchema = lazySchema(() =>
 588    z
 589      .object({
 590        type: z
 591          .enum(['string', 'number', 'boolean', 'directory', 'file'])
 592          .describe('Type of the configuration value'),
 593        title: z
 594          .string()
 595          .describe('Human-readable label shown in the config dialog'),
 596        description: z
 597          .string()
 598          .describe('Help text shown beneath the field in the config dialog'),
 599        required: z
 600          .boolean()
 601          .optional()
 602          .describe('If true, validation fails when this field is empty'),
 603        default: z
 604          .union([z.string(), z.number(), z.boolean(), z.array(z.string())])
 605          .optional()
 606          .describe('Default value used when the user provides nothing'),
 607        multiple: z
 608          .boolean()
 609          .optional()
 610          .describe('For string type: allow an array of strings'),
 611        sensitive: z
 612          .boolean()
 613          .optional()
 614          .describe(
 615            'If true, masks dialog input and stores value in secure storage (keychain/credentials file) instead of settings.json',
 616          ),
 617        min: z.number().optional().describe('Minimum value (number type only)'),
 618        max: z.number().optional().describe('Maximum value (number type only)'),
 619      })
 620      .strict(),
 621  )
 622  
 623  /**
 624   * Schema for the top-level userConfig field in plugin manifest.
 625   *
 626   * Declares user-configurable values the plugin needs. Users are prompted at
 627   * enable time. Non-sensitive values go to settings.json
 628   * pluginConfigs[pluginId].options; sensitive values go to secure storage.
 629   * Values are available as ${user_config.KEY} in MCP/LSP server config, hook
 630   * commands, and (non-sensitive only) skill/agent content.
 631   */
 632  const PluginManifestUserConfigSchema = lazySchema(() =>
 633    z.object({
 634      userConfig: z
 635        .record(
 636          z
 637            .string()
 638            .regex(
 639              /^[A-Za-z_]\w*$/,
 640              'Option keys must be valid identifiers (letters, digits, underscore; no leading digit) — they become CLAUDE_PLUGIN_OPTION_<KEY> env vars in hooks',
 641            ),
 642          PluginUserConfigOptionSchema(),
 643        )
 644        .optional()
 645        .describe(
 646          'User-configurable values this plugin needs. Prompted at enable time. ' +
 647            'Non-sensitive values saved to settings.json; sensitive values to secure storage ' +
 648            '(macOS keychain or .credentials.json). Available as ${user_config.KEY} in ' +
 649            'MCP/LSP server config, hook commands, and (non-sensitive only) skill/agent content. ' +
 650            'Note: sensitive values share a single keychain entry with OAuth tokens — keep ' +
 651            'secret counts small to stay under the ~2KB stdin-safe limit (see INC-3028).',
 652        ),
 653    }),
 654  )
 655  
 656  /**
 657   * Schema for channel declarations in plugin manifest.
 658   *
 659   * A channel is an MCP server that emits `notifications/claude/channel` to
 660   * inject messages into the conversation (Telegram, Slack, Discord, etc.).
 661   * Declaring it here lets the plugin prompt for user config (bot tokens,
 662   * owner IDs) at install time via the PluginOptionsFlow prompt,
 663   * rather than requiring users to hand-edit settings.json.
 664   *
 665   * The `server` field must match a key in the plugin's `mcpServers` — this is
 666   * not cross-validated at schema parse time (the mcpServers field can be a
 667   * path to a JSON file we haven't read yet), so the check happens at load
 668   * time in mcpPluginIntegration.ts instead.
 669   */
 670  const PluginManifestChannelsSchema = lazySchema(() =>
 671    z.object({
 672      channels: z
 673        .array(
 674          z
 675            .object({
 676              server: z
 677                .string()
 678                .min(1)
 679                .describe(
 680                  "Name of the MCP server this channel binds to. Must match a key in this plugin's mcpServers.",
 681                ),
 682              displayName: z
 683                .string()
 684                .optional()
 685                .describe(
 686                  'Human-readable name shown in the config dialog title (e.g., "Telegram"). Defaults to the server name.',
 687                ),
 688              userConfig: z
 689                .record(z.string(), PluginUserConfigOptionSchema())
 690                .optional()
 691                .describe(
 692                  'Fields to prompt the user for when enabling this plugin in assistant mode. ' +
 693                    'Saved values are substituted into ${user_config.KEY} references in the mcpServers env.',
 694                ),
 695            })
 696            .strict(),
 697        )
 698        .describe(
 699          'Channels this plugin provides. Each entry declares an MCP server as a message channel ' +
 700            'and optionally specifies user configuration to prompt for at enable time.',
 701        ),
 702    }),
 703  )
 704  
 705  /**
 706   * Schema for individual LSP server configuration.
 707   */
 708  export const LspServerConfigSchema = lazySchema(() =>
 709    z.strictObject({
 710      command: z
 711        .string()
 712        .min(1)
 713        .refine(
 714          cmd => {
 715            // Commands with spaces should use args array instead
 716            if (cmd.includes(' ') && !cmd.startsWith('/')) {
 717              return false
 718            }
 719            return true
 720          },
 721          {
 722            message:
 723              'Command should not contain spaces. Use args array for arguments.',
 724          },
 725        )
 726        .describe(
 727          'Command to execute the LSP server (e.g., "typescript-language-server")',
 728        ),
 729      args: z
 730        .array(nonEmptyString())
 731        .optional()
 732        .describe('Command-line arguments to pass to the server'),
 733      extensionToLanguage: z
 734        .record(fileExtension(), nonEmptyString())
 735        .refine(record => Object.keys(record).length > 0, {
 736          message: 'extensionToLanguage must have at least one mapping',
 737        })
 738        .describe(
 739          'Mapping from file extension to LSP language ID. File extensions and languages are derived from this mapping.',
 740        ),
 741      transport: z
 742        .enum(['stdio', 'socket'])
 743        .default('stdio')
 744        .describe('Communication transport mechanism'),
 745      env: z
 746        .record(z.string(), z.string())
 747        .optional()
 748        .describe('Environment variables to set when starting the server'),
 749      initializationOptions: z
 750        .unknown()
 751        .optional()
 752        .describe(
 753          'Initialization options passed to the server during initialization',
 754        ),
 755      settings: z
 756        .unknown()
 757        .optional()
 758        .describe(
 759          'Settings passed to the server via workspace/didChangeConfiguration',
 760        ),
 761      workspaceFolder: z
 762        .string()
 763        .optional()
 764        .describe('Workspace folder path to use for the server'),
 765      startupTimeout: z
 766        .number()
 767        .int()
 768        .positive()
 769        .optional()
 770        .describe('Maximum time to wait for server startup (milliseconds)'),
 771      shutdownTimeout: z
 772        .number()
 773        .int()
 774        .positive()
 775        .optional()
 776        .describe('Maximum time to wait for graceful shutdown (milliseconds)'),
 777      restartOnCrash: z
 778        .boolean()
 779        .optional()
 780        .describe('Whether to restart the server if it crashes'),
 781      maxRestarts: z
 782        .number()
 783        .int()
 784        .nonnegative()
 785        .optional()
 786        .describe('Maximum number of restart attempts before giving up'),
 787    }),
 788  )
 789  
 790  /**
 791   * Schema for LSP server declarations in plugin manifest.
 792   * Supports multiple formats:
 793   * - String: path to .lsp.json file
 794   * - Object: inline server configs { "serverName": {...} }
 795   * - Array: mix of strings and objects
 796   */
 797  const PluginManifestLspServerSchema = lazySchema(() =>
 798    z.object({
 799      lspServers: z.union([
 800        RelativeJSONPath().describe(
 801          'Path to .lsp.json configuration file relative to plugin root',
 802        ),
 803        z
 804          .record(z.string(), LspServerConfigSchema())
 805          .describe('LSP server configurations keyed by server name'),
 806        z
 807          .array(
 808            z.union([
 809              RelativeJSONPath().describe('Path to LSP configuration file'),
 810              z
 811                .record(z.string(), LspServerConfigSchema())
 812                .describe('Inline LSP server configurations'),
 813            ]),
 814          )
 815          .describe(
 816            'Array of LSP server configurations (paths or inline definitions)',
 817          ),
 818      ]),
 819    }),
 820  )
 821  
 822  /**
 823   * Schema for npm package names
 824   *
 825   * Validates npm package names including scoped packages.
 826   * Prevents path traversal attacks by disallowing '..' and '//'.
 827   *
 828   * Valid examples:
 829   * - "express"
 830   * - "@babel/core"
 831   * - "lodash.debounce"
 832   *
 833   * Invalid examples:
 834   * - "../../../etc/passwd"
 835   * - "package//name"
 836   */
 837  const NpmPackageNameSchema = lazySchema(() =>
 838    z
 839      .string()
 840      .refine(
 841        name => !name.includes('..') && !name.includes('//'),
 842        'Package name cannot contain path traversal patterns',
 843      )
 844      .refine(name => {
 845        // Allow scoped packages (@org/package) and regular packages
 846        const scopedPackageRegex = /^@[a-z0-9][a-z0-9-._]*\/[a-z0-9][a-z0-9-._]*$/
 847        const regularPackageRegex = /^[a-z0-9][a-z0-9-._]*$/
 848        return scopedPackageRegex.test(name) || regularPackageRegex.test(name)
 849      }, 'Invalid npm package name format'),
 850  )
 851  
 852  /**
 853   * Schema for plugin settings that get merged into the settings cascade.
 854   * Accepts any record here; filtering to allowlisted keys happens at load time
 855   * in pluginLoader.ts via PluginSettingsSchema (derived from SettingsSchema).
 856   */
 857  const PluginManifestSettingsSchema = lazySchema(() =>
 858    z.object({
 859      settings: z
 860        .record(z.string(), z.unknown())
 861        .optional()
 862        .describe(
 863          'Settings to merge when plugin is enabled. ' +
 864            'Only allowlisted keys are kept (currently: agent)',
 865        ),
 866    }),
 867  )
 868  
 869  /**
 870   * Plugin manifest file (plugin.json)
 871   *
 872   * This schema validates the structure of plugin manifests and provides
 873   * runtime type checking when loading plugins from disk.
 874   *
 875   * Unknown top-level fields are silently stripped (zod default) rather than
 876   * rejected. This keeps plugin loading resilient to custom/future top-level
 877   * fields that plugin authors may add. Nested config objects (userConfig
 878   * options, channels, lspServers) remain strict — unknown keys inside those
 879   * still fail, since a typo there is more likely to be an author mistake
 880   * than a vendor extension. Type mismatches and other validation errors
 881   * still fail at all levels. For developer feedback on unknown top-level
 882   * fields, use `claude plugin validate`.
 883   */
 884  export const PluginManifestSchema = lazySchema(() =>
 885    z.object({
 886      ...PluginManifestMetadataSchema().shape,
 887      ...PluginManifestHooksSchema().partial().shape,
 888      ...PluginManifestCommandsSchema().partial().shape,
 889      ...PluginManifestAgentsSchema().partial().shape,
 890      ...PluginManifestSkillsSchema().partial().shape,
 891      ...PluginManifestOutputStylesSchema().partial().shape,
 892      ...PluginManifestChannelsSchema().partial().shape,
 893      ...PluginManifestMcpServerSchema().partial().shape,
 894      ...PluginManifestLspServerSchema().partial().shape,
 895      ...PluginManifestSettingsSchema().partial().shape,
 896      ...PluginManifestUserConfigSchema().partial().shape,
 897    }),
 898  )
 899  
 900  /**
 901   * Schema for marketplace source locations
 902   *
 903   * Defines various ways to reference marketplace manifests including
 904   * direct URLs, GitHub repos, git URLs, npm packages, and local paths.
 905   */
 906  export const MarketplaceSourceSchema = lazySchema(() =>
 907    z.discriminatedUnion('source', [
 908      z.object({
 909        source: z.literal('url'),
 910        url: z.string().url().describe('Direct URL to marketplace.json file'),
 911        headers: z
 912          .record(z.string(), z.string())
 913          .optional()
 914          .describe('Custom HTTP headers (e.g., for authentication)'),
 915      }),
 916      z.object({
 917        source: z.literal('github'),
 918        repo: z.string().describe('GitHub repository in owner/repo format'),
 919        ref: z
 920          .string()
 921          .optional()
 922          .describe(
 923            'Git branch or tag to use (e.g., "main", "v1.0.0"). Defaults to repository default branch.',
 924          ),
 925        path: z
 926          .string()
 927          .optional()
 928          .describe(
 929            'Path to marketplace.json within repo (defaults to .claude-plugin/marketplace.json)',
 930          ),
 931        sparsePaths: z
 932          .array(z.string())
 933          .optional()
 934          .describe(
 935            'Directories to include via git sparse-checkout (cone mode). ' +
 936              'Use for monorepos where the marketplace lives in a subdirectory. ' +
 937              'Example: [".claude-plugin", "plugins"]. ' +
 938              'If omitted, the full repository is cloned.',
 939          ),
 940      }),
 941      z.object({
 942        source: z.literal('git'),
 943        // No .endsWith('.git') here — that's a GitHub/GitLab/Bitbucket
 944        // convention, not a git requirement. Azure DevOps uses
 945        // https://dev.azure.com/{org}/{proj}/_git/{repo} with no suffix, and
 946        // appending .git makes ADO look for a repo literally named {repo}.git
 947        // (TF401019). AWS CodeCommit also omits the suffix. If the user
 948        // explicitly wrote source:'git', they know it's a git repo; a typo'd
 949        // URL fails at `git clone` with a clearer error anyway. (gh-31256)
 950        url: z.string().describe('Full git repository URL'),
 951        ref: z
 952          .string()
 953          .optional()
 954          .describe(
 955            'Git branch or tag to use (e.g., "main", "v1.0.0"). Defaults to repository default branch.',
 956          ),
 957        path: z
 958          .string()
 959          .optional()
 960          .describe(
 961            'Path to marketplace.json within repo (defaults to .claude-plugin/marketplace.json)',
 962          ),
 963        sparsePaths: z
 964          .array(z.string())
 965          .optional()
 966          .describe(
 967            'Directories to include via git sparse-checkout (cone mode). ' +
 968              'Use for monorepos where the marketplace lives in a subdirectory. ' +
 969              'Example: [".claude-plugin", "plugins"]. ' +
 970              'If omitted, the full repository is cloned.',
 971          ),
 972      }),
 973      z.object({
 974        source: z.literal('npm'),
 975        package: NpmPackageNameSchema().describe(
 976          'NPM package containing marketplace.json',
 977        ),
 978      }),
 979      z.object({
 980        source: z.literal('file'),
 981        path: z.string().describe('Local file path to marketplace.json'),
 982      }),
 983      z.object({
 984        source: z.literal('directory'),
 985        path: z
 986          .string()
 987          .describe('Local directory containing .claude-plugin/marketplace.json'),
 988      }),
 989      z.object({
 990        source: z.literal('hostPattern'),
 991        hostPattern: z
 992          .string()
 993          .describe(
 994            'Regex pattern to match the host/domain extracted from any marketplace source type. ' +
 995              'For github sources, matches against "github.com". For git sources (SSH or HTTPS), ' +
 996              'extracts the hostname from the URL. Use in strictKnownMarketplaces to allow all ' +
 997              'marketplaces from a specific host (e.g., "^github\\.mycompany\\.com$").',
 998          ),
 999      }),
1000      z.object({
1001        source: z.literal('pathPattern'),
1002        pathPattern: z
1003          .string()
1004          .describe(
1005            'Regex pattern matched against the .path field of file and directory sources. ' +
1006              'Use in strictKnownMarketplaces to allow filesystem-based marketplaces alongside ' +
1007              'hostPattern restrictions for network sources. Use ".*" to allow all filesystem ' +
1008              'paths, or a narrower pattern (e.g., "^/opt/approved/") to restrict to specific ' +
1009              'directories.',
1010          ),
1011      }),
1012      z
1013        .object({
1014          source: z.literal('settings'),
1015          name: MarketplaceNameSchema()
1016            .refine(
1017              name => !ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(name.toLowerCase()),
1018              {
1019                message:
1020                  'Reserved official marketplace names cannot be used with settings sources. ' +
1021                  'validateOfficialNameSource only accepts github/git sources from anthropics/* ' +
1022                  'for these names; a settings source would be rejected after ' +
1023                  'loadAndCacheMarketplace has already written to disk with cleanupNeeded=false.',
1024              },
1025            )
1026            .describe(
1027              'Marketplace name. Must match the extraKnownMarketplaces key (enforced); ' +
1028                'the synthetic manifest is written under this name. Same validation ' +
1029                'as PluginMarketplaceSchema plus reserved-name rejection \u2014 ' +
1030                'validateOfficialNameSource runs after the disk write, too late to clean up.',
1031            ),
1032          plugins: z
1033            .array(SettingsMarketplacePluginSchema())
1034            .describe('Plugin entries declared inline in settings.json'),
1035          owner: PluginAuthorSchema().optional(),
1036        })
1037        .describe(
1038          'Inline marketplace manifest defined directly in settings.json. ' +
1039            'The reconciler writes a synthetic marketplace.json to the cache; ' +
1040            'diffMarketplaces detects edits via isEqual on the stored source ' +
1041            '(the plugins array is inside this object, so edits surface as sourceChanged).',
1042        ),
1043    ]),
1044  )
1045  
1046  export const gitSha = lazySchema(() =>
1047    z
1048      .string()
1049      .length(40)
1050      .regex(
1051        /^[a-f0-9]{40}$/,
1052        'Must be a full 40-character lowercase git commit SHA',
1053      ),
1054  )
1055  
1056  /**
1057   * Schema for plugin source locations
1058   *
1059   * Defines various ways to reference and install plugins including
1060   * local paths, npm packages, Python packages, git URLs, and GitHub repos.
1061   */
1062  export const PluginSourceSchema = lazySchema(() =>
1063    z.union([
1064      RelativePath().describe(
1065        'Path to the plugin root, relative to the marketplace root (the directory containing .claude-plugin/, not .claude-plugin/ itself)',
1066      ),
1067      z
1068        .object({
1069          source: z.literal('npm'),
1070          package: NpmPackageNameSchema()
1071            .or(z.string()) // Allow URLs and local paths as well
1072            .describe(
1073              'Package name (or url, or local path, or anything else that can be passed to `npm` as a package)',
1074            ),
1075          version: z
1076            .string()
1077            .optional()
1078            .describe('Specific version or version range (e.g., ^1.0.0, ~2.1.0)'),
1079          registry: z
1080            .string()
1081            .url()
1082            .optional()
1083            .describe(
1084              'Custom NPM registry URL (defaults to using system default, likely npmjs.org)',
1085            ),
1086        })
1087        .describe('NPM package as plugin source'),
1088      z
1089        .object({
1090          source: z.literal('pip'),
1091          package: z
1092            .string()
1093            .describe('Python package name as it appears on PyPI'),
1094          version: z
1095            .string()
1096            .optional()
1097            .describe('Version specifier (e.g., ==1.0.0, >=2.0.0, <3.0.0)'),
1098          registry: z
1099            .string()
1100            .url()
1101            .optional()
1102            .describe(
1103              'Custom PyPI registry URL (defaults to using system default, likely pypi.org)',
1104            ),
1105        })
1106        .describe('Python package as plugin source'),
1107      z.object({
1108        source: z.literal('url'),
1109        // See note on MarketplaceSourceSchema source:'git' re: .endsWith('.git')
1110        // — dropped to support Azure DevOps / CodeCommit URLs (gh-31256).
1111        url: z.string().describe('Full git repository URL (https:// or git@)'),
1112        ref: z
1113          .string()
1114          .optional()
1115          .describe(
1116            'Git branch or tag to use (e.g., "main", "v1.0.0"). Defaults to repository default branch.',
1117          ),
1118        sha: gitSha().optional().describe('Specific commit SHA to use'),
1119      }),
1120      z.object({
1121        source: z.literal('github'),
1122        repo: z.string().describe('GitHub repository in owner/repo format'),
1123        ref: z
1124          .string()
1125          .optional()
1126          .describe(
1127            'Git branch or tag to use (e.g., "main", "v1.0.0"). Defaults to repository default branch.',
1128          ),
1129        sha: gitSha().optional().describe('Specific commit SHA to use'),
1130      }),
1131      z
1132        .object({
1133          source: z.literal('git-subdir'),
1134          url: z
1135            .string()
1136            .describe(
1137              'Git repository: GitHub owner/repo shorthand, https://, or git@ URL',
1138            ),
1139          path: z
1140            .string()
1141            .min(1)
1142            .describe(
1143              'Subdirectory within the repo containing the plugin (e.g., "tools/claude-plugin"). ' +
1144                'Cloned sparsely using partial clone (--filter=tree:0) to minimize bandwidth for monorepos.',
1145            ),
1146          ref: z
1147            .string()
1148            .optional()
1149            .describe(
1150              'Git branch or tag to use (e.g., "main", "v1.0.0"). Defaults to repository default branch.',
1151            ),
1152          sha: gitSha().optional().describe('Specific commit SHA to use'),
1153        })
1154        .describe(
1155          'Plugin located in a subdirectory of a larger repository (monorepo). ' +
1156            'Only the specified subdirectory is materialized; the rest of the repo is not downloaded.',
1157        ),
1158      // TODO (future work) gist
1159      // TODO (future work) single file?
1160    ]),
1161  )
1162  
1163  /**
1164   * Narrow plugin entry for settings-sourced marketplaces.
1165   *
1166   * Settings-sourced marketplaces point at remote plugins that have their own
1167   * plugin.json — there is no reason to inline commands/agents/hooks/mcp/lsp in
1168   * settings.json. This schema carries only what loadPluginFromMarketplaceEntry
1169   * reads (name, source, version, strict) plus description for discoverability.
1170   *
1171   * The synthetic marketplace.json written by loadAndCacheMarketplace is re-parsed
1172   * with the full PluginMarketplaceSchema, which widens these entries back to
1173   * PluginMarketplaceEntry (strict gets its .default(true), everything else stays
1174   * undefined). So this narrowness is settings-surface-only; downstream code sees
1175   * the same shape it would from any sparse marketplace.json entry.
1176   *
1177   * Keeping this narrow prevents PluginManifestSchema().partial() from expanding
1178   * inline in settingsTypes.generated.ts — that expansion is ~870 lines per
1179   * occurrence, and MarketplaceSource appears three times in the settings schema
1180   * (extraKnownMarketplaces, strictKnownMarketplaces, blockedMarketplaces).
1181   */
1182  const SettingsMarketplacePluginSchema = lazySchema(() =>
1183    z
1184      .object({
1185        name: z
1186          .string()
1187          .min(1, 'Plugin name cannot be empty')
1188          .refine(name => !name.includes(' '), {
1189            message:
1190              'Plugin name cannot contain spaces. Use kebab-case (e.g., "my-plugin")',
1191          })
1192          .describe('Plugin name as it appears in the target repository'),
1193        source: PluginSourceSchema().describe(
1194          'Where to fetch the plugin from. Must be a remote source — relative ' +
1195            'paths have no marketplace repository to resolve against.',
1196        ),
1197        description: z.string().optional(),
1198        version: z.string().optional(),
1199        strict: z.boolean().optional(),
1200      })
1201      .refine(p => typeof p.source !== 'string', {
1202        message:
1203          'Plugins in a settings-sourced marketplace must use remote sources ' +
1204          '(github, git-subdir, npm, url, pip). Relative-path sources like "./foo" ' +
1205          'have no marketplace repository to resolve against.',
1206      }),
1207  )
1208  
1209  /**
1210   * Check if a plugin source is a local path (stored in marketplace directory).
1211   *
1212   * Local plugins have their source as a string starting with './' (relative to marketplace).
1213   * External plugins have their source as an object (npm, pip, git, github, etc.).
1214   *
1215   * This function provides a semantic wrapper around the './' prefix check, making
1216   * the intent clear and centralizing the logic for determining plugin source type.
1217   *
1218   * @param source The plugin source from PluginMarketplaceEntry
1219   * @returns true if the source is a local path, false if it's an external source
1220   */
1221  export function isLocalPluginSource(source: PluginSource): source is string {
1222    return typeof source === 'string' && source.startsWith('./')
1223  }
1224  
1225  /**
1226   * Whether a marketplace source points at a user-controlled local filesystem path.
1227   *
1228   * For local sources (`file`/`directory`), `installLocation` IS the user's path —
1229   * it lives outside the plugins cache dir and marketplace operations on it are
1230   * read-only. For remote sources (`github`/`git`/`url`/`npm`), `installLocation`
1231   * is a cache-dir entry managed by Claude Code and subject to rm/re-clone.
1232   *
1233   * Contrast with isLocalPluginSource, which operates on PluginSource (the
1234   * per-plugin source inside a marketplace entry) and checks for `./` prefix.
1235   */
1236  export function isLocalMarketplaceSource(
1237    source: MarketplaceSource,
1238  ): source is Extract<MarketplaceSource, { source: 'file' | 'directory' }> {
1239    return source.source === 'file' || source.source === 'directory'
1240  }
1241  
1242  /**
1243   * Schema for individual plugin entries in a marketplace
1244   *
1245   * When strict=true (default): Plugin.json is required, marketplace fields supplement it
1246   * When strict=false: Plugin.json is optional, marketplace provides full manifest
1247   *
1248   * Unknown fields are silently stripped (zod default) rather than rejected.
1249   * Marketplace entries are validated as an array — if one entry rejected
1250   * unknown keys, the whole marketplace.json would fail to parse and ALL
1251   * plugins from that marketplace would become unavailable. Stripping keeps
1252   * the blast radius to zero for custom/future fields.
1253   */
1254  export const PluginMarketplaceEntrySchema = lazySchema(() =>
1255    PluginManifestSchema()
1256      .partial()
1257      .extend({
1258        name: z
1259          .string()
1260          .min(1, 'Plugin name cannot be empty')
1261          .refine(name => !name.includes(' '), {
1262            message:
1263              'Plugin name cannot contain spaces. Use kebab-case (e.g., "my-plugin")',
1264          })
1265          .describe('Unique identifier matching the plugin name'),
1266        source: PluginSourceSchema().describe('Where to fetch the plugin from'),
1267        category: z
1268          .string()
1269          .optional()
1270          .describe(
1271            'Category for organizing plugins (e.g., "productivity", "development")',
1272          ),
1273        tags: z
1274          .array(z.string())
1275          .optional()
1276          .describe('Tags for searchability and discovery'),
1277        strict: z
1278          .boolean()
1279          .optional()
1280          .default(true)
1281          .describe(
1282            'Require the plugin manifest to be present in the plugin folder. If false, the marketplace entry provides the manifest.',
1283          ),
1284      }),
1285  )
1286  
1287  /**
1288   * Schema for plugin marketplace configuration
1289   *
1290   * Defines the structure for curated collections of plugins that can
1291   * be discovered and installed from a central repository.
1292   */
1293  export const PluginMarketplaceSchema = lazySchema(() =>
1294    z.object({
1295      name: MarketplaceNameSchema(),
1296      owner: PluginAuthorSchema().describe(
1297        'Marketplace maintainer or curator information',
1298      ),
1299      plugins: z
1300        .array(PluginMarketplaceEntrySchema())
1301        .describe('Collection of available plugins in this marketplace'),
1302      forceRemoveDeletedPlugins: z
1303        .boolean()
1304        .optional()
1305        .describe(
1306          'When true, plugins removed from this marketplace will be automatically uninstalled and flagged for users',
1307        ),
1308      metadata: z
1309        .object({
1310          pluginRoot: z
1311            .string()
1312            .optional()
1313            .describe('Base path for relative plugin sources'),
1314          version: z.string().optional().describe('Marketplace version'),
1315          description: z.string().optional().describe('Marketplace description'),
1316        })
1317        .optional()
1318        .describe('Optional marketplace metadata'),
1319      allowCrossMarketplaceDependenciesOn: z
1320        .array(z.string())
1321        .optional()
1322        .describe(
1323          "Marketplace names whose plugins may be auto-installed as dependencies. Only the root marketplace's allowlist applies \u2014 no transitive trust.",
1324        ),
1325    }),
1326  )
1327  
1328  /**
1329   * Schema for plugin ID format
1330   *
1331   * Plugin IDs follow the format: "plugin-name@marketplace-name"
1332   * Both parts allow alphanumeric characters, hyphens, dots, and underscores.
1333   *
1334   * Examples:
1335   * - "code-formatter@anthropic-tools"
1336   * - "db_assistant@company-internal"
1337   * - "my.plugin@personal-marketplace"
1338   */
1339  export const PluginIdSchema = lazySchema(() =>
1340    z
1341      .string()
1342      .regex(
1343        /^[a-z0-9][-a-z0-9._]*@[a-z0-9][-a-z0-9._]*$/i,
1344        'Plugin ID must be in format: plugin@marketplace',
1345      ),
1346  )
1347  
1348  const DEP_REF_REGEX =
1349    /^[a-z0-9][-a-z0-9._]*(@[a-z0-9][-a-z0-9._]*)?(@\^[^@]*)?$/i
1350  
1351  /**
1352   * Schema for entries in a plugin's `dependencies` array.
1353   *
1354   * Accepts three forms, all normalized to a plain "name" or "name@mkt" string
1355   * by the transform — downstream code (qualifyDependency, resolveDependencyClosure,
1356   * verifyAndDemote) never sees versions or objects:
1357   *
1358   *   "plugin"                → bare, resolved against declaring plugin's marketplace
1359   *   "plugin@marketplace"    → qualified
1360   *   "plugin@mkt@^1.2"       → trailing @^version silently stripped (forwards-compat)
1361   *   {name, marketplace?, …} → object form, version etc. stripped (forwards-compat)
1362   *
1363   * The latter two are permitted-but-ignored so future clients adding version
1364   * constraints don't cause old clients to fail schema validation and reject
1365   * the whole plugin. See CC-993 for the eventual version-range design.
1366   */
1367  export const DependencyRefSchema = lazySchema(() =>
1368    z.union([
1369      z
1370        .string()
1371        .regex(
1372          DEP_REF_REGEX,
1373          'Dependency must be a plugin name, optionally qualified with @marketplace',
1374        )
1375        .transform(s => s.replace(/@\^[^@]*$/, '')),
1376      z
1377        .object({
1378          name: z
1379            .string()
1380            .min(1)
1381            .regex(/^[a-z0-9][-a-z0-9._]*$/i),
1382          marketplace: z
1383            .string()
1384            .min(1)
1385            .regex(/^[a-z0-9][-a-z0-9._]*$/i)
1386            .optional(),
1387        })
1388        .loose()
1389        .transform(o => (o.marketplace ? `${o.name}@${o.marketplace}` : o.name)),
1390    ]),
1391  )
1392  
1393  /**
1394   * Schema for plugin reference in settings (repo or user level)
1395   *
1396   * Can be either:
1397   * - Simple string: "plugin-name@marketplace-name"
1398   * - Object with additional configuration
1399   *
1400   * The plugin source (npm, git, local) is defined in the marketplace entry itself,
1401   * not in the plugin reference.
1402   *
1403   * Examples:
1404   * - "code-formatter@anthropic-tools"
1405   * - "db-assistant@company-internal"
1406   * - { id: "formatter@tools", version: "^2.0.0", required: true }
1407   */
1408  export const SettingsPluginEntrySchema = lazySchema(() =>
1409    z.union([
1410      // Simple format: "plugin@marketplace"
1411      PluginIdSchema(),
1412      // Extended format with configuration
1413      z.object({
1414        id: PluginIdSchema().describe(
1415          'Plugin identifier (e.g., "formatter@tools")',
1416        ),
1417        version: z
1418          .string()
1419          .optional()
1420          .describe('Version constraint (e.g., "^2.0.0")'),
1421        required: z.boolean().optional().describe('If true, cannot be disabled'),
1422        config: z
1423          .record(z.string(), z.unknown())
1424          .optional()
1425          .describe('Plugin-specific configuration'),
1426      }),
1427    ]),
1428  )
1429  
1430  /**
1431   * Schema for installed plugin metadata (V1 format)
1432   *
1433   * Tracks the actual installation state of a plugin. All plugins are
1434   * installed from marketplaces, which contain the actual source details
1435   * (npm, git, local, etc.). The plugin ID is the key in the plugins record,
1436   * so it's not duplicated here.
1437   *
1438   * Example entry for key "code-formatter@anthropic-tools":
1439   * {
1440   *   "version": "1.2.0",
1441   *   "installedAt": "2024-01-15T10:30:00Z",
1442   *   "marketplace": "anthropic-tools",
1443   *   "installPath": "/home/user/.claude/plugins/installed/anthropic-tools/code-formatter"
1444   * }
1445   */
1446  export const InstalledPluginSchema = lazySchema(() =>
1447    z.object({
1448      version: z.string().describe('Currently installed version'),
1449      installedAt: z.string().describe('ISO 8601 timestamp of installation'),
1450      lastUpdated: z
1451        .string()
1452        .optional()
1453        .describe('ISO 8601 timestamp of last update'),
1454      installPath: z
1455        .string()
1456        .describe('Absolute path to the installed plugin directory'),
1457      gitCommitSha: z
1458        .string()
1459        .optional()
1460        .describe('Git commit SHA for git-based plugins (for version tracking)'),
1461    }),
1462  )
1463  
1464  /**
1465   * Schema for the installed_plugins.json file (V1 format)
1466   *
1467   * Contains a version number and maps plugin IDs to their installation metadata.
1468   * Maintained automatically by Claude Code, not edited by users.
1469   *
1470   * The version field tracks schema changes. When the version doesn't match
1471   * the current schema version, Claude Code will update the file on next startup.
1472   *
1473   * Example file:
1474   * {
1475   *   "version": 1,
1476   *   "plugins": {
1477   *     "code-formatter@anthropic-tools": { ... },
1478   *     "db-assistant@company-internal": { ... }
1479   *   }
1480   * }
1481   */
1482  export const InstalledPluginsFileSchemaV1 = lazySchema(() =>
1483    z.object({
1484      version: z.literal(1).describe('Schema version 1'),
1485      plugins: z
1486        .record(
1487          PluginIdSchema(), // Validated plugin ID key (e.g., "formatter@tools")
1488          InstalledPluginSchema(),
1489        )
1490        .describe('Map of plugin IDs to their installation metadata'),
1491    }),
1492  )
1493  
1494  /**
1495   * Scope types for plugin installation (V2)
1496   *
1497   * Plugins can be installed at different scopes:
1498   * - managed: Enterprise/system-wide (read-only, platform-specific paths)
1499   * - user: User's global settings (~/.claude/settings.json)
1500   * - project: Shared project settings ($project/.claude/settings.json)
1501   * - local: Personal project overrides ($project/.claude/settings.local.json)
1502   *
1503   * Note: 'flag' scope plugins (from --settings) are session-only and
1504   * are NOT persisted to installed_plugins.json.
1505   */
1506  export const PluginScopeSchema = lazySchema(() =>
1507    z.enum(['managed', 'user', 'project', 'local']),
1508  )
1509  
1510  /**
1511   * Schema for a single plugin installation entry (V2)
1512   *
1513   * Each plugin can have multiple installations at different scopes.
1514   * For example, the same plugin could be installed at user scope with v1.0
1515   * and at project scope with v1.1.
1516   */
1517  export const PluginInstallationEntrySchema = lazySchema(() =>
1518    z.object({
1519      scope: PluginScopeSchema().describe('Installation scope'),
1520      projectPath: z
1521        .string()
1522        .optional()
1523        .describe('Project path (required for project/local scopes)'),
1524      installPath: z
1525        .string()
1526        .describe('Absolute path to the versioned plugin directory'),
1527      // Preserved from V1:
1528      version: z.string().optional().describe('Currently installed version'),
1529      installedAt: z
1530        .string()
1531        .optional()
1532        .describe('ISO 8601 timestamp of installation'),
1533      lastUpdated: z
1534        .string()
1535        .optional()
1536        .describe('ISO 8601 timestamp of last update'),
1537      gitCommitSha: z
1538        .string()
1539        .optional()
1540        .describe('Git commit SHA for git-based plugins'),
1541    }),
1542  )
1543  
1544  /**
1545   * Schema for the installed_plugins.json file (V2 format)
1546   *
1547   * V2 changes from V1:
1548   * - Each plugin ID maps to an ARRAY of installations (one per scope)
1549   * - Supports multi-scope installation (same plugin at different scopes/versions)
1550   *
1551   * Example file:
1552   * {
1553   *   "version": 2,
1554   *   "plugins": {
1555   *     "code-formatter@anthropic-tools": [
1556   *       { "scope": "user", "installPath": "...", "version": "1.0.0" },
1557   *       { "scope": "project", "projectPath": "/path/to/project", "installPath": "...", "version": "1.1.0" }
1558   *     ]
1559   *   }
1560   * }
1561   */
1562  export const InstalledPluginsFileSchemaV2 = lazySchema(() =>
1563    z.object({
1564      version: z.literal(2).describe('Schema version 2'),
1565      plugins: z
1566        .record(PluginIdSchema(), z.array(PluginInstallationEntrySchema()))
1567        .describe('Map of plugin IDs to arrays of installation entries'),
1568    }),
1569  )
1570  
1571  /**
1572   * Combined schema that accepts both V1 and V2 formats
1573   * Used for reading existing files before migration
1574   */
1575  export const InstalledPluginsFileSchema = lazySchema(() =>
1576    z.union([InstalledPluginsFileSchemaV1(), InstalledPluginsFileSchemaV2()]),
1577  )
1578  
1579  /**
1580   * Schema for a known marketplace entry
1581   *
1582   * Tracks metadata about a registered marketplace in the user's configuration.
1583   * Each entry contains the source location, cache path, and last update time.
1584   *
1585   * Example entry:
1586   * {
1587   *   "source": { "source": "github", "repo": "anthropic/claude-plugins" },
1588   *   "installLocation": "/home/user/.claude/plugins/cached/marketplaces/anthropic-tools",
1589   *   "lastUpdated": "2024-01-15T10:30:00Z"
1590   * }
1591   */
1592  export const KnownMarketplaceSchema = lazySchema(() =>
1593    z.object({
1594      source: MarketplaceSourceSchema().describe(
1595        'Where to fetch the marketplace from',
1596      ),
1597      installLocation: z
1598        .string()
1599        .describe('Local cache path where marketplace manifest is stored'),
1600      lastUpdated: z
1601        .string()
1602        .describe('ISO 8601 timestamp of last marketplace refresh'),
1603      autoUpdate: z
1604        .boolean()
1605        .optional()
1606        .describe(
1607          'Whether to automatically update this marketplace and its installed plugins on startup',
1608        ),
1609    }),
1610  )
1611  
1612  /**
1613   * Schema for the known_marketplaces.json file
1614   *
1615   * Maps marketplace names to their source and cache metadata.
1616   * Used to track which marketplaces are registered and where to find them.
1617   *
1618   * Example file:
1619   * {
1620   *   "anthropic-tools": { "source": { ... }, "installLocation": "...", "lastUpdated": "..." },
1621   *   "company-internal": { "source": { ... }, "installLocation": "...", "lastUpdated": "..." }
1622   * }
1623   */
1624  export const KnownMarketplacesFileSchema = lazySchema(() =>
1625    z.record(
1626      z.string(), // Marketplace name as key
1627      KnownMarketplaceSchema(),
1628    ),
1629  )
1630  
1631  // Inferred types from schemas
1632  /**
1633   * Metadata for plugin command definitions.
1634   *
1635   * Commands can be defined with either:
1636   * - `source`: Path to a markdown file (e.g., "./README.md")
1637   * - `content`: Inline markdown content string
1638   *
1639   * INVARIANT: Exactly one of `source` or `content` must be present.
1640   * This invariant is enforced at runtime by CommandMetadataSchema validation.
1641   *
1642   * Validation occurs at plugin manifest parsing. Metadata is assumed valid
1643   * after passing through createPluginFromPath().
1644   *
1645   * @see CommandMetadataSchema for runtime validation rules
1646   */
1647  export type CommandMetadata = z.infer<ReturnType<typeof CommandMetadataSchema>>
1648  export type MarketplaceSource = z.infer<
1649    ReturnType<typeof MarketplaceSourceSchema>
1650  >
1651  export type PluginAuthor = z.infer<ReturnType<typeof PluginAuthorSchema>>
1652  export type PluginSource = z.infer<ReturnType<typeof PluginSourceSchema>>
1653  export type PluginManifest = z.infer<ReturnType<typeof PluginManifestSchema>>
1654  export type PluginManifestChannel = NonNullable<
1655    PluginManifest['channels']
1656  >[number]
1657  
1658  export type PluginMarketplace = z.infer<
1659    ReturnType<typeof PluginMarketplaceSchema>
1660  >
1661  export type PluginMarketplaceEntry = z.infer<
1662    ReturnType<typeof PluginMarketplaceEntrySchema>
1663  >
1664  export type PluginId = z.infer<ReturnType<typeof PluginIdSchema>> // string in "plugin@marketplace" format
1665  export type InstalledPlugin = z.infer<ReturnType<typeof InstalledPluginSchema>>
1666  export type InstalledPluginsFileV1 = z.infer<
1667    ReturnType<typeof InstalledPluginsFileSchemaV1>
1668  >
1669  export type InstalledPluginsFileV2 = z.infer<
1670    ReturnType<typeof InstalledPluginsFileSchemaV2>
1671  >
1672  export type PluginScope = z.infer<ReturnType<typeof PluginScopeSchema>>
1673  export type PluginInstallationEntry = z.infer<
1674    ReturnType<typeof PluginInstallationEntrySchema>
1675  >
1676  export type KnownMarketplace = z.infer<
1677    ReturnType<typeof KnownMarketplaceSchema>
1678  >
1679  export type KnownMarketplacesFile = z.infer<
1680    ReturnType<typeof KnownMarketplacesFileSchema>
1681  > // Record<string, KnownMarketplace>