source dump of claude code
at main 1681 lines 59 kB view raw
1import { z } from 'zod/v4' 2import { HooksSchema } from '../../schemas/hooks.js' 3import { McpServerConfigSchema } from '../../services/mcp/types.js' 4import { 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 */ 19export 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 */ 35const 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 */ 48export 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 */ 71export 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 */ 79const 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 */ 87export 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 */ 107export 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 */ 119export 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 */ 162const RelativePath = lazySchema(() => z.string().startsWith('./')) 163 164/** 165 * Schema for relative paths to JSON files 166 */ 167const 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 */ 173const 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 */ 193const RelativeMarkdownPath = lazySchema(() => RelativePath().endsWith('.md')) 194 195/** 196 * Schema for relative paths to command sources (markdown files or directories containing SKILL.md) 197 */ 198const 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 */ 216const 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 */ 251export 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 */ 274const 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 */ 328export 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 */ 348const 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 */ 385export 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 */ 429const 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 */ 460const 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 */ 484const 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 */ 507const 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 527const nonEmptyString = lazySchema(() => z.string().min(1)) 528const 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 */ 543const 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 */ 587const 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 */ 632const 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 */ 670const 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 */ 708export 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 */ 797const 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 */ 837const 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 */ 857const 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 */ 884export 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 */ 906export 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 1046export 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 */ 1062export 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 */ 1182const 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 */ 1221export 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 */ 1236export 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 */ 1254export 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 */ 1293export 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 */ 1339export 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 1348const 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 */ 1367export 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 */ 1408export 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 */ 1446export 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 */ 1482export 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 */ 1506export 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 */ 1517export 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 */ 1562export 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 */ 1575export 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 */ 1592export 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 */ 1624export 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 */ 1647export type CommandMetadata = z.infer<ReturnType<typeof CommandMetadataSchema>> 1648export type MarketplaceSource = z.infer< 1649 ReturnType<typeof MarketplaceSourceSchema> 1650> 1651export type PluginAuthor = z.infer<ReturnType<typeof PluginAuthorSchema>> 1652export type PluginSource = z.infer<ReturnType<typeof PluginSourceSchema>> 1653export type PluginManifest = z.infer<ReturnType<typeof PluginManifestSchema>> 1654export type PluginManifestChannel = NonNullable< 1655 PluginManifest['channels'] 1656>[number] 1657 1658export type PluginMarketplace = z.infer< 1659 ReturnType<typeof PluginMarketplaceSchema> 1660> 1661export type PluginMarketplaceEntry = z.infer< 1662 ReturnType<typeof PluginMarketplaceEntrySchema> 1663> 1664export type PluginId = z.infer<ReturnType<typeof PluginIdSchema>> // string in "plugin@marketplace" format 1665export type InstalledPlugin = z.infer<ReturnType<typeof InstalledPluginSchema>> 1666export type InstalledPluginsFileV1 = z.infer< 1667 ReturnType<typeof InstalledPluginsFileSchemaV1> 1668> 1669export type InstalledPluginsFileV2 = z.infer< 1670 ReturnType<typeof InstalledPluginsFileSchemaV2> 1671> 1672export type PluginScope = z.infer<ReturnType<typeof PluginScopeSchema>> 1673export type PluginInstallationEntry = z.infer< 1674 ReturnType<typeof PluginInstallationEntrySchema> 1675> 1676export type KnownMarketplace = z.infer< 1677 ReturnType<typeof KnownMarketplaceSchema> 1678> 1679export type KnownMarketplacesFile = z.infer< 1680 ReturnType<typeof KnownMarketplacesFileSchema> 1681> // Record<string, KnownMarketplace>