UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

617 lines 20.5 kB
/** * Zod Validation Schemas for Extension Types * * Provides strict runtime validation for all extension types with helpful * error messages. Ensures extension manifests conform to the unified schema. * * @implements @.aiwg/requirements/use-cases/UC-003-extension-validation.md * @architecture @.aiwg/architecture/unified-extension-schema.md * @tests @test/unit/extensions/validation.test.ts * @version 1.0.0 */ import { z } from 'zod'; // ============================================ // Core Enums & Primitives // ============================================ /** * Extension type discriminator */ export const ExtensionTypeSchema = z.enum([ 'agent', 'command', 'skill', 'hook', 'tool', 'mcp-server', 'framework', 'addon', 'template', 'prompt', ]); /** * Extension lifecycle status */ export const ExtensionStatusSchema = z.enum([ 'stable', 'beta', 'experimental', 'deprecated', 'archived', ]); /** * Platform support level */ export const PlatformSupportSchema = z.enum([ 'full', 'partial', 'experimental', 'none', ]); /** * Hook lifecycle events */ export const HookEventSchema = z.enum([ 'pre-session', 'post-session', 'pre-command', 'post-command', 'pre-agent', 'post-agent', 'pre-write', 'post-write', 'pre-bash', 'post-bash', ]); // ============================================ // Shared Schemas // ============================================ /** * Platform compatibility matrix */ export const PlatformCompatibilitySchema = z.object({ claude: PlatformSupportSchema.optional(), factory: PlatformSupportSchema.optional(), cursor: PlatformSupportSchema.optional(), copilot: PlatformSupportSchema.optional(), windsurf: PlatformSupportSchema.optional(), codex: PlatformSupportSchema.optional(), opencode: PlatformSupportSchema.optional(), generic: PlatformSupportSchema.optional(), }).refine((data) => Object.values(data).some((val) => val !== undefined), { message: 'At least one platform must be specified' }); /** * Deployment configuration */ export const DeploymentConfigSchema = z.object({ pathTemplate: z.string().min(1, 'Path template is required'), pathOverrides: z.record(z.string()).optional(), additionalFiles: z.array(z.string()).optional(), autoInstall: z.boolean().optional().default(false), core: z.boolean().optional().default(false), }); /** * A single .aiwg/ path entry in a memory footprint */ export const MemoryPathSchema = z.object({ path: z.string().min(1, 'Memory path is required'), description: z.string().min(1, 'Memory path description is required'), }); /** * .aiwg/ memory footprint declaration */ export const MemoryFootprintSchema = z.object({ creates: z.array(MemoryPathSchema).optional(), normalizedFiles: z.array(MemoryPathSchema).optional(), }); /** * Deprecation information */ export const DeprecationSchema = z.object({ date: z.string().datetime({ message: 'Date must be ISO 8601 format' }), successor: z.string().optional(), reason: z.string().min(1, 'Deprecation reason is required'), }); /** * Installation state */ export const InstallationSchema = z.object({ installedAt: z.string().datetime({ message: 'Install timestamp must be ISO 8601 format' }), installedFrom: z.enum(['builtin', 'registry', 'local', 'git']), installedPath: z.string().min(1), enabled: z.boolean(), }); /** * Signature verification */ export const SignatureSchema = z.object({ algorithm: z.enum(['pgp', 'ed25519']), value: z.string().min(1), publicKey: z.string().optional(), }); // ============================================ // Type-Specific Metadata Schemas // ============================================ /** * Agent-specific metadata */ export const AgentMetadataSchema = z.object({ type: z.literal('agent'), role: z.string().min(1, 'Agent role is required'), model: z.object({ tier: z.enum(['haiku', 'sonnet', 'opus']), override: z.string().optional(), }), tools: z.array(z.string()).min(1, 'At least one tool is required'), template: z.string().optional(), maxTools: z.number().int().positive().optional(), canDelegate: z.boolean().optional(), readOnly: z.boolean().optional(), workflow: z.array(z.string()).optional(), expertise: z.array(z.string()).optional(), responsibilities: z.array(z.string()).optional(), }); /** * Command argument definition */ export const CommandArgumentSchema = z.object({ name: z.string().min(1), description: z.string().min(1), required: z.boolean(), type: z.enum(['string', 'number', 'boolean']), default: z.union([z.string(), z.number(), z.boolean()]).optional(), position: z.number().int().nonnegative().optional(), }); /** * Command option definition */ export const CommandOptionSchema = z.object({ name: z.string().min(1), description: z.string().min(1), type: z.enum(['string', 'boolean', 'number', 'array']), default: z.union([z.string(), z.boolean(), z.number()]).optional(), short: z.string().length(1).optional(), long: z.string().min(2).optional(), }); /** * Command-specific metadata * * @deprecated As a source format — commands are generated from skills at deploy time. */ export const CommandMetadataSchema = z.object({ type: z.literal('command'), template: z.enum(['utility', 'transformation', 'orchestration']), arguments: z.array(CommandArgumentSchema).optional(), options: z.array(CommandOptionSchema).optional(), argumentHint: z.string().optional(), allowedTools: z.array(z.string()).optional(), model: z.string().optional(), executionSteps: z.array(z.string()).optional(), successCriteria: z.array(z.string()).optional(), cliDisabled: z.boolean().optional(), executedViaSkillRunner: z.boolean().optional(), generatedFrom: z.string().optional(), }); /** * Command translation hints for skill→command generation */ export const CommandHintSchema = z.object({ argumentHint: z.string().optional(), allowedTools: z.array(z.string()).optional(), template: z.enum(['utility', 'transformation', 'orchestration']).optional(), arguments: z.array(CommandArgumentSchema).optional(), options: z.array(CommandOptionSchema).optional(), executionSteps: z.array(z.string()).optional(), successCriteria: z.array(z.string()).optional(), model: z.string().optional(), cliDisabled: z.boolean().optional(), executedViaSkillRunner: z.boolean().optional(), }); /** * Skill reference material */ export const SkillReferenceSchema = z.object({ filename: z.string().min(1), description: z.string().min(1), path: z.string().min(1), }); /** * SKILL.md frontmatter schema * * Validates the YAML frontmatter block at the top of a SKILL.md file. * This is distinct from {@link SkillMetadataSchema}, which validates the * `metadata` field of an Extension manifest. * * `description` is REQUIRED and must be non-empty. Codex rejects SKILL.md * files that lack a description; Claude Code uses this field for * natural-language invocation. Do NOT relax this rule — a blank description * is what caused the 107-file regression we are guarding against. */ export const SkillFrontmatterSchema = z.object({ name: z.string().min(1, 'Skill name is required'), description: z .string({ required_error: 'Description is required (Codex rejects SKILL.md without it)', }) .min(1, 'Description is required and must be non-empty'), version: z.string().regex(/^\d+\.\d+\.\d+/, 'Version must be semver or CalVer format (e.g., 1.0.0)').optional(), namespace: z.string().optional(), platforms: z.union([z.array(z.string()), z.string()]).optional(), triggers: z.array(z.string()).optional(), aliases: z.array(z.string()).optional(), deprecated_names: z.array(z.string()).optional(), tools: z.union([z.array(z.string()), z.string()]).optional(), 'allowed-tools': z.union([z.array(z.string()), z.string()]).optional(), allowedTools: z.union([z.array(z.string()), z.string()]).optional(), effort: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), 'user-invocable': z.boolean().optional(), userInvocable: z.boolean().optional(), 'disable-model-invocation': z.boolean().optional(), disableModelInvocation: z.boolean().optional(), context: z.enum(['fork', 'inherit']).optional(), author: z.string().optional(), license: z.string().optional(), metadata: z.record(z.unknown()).optional(), }).passthrough(); /** * Validate SKILL.md frontmatter. * * Use this when parsing the YAML frontmatter block of a SKILL.md file to * catch missing or empty `description` fields before deployment. * * @example * ```typescript * const result = validateSkillFrontmatter(parsedYaml); * if (!result.success) { * throw new Error('Invalid SKILL.md frontmatter: ' + * formatValidationErrors(result.errors).join(', ')); * } * ``` */ export function validateSkillFrontmatter(data) { const result = SkillFrontmatterSchema.safeParse(data); return result.success ? { success: true, data: result.data } : { success: false, errors: result.error }; } /** * Skill-specific metadata */ export const SkillMetadataSchema = z.object({ type: z.literal('skill'), triggerPhrases: z.array(z.string()).min(1, 'At least one trigger phrase is required'), autoTrigger: z.boolean().optional().default(false), autoTriggerConditions: z.array(z.string()).optional(), tools: z.array(z.string()).optional(), references: z.array(SkillReferenceSchema).optional(), inputRequirements: z.array(z.string()).optional(), outputFormat: z.string().optional(), // Official Claude Code SKILL.md frontmatter effort: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), userInvocable: z.boolean().optional(), disableModelInvocation: z.boolean().optional(), context: z.enum(['fork', 'inherit']).optional(), allowedTools: z.array(z.string()).optional(), // Command translation hints commandHint: CommandHintSchema.optional(), }); /** * Hook-specific metadata */ export const HookMetadataSchema = z.object({ type: z.literal('hook'), event: HookEventSchema, priority: z.number().int().optional().default(100), canModify: z.boolean().optional().default(false), canBlock: z.boolean().optional().default(false), configSchema: z.record(z.unknown()).optional(), }); /** * Tool-specific metadata */ export const ToolMetadataSchema = z.object({ type: z.literal('tool'), category: z.enum(['core', 'languages', 'utilities', 'custom']), executable: z.string().min(1, 'Executable path is required'), verificationStatus: z.enum(['verified', 'unverified']).optional(), lastVerified: z.string().datetime().optional(), manPage: z.string().optional(), aliases: z.array(z.string()).optional(), relatedTools: z.array(z.string()).optional(), platformNotes: z.record(z.string()).optional(), installHint: z.string().optional(), }); /** * MCP tool summary */ export const MCPToolSummarySchema = z.object({ name: z.string().min(1), description: z.string().min(1), dangerous: z.boolean(), }); /** * MCP Server-specific metadata */ export const MCPServerMetadataSchema = z.object({ type: z.literal('mcp-server'), mcpVersion: z.string().min(1, 'MCP version is required'), transport: z.enum(['stdio', 'http']), port: z.number().int().positive().optional(), capabilities: z.object({ tools: z.boolean(), resources: z.boolean(), prompts: z.boolean(), sampling: z.boolean(), logging: z.boolean(), }), sourceType: z.enum(['cli', 'api', 'catalog', 'nl', 'extension']), sourceCommand: z.string().optional(), sourceBaseUrl: z.string().url().optional(), workingDirectory: z.string().optional(), environment: z.record(z.string()).optional(), tools: z.array(MCPToolSummarySchema).optional(), resources: z.array(z.string()).optional(), prompts: z.array(z.string()).optional(), }); /** * Framework-specific metadata */ export const FrameworkMetadataSchema = z.object({ type: z.literal('framework'), domain: z.string().min(1, 'Framework domain is required'), includes: z.object({ agents: z.array(z.string()).optional(), commands: z.array(z.string()).optional(), skills: z.array(z.string()).optional(), hooks: z.array(z.string()).optional(), templates: z.array(z.string()).optional(), prompts: z.array(z.string()).optional(), }), configSchema: z.record(z.unknown()).optional(), defaultConfig: z.record(z.unknown()).optional(), }); /** * Addon-specific metadata */ export const AddonMetadataSchema = z.object({ type: z.literal('addon'), entry: z.object({ agents: z.string().optional(), commands: z.string().optional(), skills: z.string().optional(), hooks: z.string().optional(), templates: z.string().optional(), prompts: z.string().optional(), }), provides: z.object({ agents: z.array(z.string()).optional(), commands: z.array(z.string()).optional(), skills: z.array(z.string()).optional(), hooks: z.array(z.string()).optional(), templates: z.array(z.string()).optional(), prompts: z.array(z.string()).optional(), }), }); /** * Template variable definition */ export const TemplateVariableSchema = z.object({ name: z.string().min(1), description: z.string().min(1), type: z.enum(['string', 'number', 'boolean', 'array', 'object']), required: z.boolean(), default: z.unknown().optional(), }); /** * Template-specific metadata */ export const TemplateMetadataSchema = z.object({ type: z.literal('template'), format: z.string().min(1, 'Template format is required'), variables: z.array(TemplateVariableSchema).optional(), sections: z.array(z.string()).optional(), targetArtifact: z.string().optional(), }); /** * Prompt-specific metadata */ export const PromptMetadataSchema = z.object({ type: z.literal('prompt'), category: z.string().min(1, 'Prompt category is required'), purpose: z.string().min(1, 'Prompt purpose is required'), useWhen: z.array(z.string()).min(1, 'At least one use-when condition is required'), variables: z.array(z.string()).optional(), requiredContext: z.array(z.string()).optional(), }); /** * Union of all type-specific metadata schemas */ export const ExtensionMetadataSchema = z.discriminatedUnion('type', [ AgentMetadataSchema, CommandMetadataSchema, SkillMetadataSchema, HookMetadataSchema, ToolMetadataSchema, MCPServerMetadataSchema, FrameworkMetadataSchema, AddonMetadataSchema, TemplateMetadataSchema, PromptMetadataSchema, ]); // ============================================ // Base Extension Schema // ============================================ /** * Complete extension schema * * Validates all required and optional fields according to the unified schema. */ export const ExtensionSchema = z.object({ // Core Identity id: z.string().regex(/^[a-z][a-z0-9-]*$/, 'ID must be kebab-case (lowercase, hyphens only)'), type: ExtensionTypeSchema, name: z.string().min(1, 'Name is required'), description: z.string().min(1, 'Description is required'), version: z.string().regex(/^\d+\.\d+\.\d+/, 'Version must be semver or CalVer format (e.g., 1.0.0 or 2026.1.5)'), // Discovery & Classification capabilities: z.array(z.string()).min(1, 'At least one capability is required'), keywords: z.array(z.string()).min(1, 'At least one keyword is required'), category: z.string().optional(), // Platform & Deployment platforms: PlatformCompatibilitySchema, deployment: DeploymentConfigSchema, // Dependencies & Requirements requires: z.array(z.string()).optional(), recommends: z.array(z.string()).optional(), conflicts: z.array(z.string()).optional(), systemDependencies: z.record(z.string()).optional(), // Metadata & Documentation author: z.string().optional(), license: z.string().optional(), repository: z.string().url().optional(), homepage: z.string().url().optional(), bugs: z.string().url().optional(), documentation: z.record(z.string()).optional(), researchCompliance: z.record(z.array(z.string())).optional(), memory: MemoryFootprintSchema.optional(), // Type-Specific Data metadata: ExtensionMetadataSchema, // Lifecycle & Status status: ExtensionStatusSchema.optional().default('stable'), deprecation: DeprecationSchema.optional(), installation: InstallationSchema.optional(), // Validation & Quality checksum: z.string().optional(), signature: SignatureSchema.optional(), }).refine((data) => data.type === data.metadata.type, { message: 'Extension type must match metadata type' }); /** * Validate an extension manifest * * @param data - Unknown data to validate * @returns Validation result with typed data or errors * * @example * ```typescript * const result = validateExtension(data); * if (result.success) { * console.log('Valid extension:', result.data.name); * } else { * console.error('Validation errors:', result.errors.format()); * } * ``` */ export function validateExtension(data) { const result = ExtensionSchema.safeParse(data); if (result.success) { return { success: true, data: result.data, }; } else { return { success: false, errors: result.error, }; } } /** * Type guard to check if data is a valid extension * * @param data - Unknown data to check * @returns True if data is a valid extension * * @example * ```typescript * if (isValidExtension(data)) { * // TypeScript knows data is ValidatedExtension here * console.log(data.name); * } * ``` */ export function isValidExtension(data) { return ExtensionSchema.safeParse(data).success; } /** * Validate and throw on error * * Use this when you want validation errors to propagate as exceptions. * * @param data - Unknown data to validate * @returns Validated extension data * @throws {z.ZodError} If validation fails * * @example * ```typescript * try { * const extension = validateExtensionStrict(data); * console.log('Valid extension:', extension.name); * } catch (error) { * if (error instanceof z.ZodError) { * console.error('Validation failed:', error.format()); * } * } * ``` */ export function validateExtensionStrict(data) { return ExtensionSchema.parse(data); } /** * Validate extension metadata only * * Useful for validating type-specific metadata in isolation. * * @param data - Unknown metadata to validate * @returns Validation result */ export function validateExtensionMetadata(data) { const result = ExtensionMetadataSchema.safeParse(data); if (result.success) { return { success: true, data: result.data }; } else { return { success: false, errors: result.error }; } } /** * Format validation errors for display * * Converts Zod errors into human-readable messages. * * @param errors - Zod validation errors * @returns Formatted error messages * * @example * ```typescript * const result = validateExtension(data); * if (!result.success) { * const messages = formatValidationErrors(result.errors); * console.error('Validation failed:\n' + messages.join('\n')); * } * ``` */ export function formatValidationErrors(errors) { return errors.issues.map((issue) => { const path = issue.path.join('.'); return `${path}: ${issue.message}`; }); } /** * Check if extension matches expected type * * Type-safe way to validate extension type before accessing type-specific fields. * * @param extension - Validated extension * @param type - Expected extension type * @returns True if extension matches type * * @example * ```typescript * if (isExtensionType(extension, 'agent')) { * // TypeScript narrows metadata to AgentMetadata * console.log(extension.metadata.role); * } * ``` */ export function isExtensionType(extension, type) { return extension.type === type && extension.metadata.type === type; } //# sourceMappingURL=validation.js.map