export interface IdeAPluginManifest { ideaPluginManifestVersion: 1; id: string; displayName: string; version: string; main: string; trustLevel: "full"; description?: string; publisher?: string; engines?: IdeAPluginEngineConstraints; capabilities?: IdeAPluginCapability[]; /** * Defaults to "app". Use "project" when activate(ctx) requires a focused project. */ activationScope?: IdeAPluginActivationScope; contributes?: { menus?: IdeAPluginTopLevelMenuContribution[]; menuItems?: IdeAPluginMenuItemContribution[]; layouts?: IdeAPluginLayoutContribution[]; mcpServers?: IdeAPluginMcpServerContribution[]; }; } export type IdeAPluginCapability = "ui" | "mcp" | "tooling"; export type IdeAPluginActivationScope = "app" | "project"; export interface IdeAPluginEngineConstraints { idea?: string; } export interface IdeAPluginTopLevelMenuContribution { id: string; label: string; topLevel: true; order?: number; icon?: string; } export interface IdeAPluginMenuItemContribution { id: string; targetMenuId: string; label: string; command: string; order?: number; icon?: string; when?: string; } export interface IdeAPluginLayoutContribution { type: string; label: string; component: string; order?: number; icon?: string; when?: string; } export interface IdeAPluginMcpServerContribution { id: string; displayName: string; command: string; args?: string[]; env?: Record; cwd?: string; transport: "stdio"; autoStart?: boolean; allowAbsoluteCommand?: boolean; } export type PluginManifestValidationResult = | { success: true; data: IdeAPluginManifest; errors: [] } | { success: false; data?: undefined; errors: string[] }; const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; export function validatePluginManifest(input: unknown): PluginManifestValidationResult { const errors: string[] = []; if (!isRecord(input)) { return { success: false, errors: ["manifest must be an object"] }; } if (input.ideaPluginManifestVersion !== 1) { errors.push("ideaPluginManifestVersion must be 1"); } requireString(input, "id", errors); requireString(input, "displayName", errors); requireString(input, "version", errors); requireString(input, "main", errors); if (input.trustLevel !== "full") { errors.push("trustLevel must be full"); } optionalString(input, "description", errors); optionalString(input, "publisher", errors); if (typeof input.id === "string" && !PLUGIN_ID_PATTERN.test(input.id)) { errors.push("id must contain lowercase letters, digits, dots or dashes, and start/end with an alphanumeric character"); } if (typeof input.version === "string" && !SEMVER_PATTERN.test(input.version)) { errors.push("version must use semver syntax, for example 0.1.0"); } validateEngines(input.engines, errors); validateCapabilities(input.capabilities, errors); validateActivationScope(input.activationScope, errors); validateContributes(input.contributes, errors); if (errors.length > 0) { return { success: false, errors }; } return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] }; } export function isPluginManifest(input: unknown): input is IdeAPluginManifest { return validatePluginManifest(input).success; } export function assertPluginManifest(input: unknown): asserts input is IdeAPluginManifest { const result = validatePluginManifest(input); if (!result.success) { throw new Error(`Invalid IdeA plugin manifest: ${result.errors.join("; ")}`); } } function validateEngines(value: unknown, errors: string[]): void { if (value === undefined) { return; } if (!isRecord(value)) { errors.push("engines must be an object when provided"); return; } optionalString(value, "idea", errors, "engines.idea"); } function validateCapabilities(value: unknown, errors: string[]): void { if (value === undefined) { return; } if (!Array.isArray(value)) { errors.push("capabilities must be an array when provided"); return; } value.forEach((capability, index) => { if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") { errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`); } }); } function validateActivationScope(value: unknown, errors: string[]): void { if (value === undefined) { return; } if (value !== "app" && value !== "project") { errors.push("activationScope must be \"app\" or \"project\" when provided"); } } function validateContributes(value: unknown, errors: string[]): void { if (value === undefined) { return; } if (!isRecord(value)) { errors.push("contributes must be an object when provided"); return; } validateArray(value, "menus", errors, (menu, index) => { requireString(menu, "id", errors, `contributes.menus[${index}].id`); requireString(menu, "label", errors, `contributes.menus[${index}].label`); if (menu.topLevel !== true) { errors.push(`contributes.menus[${index}].topLevel must be true`); } optionalNumber(menu, "order", errors, `contributes.menus[${index}].order`); optionalString(menu, "icon", errors, `contributes.menus[${index}].icon`); }); validateArray(value, "menuItems", errors, (item, index) => { requireString(item, "id", errors, `contributes.menuItems[${index}].id`); requireString(item, "targetMenuId", errors, `contributes.menuItems[${index}].targetMenuId`); requireString(item, "label", errors, `contributes.menuItems[${index}].label`); requireString(item, "command", errors, `contributes.menuItems[${index}].command`); optionalNumber(item, "order", errors, `contributes.menuItems[${index}].order`); optionalString(item, "icon", errors, `contributes.menuItems[${index}].icon`); optionalString(item, "when", errors, `contributes.menuItems[${index}].when`); }); validateArray(value, "layouts", errors, (layout, index) => { requireString(layout, "type", errors, `contributes.layouts[${index}].type`); requireString(layout, "label", errors, `contributes.layouts[${index}].label`); requireString(layout, "component", errors, `contributes.layouts[${index}].component`); optionalNumber(layout, "order", errors, `contributes.layouts[${index}].order`); optionalString(layout, "icon", errors, `contributes.layouts[${index}].icon`); optionalString(layout, "when", errors, `contributes.layouts[${index}].when`); }); validateArray(value, "mcpServers", errors, (server, index) => { requireString(server, "id", errors, `contributes.mcpServers[${index}].id`); requireString(server, "displayName", errors, `contributes.mcpServers[${index}].displayName`); requireString(server, "command", errors, `contributes.mcpServers[${index}].command`); if (server.transport !== "stdio") { errors.push(`contributes.mcpServers[${index}].transport must be "stdio"`); } optionalStringArray(server, "args", errors, `contributes.mcpServers[${index}].args`); optionalStringRecord(server, "env", errors, `contributes.mcpServers[${index}].env`); optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`); optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`); optionalBoolean(server, "allowAbsoluteCommand", errors, `contributes.mcpServers[${index}].allowAbsoluteCommand`); }); } function validateArray( record: Record, key: string, errors: string[], validateItem: (item: Record, index: number) => void ): void { const value = record[key]; if (value === undefined) { return; } if (!Array.isArray(value)) { errors.push(`contributes.${key} must be an array when provided`); return; } value.forEach((item, index) => { if (!isRecord(item)) { errors.push(`contributes.${key}[${index}] must be an object`); return; } validateItem(item, index); }); } function requireString( record: Record, key: string, errors: string[], label = key ): void { if (typeof record[key] !== "string" || record[key].trim().length === 0) { errors.push(`${label} must be a non-empty string`); } } function optionalString( record: Record, key: string, errors: string[], label = key ): void { if (record[key] !== undefined && typeof record[key] !== "string") { errors.push(`${label} must be a string when provided`); } } function optionalNumber( record: Record, key: string, errors: string[], label = key ): void { if (record[key] !== undefined && typeof record[key] !== "number") { errors.push(`${label} must be a number when provided`); } } function optionalBoolean( record: Record, key: string, errors: string[], label = key ): void { if (record[key] !== undefined && typeof record[key] !== "boolean") { errors.push(`${label} must be a boolean when provided`); } } function optionalStringArray( record: Record, key: string, errors: string[], label = key ): void { const value = record[key]; if (value === undefined) { return; } if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { errors.push(`${label} must be an array of strings when provided`); } } function optionalStringRecord( record: Record, key: string, errors: string[], label = key ): void { const value = record[key]; if (value === undefined) { return; } if (!isRecord(value) || Object.values(value).some((item) => typeof item !== "string")) { errors.push(`${label} must be an object of strings when provided`); } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }