Skip to main content
Glama
manifest.ts43.4 kB
/** * WP Navigator Manifest Schema and Loader * * Phase B1: wpnavigator.jsonc manifest for site configuration. * Supports JSONC (JSON with comments) for documentation. * * @package WP_Navigator_MCP * @since 1.1.0 */ import * as fs from 'fs'; import * as path from 'path'; // ============================================================================= // Schema Version // ============================================================================= /** * Current schema version (integer) * - schema_version: 1 = initial release (v2.0-v2.6) * - schema_version: 2 = v2.7.0 with tools/roles/ai sections * Used for compatibility checking and fail-fast validation */ export const CURRENT_SCHEMA_VERSION = 2; /** * Current manifest schema version (semver string - legacy) * @deprecated Use CURRENT_SCHEMA_VERSION instead */ export const MANIFEST_SCHEMA_VERSION = '1.0'; /** * Minimum supported manifest version for backwards compatibility (legacy) * @deprecated Use CURRENT_SCHEMA_VERSION instead */ export const MIN_MANIFEST_VERSION = '1.0'; // ============================================================================= // Schema Types // ============================================================================= /** * Site metadata */ export interface ManifestMeta { /** Site name for display and identification */ name: string; /** Site description */ description?: string; /** Site URL (informational, config has authoritative URL) */ url?: string; /** Additional metadata tags */ tags?: string[]; } /** * Color palette configuration */ export interface BrandPalette { /** Primary brand color (hex) */ primary?: string; /** Secondary brand color (hex) */ secondary?: string; /** Accent color for CTAs and highlights (hex) */ accent?: string; /** Neutral color for text and backgrounds (hex) */ neutral?: string; /** Additional named colors */ [key: string]: string | undefined; } /** * Typography configuration */ export interface BrandFonts { /** Heading font family */ heading?: string; /** Body text font family */ body?: string; /** Monospace font family */ mono?: string; /** Font fallback stack */ fallback?: string; } /** * Layout configuration */ export interface BrandLayout { /** Container max width (e.g., "1200px", "80rem") */ containerWidth?: string; /** Spacing density: compact, comfortable, spacious */ spacing?: 'compact' | 'comfortable' | 'spacious'; /** Border radius style: none, subtle, rounded, pill */ borderRadius?: 'none' | 'subtle' | 'rounded' | 'pill'; } /** * Voice and tone guidance for AI content generation */ export interface BrandVoice { /** Overall tone: professional, casual, friendly, technical, etc. */ tone?: string; /** Target audience description */ audience?: string; /** Key brand values or personality traits */ values?: string[]; /** Words/phrases to avoid */ avoid?: string[]; } /** * Brand configuration section * All fields optional with sensible defaults */ export interface ManifestBrand { /** Color palette */ palette?: BrandPalette; /** Typography settings */ fonts?: BrandFonts; /** Layout preferences */ layout?: BrandLayout; /** Voice and tone guidance */ voice?: BrandVoice; } /** * Page definition in manifest */ export interface ManifestPage { /** URL slug (e.g., "about", "contact") */ slug: string; /** Page title */ title: string; /** Path to template/snapshot file (Phase B2) */ template?: string; /** Target publish status */ status?: 'publish' | 'draft' | 'private' | 'pending'; /** Parent page slug for hierarchical pages */ parent?: string; /** Menu order for sorting */ menu_order?: number; /** SEO meta description */ meta_description?: string; /** Additional page metadata (extensible for B2) */ [key: string]: unknown; } /** * Plugin configuration in manifest */ export interface ManifestPlugin { /** Whether plugin should be active */ enabled: boolean; /** Plugin-specific settings to apply */ settings?: Record<string, unknown>; /** Default values for plugin options */ defaults?: Record<string, unknown>; /** Required plugin version (semver range) */ version?: string; } /** * Plugins section - keyed by plugin slug */ export interface ManifestPlugins { [pluginSlug: string]: ManifestPlugin; } /** * Backup reminder frequency options */ export type BackupReminderFrequency = 'first_sync_only' | 'always' | 'daily' | 'never'; /** * Backup reminder configuration */ export interface BackupReminders { /** Enable backup reminders (default: true) */ enabled?: boolean; /** Show reminder before sync operations (default: true) */ before_sync?: boolean; /** How often to show reminders (default: 'first_sync_only') */ frequency?: BackupReminderFrequency; } /** * Safety block for manifest operations * Controls what sync/apply operations are allowed */ export interface ManifestSafety { /** Allow creating new pages from manifest */ allow_create_pages?: boolean; /** Allow updating existing pages */ allow_update_pages?: boolean; /** Allow deleting pages not in manifest */ allow_delete_pages?: boolean; /** Allow plugin activation/deactivation */ allow_plugin_changes?: boolean; /** Allow theme changes */ allow_theme_changes?: boolean; /** Require confirmation for destructive operations (default: true) */ require_confirmation?: boolean; /** Require confirmation specifically for sync operations (default: true) */ require_sync_confirmation?: boolean; /** Whether user has acknowledged first sync warning (default: false) */ first_sync_acknowledged?: boolean; /** Backup reminder configuration */ backup_reminders?: BackupReminders; } /** * Root wpnavigator.jsonc manifest schema */ export interface WPNavManifest { /** Schema version as integer (required, currently 1) */ schema_version: number; /** * Schema version for compatibility checking (required) * @deprecated Use schema_version instead. Will be removed in v2.0. */ manifest_version: string; /** Site metadata */ meta: ManifestMeta; /** Brand configuration */ brand?: ManifestBrand; /** Page definitions */ pages?: ManifestPage[]; /** Plugin configurations */ plugins?: ManifestPlugins; /** Safety constraints for sync operations */ safety?: ManifestSafety; } // ============================================================================= // Schema Version 2 Types (v2.7.0+) // ============================================================================= /** * Valid tool category strings (matches ToolCategory enum values) */ export type ToolCategoryString = | 'core' | 'content' | 'taxonomy' | 'users' | 'plugins' | 'themes' | 'workflows' | 'cookbook' | 'roles' | 'batch'; /** * Cookbook binding configuration */ export interface ManifestCookbookBinding { /** Cookbooks to explicitly load */ load?: string[]; /** Auto-detect cookbooks based on active plugins (default: true) */ auto_detect?: boolean; /** Path to project-local cookbooks directory */ project_path?: string; } /** * Tools section for Schema v2 * Controls which tools are available to the AI */ export interface ManifestTools { /** Tool categories to enable (default: all) */ enabled?: ToolCategoryString[]; /** Tool categories to disable (overrides enabled) */ disabled?: ToolCategoryString[]; /** Individual tool overrides: { "wpnav_delete_post": false } */ overrides?: Record<string, boolean>; /** Cookbook configuration */ cookbooks?: ManifestCookbookBinding; } /** * Role override configuration */ export interface ManifestRoleOverrides { /** Additional tools to allow beyond role defaults */ tools_allow?: string[]; /** Tools to deny even if role allows them */ tools_deny?: string[]; } /** * Roles section for Schema v2 * Controls AI persona and tool access */ export interface ManifestRoles { /** Active role name (e.g., 'content-editor', 'developer') */ active?: string; /** Auto-detect appropriate role based on user capabilities (default: true) */ auto_detect?: boolean; /** Path to project-local roles directory */ project_path?: string; /** Role-specific overrides */ overrides?: ManifestRoleOverrides; } /** * AI focus modes for token reduction */ export type AIFocusMode = 'content-editing' | 'full-admin' | 'read-only' | 'custom'; /** * AI configuration section for Schema v2 * Controls AI behavior and context */ export interface ManifestAI { /** Focus mode for token reduction (default: 'content-editing') */ focus?: AIFocusMode; /** Custom AI instructions to include in context */ instructions?: string; /** Path to sample prompts directory */ prompts_path?: string; /** Auto-detected active plugins (read-only, populated by introspect) */ detected_plugins?: string[]; /** Detected page builder (read-only, populated by introspect) */ page_builder?: string; /** Recommended cookbooks based on detected plugins (read-only) */ recommended_cookbooks?: string[]; /** Recommended role based on user capabilities (read-only) */ recommended_role?: string; } /** * Safety mode presets for Schema v2 */ export type SafetyMode = 'yolo' | 'normal' | 'cautious'; /** * Operation types that can be controlled */ export type OperationType = 'create' | 'update' | 'delete' | 'activate' | 'deactivate' | 'batch'; /** * Enhanced safety settings for Schema v2 * Extends ManifestSafety with new fields */ export interface ManifestSafetyV2 extends ManifestSafety { /** Safety preset mode (default: 'cautious') */ mode?: SafetyMode; /** Maximum items in batch operations (default: 10) */ max_batch_size?: number; /** Operations to allow (default: ['create', 'update']) */ allowed_operations?: OperationType[]; /** Operations to block (overrides allowed, default: ['delete']) */ blocked_operations?: OperationType[]; } /** * Environment-specific configuration override * Can override any top-level manifest section */ export interface ManifestEnvironmentOverride { /** Override tools configuration */ tools?: ManifestTools; /** Override roles configuration */ roles?: ManifestRoles; /** Override AI configuration */ ai?: ManifestAI; /** Override safety configuration */ safety?: ManifestSafetyV2; } /** * Environment overrides section for Schema v2 * Keys are environment names (e.g., 'local', 'staging', 'production') */ export interface ManifestEnvironments { [envName: string]: ManifestEnvironmentOverride; } /** * Schema v2 manifest (v2.7.0+) * Extends v1 with tools, roles, ai, and env sections */ export interface WPNavManifestV2 extends Omit<WPNavManifest, 'safety'> { /** Schema version (must be 2 for v2 features) */ schema_version: 2; /** Optional JSON Schema URL for IDE support */ $schema?: string; /** Tool access configuration */ tools?: ManifestTools; /** Role configuration */ roles?: ManifestRoles; /** AI behavior configuration */ ai?: ManifestAI; /** Enhanced safety settings (v2) */ safety?: ManifestSafetyV2; /** Environment-specific overrides */ env?: ManifestEnvironments; } /** * Runtime manifest type that can be either v1 or v2 */ export type WPNavManifestRuntime = WPNavManifest | WPNavManifestV2; // ============================================================================= // Schema v2 Type Guards // ============================================================================= /** * Check if a manifest is Schema v2 */ export function isManifestV2(manifest: WPNavManifestRuntime): manifest is WPNavManifestV2 { return manifest.schema_version >= 2; } /** * Check if a value is a valid tool category string */ export function isToolCategoryString(value: unknown): value is ToolCategoryString { const validCategories: ToolCategoryString[] = [ 'core', 'content', 'taxonomy', 'users', 'plugins', 'themes', 'workflows', 'cookbook', 'roles', 'batch', ]; return typeof value === 'string' && validCategories.includes(value as ToolCategoryString); } /** * Check if a value is a valid AI focus mode */ export function isAIFocusMode(value: unknown): value is AIFocusMode { const validModes: AIFocusMode[] = ['content-editing', 'full-admin', 'read-only', 'custom']; return typeof value === 'string' && validModes.includes(value as AIFocusMode); } /** * Check if a value is a valid safety mode */ export function isSafetyMode(value: unknown): value is SafetyMode { const validModes: SafetyMode[] = ['yolo', 'normal', 'cautious']; return typeof value === 'string' && validModes.includes(value as SafetyMode); } /** * Check if a value is a valid operation type */ export function isOperationType(value: unknown): value is OperationType { const validTypes: OperationType[] = [ 'create', 'update', 'delete', 'activate', 'deactivate', 'batch', ]; return typeof value === 'string' && validTypes.includes(value as OperationType); } // ============================================================================= // JSONC Parser // ============================================================================= /** * Strip comments from JSONC content * * Supports: * - Single-line comments: // comment * - Multi-line comments: /* comment * / * * Preserves strings containing comment-like sequences */ export function stripJsonComments(jsonc: string): string { let result = ''; let i = 0; let inString = false; let stringChar = ''; while (i < jsonc.length) { const char = jsonc[i]; const nextChar = jsonc[i + 1]; // Handle string boundaries if (!inString && (char === '"' || char === "'")) { inString = true; stringChar = char; result += char; i++; continue; } if (inString) { // Check for escape sequences if (char === '\\' && i + 1 < jsonc.length) { result += char + jsonc[i + 1]; i += 2; continue; } // Check for string end if (char === stringChar) { inString = false; stringChar = ''; } result += char; i++; continue; } // Handle single-line comments if (char === '/' && nextChar === '/') { // Skip until end of line while (i < jsonc.length && jsonc[i] !== '\n') { i++; } continue; } // Handle multi-line comments if (char === '/' && nextChar === '*') { i += 2; // Skip /* // Find closing */ while (i < jsonc.length - 1) { if (jsonc[i] === '*' && jsonc[i + 1] === '/') { i += 2; // Skip */ break; } i++; } continue; } result += char; i++; } return result; } /** * Parse JSONC content (JSON with comments) * * @param content - JSONC string content * @returns Parsed JSON object * @throws Error if JSON is invalid after stripping comments */ export function parseJsonc<T = unknown>(content: string): T { const stripped = stripJsonComments(content); return JSON.parse(stripped) as T; } // ============================================================================= // Manifest Validation // ============================================================================= /** * Error thrown when manifest validation fails */ export class ManifestValidationError extends Error { constructor( message: string, public readonly path?: string, public readonly field?: string ) { super(message); this.name = 'ManifestValidationError'; } } /** * Check if a version string is valid semver-like (major.minor or major.minor.patch) */ function isValidVersion(version: string): boolean { return /^\d+\.\d+(\.\d+)?$/.test(version); } /** * Compare two version strings * Returns: -1 if a < b, 0 if a == b, 1 if a > b */ function compareVersions(a: string, b: string): number { const aParts = a.split('.').map(Number); const bParts = b.split('.').map(Number); for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { const aVal = aParts[i] ?? 0; const bVal = bParts[i] ?? 0; if (aVal < bVal) return -1; if (aVal > bVal) return 1; } return 0; } /** * Error class for unsupported schema version * Use exit code 2 for schema version errors (vs exit code 1 for other errors) */ export class SchemaVersionError extends ManifestValidationError { /** Exit code for schema version errors */ readonly exitCode = 2; /** Instructions for how to fix the error */ readonly upgradeInstructions: string; constructor(message: string, filePath: string, instructions: string) { super(message, filePath, 'schema_version'); this.name = 'SchemaVersionError'; this.upgradeInstructions = instructions; } } /** * Validate manifest structure and version compatibility */ export function validateManifest(manifest: unknown, filePath: string): WPNavManifest { if (!manifest || typeof manifest !== 'object') { throw new ManifestValidationError('Manifest must be a JSON object', filePath); } const obj = manifest as Record<string, unknown>; // Validate schema_version (required, integer) if (obj.schema_version === undefined) { throw new SchemaVersionError( 'schema_version is missing', filePath, 'Add "schema_version": 1 to your manifest file.' ); } if (typeof obj.schema_version !== 'number' || !Number.isInteger(obj.schema_version)) { throw new SchemaVersionError( `Invalid schema_version: expected integer, got ${typeof obj.schema_version}`, filePath, 'Set "schema_version": 1 (must be an integer, not a string).' ); } if (obj.schema_version > CURRENT_SCHEMA_VERSION) { throw new SchemaVersionError( `Unsupported manifest schema_version: ${obj.schema_version}`, filePath, `This version of wpnav only understands schema_version ${CURRENT_SCHEMA_VERSION}.\n\nTo fix this:\n• Update the wpnav CLI to the latest version, or\n• Downgrade your manifest schema to ${CURRENT_SCHEMA_VERSION} (if safe and intentional).` ); } if (obj.schema_version < 1) { throw new SchemaVersionError( `Invalid schema_version: ${obj.schema_version} (must be >= 1)`, filePath, 'Set "schema_version": 1 (minimum supported version).' ); } // Validate manifest_version (required, for backwards compatibility) if (!obj.manifest_version || typeof obj.manifest_version !== 'string') { throw new ManifestValidationError( 'Missing or invalid manifest_version (expected string like "1.0")', filePath, 'manifest_version' ); } if (!isValidVersion(obj.manifest_version)) { throw new ManifestValidationError( `Invalid manifest_version format: "${obj.manifest_version}" (expected "major.minor" like "1.0")`, filePath, 'manifest_version' ); } // Check version compatibility (legacy manifest_version checks) if (compareVersions(obj.manifest_version, MIN_MANIFEST_VERSION) < 0) { throw new ManifestValidationError( `Manifest version ${obj.manifest_version} is older than minimum supported ${MIN_MANIFEST_VERSION}`, filePath, 'manifest_version' ); } if (compareVersions(obj.manifest_version, MANIFEST_SCHEMA_VERSION) > 0) { throw new ManifestValidationError( `Manifest version ${obj.manifest_version} is newer than supported ${MANIFEST_SCHEMA_VERSION}. Please update wp-navigator-mcp.`, filePath, 'manifest_version' ); } // Validate meta (required) if (!obj.meta || typeof obj.meta !== 'object') { throw new ManifestValidationError('Missing or invalid meta section', filePath, 'meta'); } const meta = obj.meta as Record<string, unknown>; if (!meta.name || typeof meta.name !== 'string') { throw new ManifestValidationError( 'Missing or invalid meta.name (required string)', filePath, 'meta.name' ); } // Validate pages array if present if (obj.pages !== undefined) { if (!Array.isArray(obj.pages)) { throw new ManifestValidationError('pages must be an array', filePath, 'pages'); } for (let i = 0; i < obj.pages.length; i++) { const page = obj.pages[i] as Record<string, unknown>; if (!page.slug || typeof page.slug !== 'string') { throw new ManifestValidationError( `pages[${i}] missing required slug field`, filePath, `pages[${i}].slug` ); } if (!page.title || typeof page.title !== 'string') { throw new ManifestValidationError( `pages[${i}] missing required title field`, filePath, `pages[${i}].title` ); } } } // Validate plugins object if present if (obj.plugins !== undefined) { if (typeof obj.plugins !== 'object' || Array.isArray(obj.plugins)) { throw new ManifestValidationError( 'plugins must be an object keyed by plugin slug', filePath, 'plugins' ); } const plugins = obj.plugins as Record<string, unknown>; for (const [slug, config] of Object.entries(plugins)) { if (!config || typeof config !== 'object') { throw new ManifestValidationError( `plugins.${slug} must be an object`, filePath, `plugins.${slug}` ); } const pluginConfig = config as Record<string, unknown>; if (typeof pluginConfig.enabled !== 'boolean') { throw new ManifestValidationError( `plugins.${slug}.enabled must be a boolean`, filePath, `plugins.${slug}.enabled` ); } } } // Validate safety.backup_reminders.frequency if present if (obj.safety !== undefined && typeof obj.safety === 'object') { const safety = obj.safety as Record<string, unknown>; if (safety.backup_reminders !== undefined && typeof safety.backup_reminders === 'object') { const reminders = safety.backup_reminders as Record<string, unknown>; if (reminders.frequency !== undefined) { const validFrequencies = ['first_sync_only', 'always', 'daily', 'never']; if (!validFrequencies.includes(reminders.frequency as string)) { throw new ManifestValidationError( `Invalid backup_reminders.frequency: "${reminders.frequency}". Must be one of: ${validFrequencies.join(', ')}`, filePath, 'safety.backup_reminders.frequency' ); } } } } // Schema v2 validation if (obj.schema_version >= 2) { validateManifestV2Sections(obj, filePath); } return manifest as WPNavManifestRuntime; } // ============================================================================= // Schema v2 Section Validators // ============================================================================= /** * Valid tool category strings for validation */ const VALID_TOOL_CATEGORIES: ToolCategoryString[] = [ 'core', 'content', 'taxonomy', 'users', 'plugins', 'themes', 'workflows', 'cookbook', 'roles', 'batch', ]; /** * Validate v2-specific sections (tools, roles, ai, safety v2, env) */ function validateManifestV2Sections(obj: Record<string, unknown>, filePath: string): void { // Validate tools section if (obj.tools !== undefined) { validateToolsSection(obj.tools, filePath); } // Validate roles section if (obj.roles !== undefined) { validateRolesSection(obj.roles, filePath); } // Validate ai section if (obj.ai !== undefined) { validateAISection(obj.ai, filePath); } // Validate v2 safety fields if (obj.safety !== undefined) { validateSafetyV2Section(obj.safety, filePath); } // Validate env section if (obj.env !== undefined) { validateEnvSection(obj.env, filePath); } } /** * Validate tools section */ function validateToolsSection(tools: unknown, filePath: string): void { if (typeof tools !== 'object' || tools === null) { throw new ManifestValidationError('tools must be an object', filePath, 'tools'); } const toolsObj = tools as Record<string, unknown>; // Validate enabled array if (toolsObj.enabled !== undefined) { if (!Array.isArray(toolsObj.enabled)) { throw new ManifestValidationError( 'tools.enabled must be an array', filePath, 'tools.enabled' ); } for (const category of toolsObj.enabled) { if (!VALID_TOOL_CATEGORIES.includes(category as ToolCategoryString)) { throw new ManifestValidationError( `Invalid tool category in tools.enabled: "${category}". Valid categories: ${VALID_TOOL_CATEGORIES.join(', ')}`, filePath, 'tools.enabled' ); } } } // Validate disabled array if (toolsObj.disabled !== undefined) { if (!Array.isArray(toolsObj.disabled)) { throw new ManifestValidationError( 'tools.disabled must be an array', filePath, 'tools.disabled' ); } for (const category of toolsObj.disabled) { if (!VALID_TOOL_CATEGORIES.includes(category as ToolCategoryString)) { throw new ManifestValidationError( `Invalid tool category in tools.disabled: "${category}". Valid categories: ${VALID_TOOL_CATEGORIES.join(', ')}`, filePath, 'tools.disabled' ); } } } // Validate overrides object if (toolsObj.overrides !== undefined) { if (typeof toolsObj.overrides !== 'object' || Array.isArray(toolsObj.overrides)) { throw new ManifestValidationError( 'tools.overrides must be an object', filePath, 'tools.overrides' ); } const overrides = toolsObj.overrides as Record<string, unknown>; for (const [toolName, enabled] of Object.entries(overrides)) { if (typeof enabled !== 'boolean') { throw new ManifestValidationError( `tools.overrides.${toolName} must be a boolean`, filePath, `tools.overrides.${toolName}` ); } } } // Validate cookbooks object if (toolsObj.cookbooks !== undefined) { validateCookbooksSection(toolsObj.cookbooks, filePath); } } /** * Validate cookbooks binding section */ function validateCookbooksSection(cookbooks: unknown, filePath: string): void { if (typeof cookbooks !== 'object' || cookbooks === null) { throw new ManifestValidationError( 'tools.cookbooks must be an object', filePath, 'tools.cookbooks' ); } const cookbooksObj = cookbooks as Record<string, unknown>; if (cookbooksObj.load !== undefined && !Array.isArray(cookbooksObj.load)) { throw new ManifestValidationError( 'tools.cookbooks.load must be an array', filePath, 'tools.cookbooks.load' ); } if (cookbooksObj.auto_detect !== undefined && typeof cookbooksObj.auto_detect !== 'boolean') { throw new ManifestValidationError( 'tools.cookbooks.auto_detect must be a boolean', filePath, 'tools.cookbooks.auto_detect' ); } if (cookbooksObj.project_path !== undefined && typeof cookbooksObj.project_path !== 'string') { throw new ManifestValidationError( 'tools.cookbooks.project_path must be a string', filePath, 'tools.cookbooks.project_path' ); } } /** * Validate roles section */ function validateRolesSection(roles: unknown, filePath: string): void { if (typeof roles !== 'object' || roles === null) { throw new ManifestValidationError('roles must be an object', filePath, 'roles'); } const rolesObj = roles as Record<string, unknown>; if (rolesObj.active !== undefined && typeof rolesObj.active !== 'string') { throw new ManifestValidationError('roles.active must be a string', filePath, 'roles.active'); } if (rolesObj.auto_detect !== undefined && typeof rolesObj.auto_detect !== 'boolean') { throw new ManifestValidationError( 'roles.auto_detect must be a boolean', filePath, 'roles.auto_detect' ); } if (rolesObj.project_path !== undefined && typeof rolesObj.project_path !== 'string') { throw new ManifestValidationError( 'roles.project_path must be a string', filePath, 'roles.project_path' ); } // Validate overrides if (rolesObj.overrides !== undefined) { if (typeof rolesObj.overrides !== 'object' || Array.isArray(rolesObj.overrides)) { throw new ManifestValidationError( 'roles.overrides must be an object', filePath, 'roles.overrides' ); } const overrides = rolesObj.overrides as Record<string, unknown>; if (overrides.tools_allow !== undefined && !Array.isArray(overrides.tools_allow)) { throw new ManifestValidationError( 'roles.overrides.tools_allow must be an array', filePath, 'roles.overrides.tools_allow' ); } if (overrides.tools_deny !== undefined && !Array.isArray(overrides.tools_deny)) { throw new ManifestValidationError( 'roles.overrides.tools_deny must be an array', filePath, 'roles.overrides.tools_deny' ); } } } /** * Valid AI focus modes for validation */ const VALID_AI_FOCUS_MODES: AIFocusMode[] = [ 'content-editing', 'full-admin', 'read-only', 'custom', ]; /** * Validate ai section */ function validateAISection(ai: unknown, filePath: string): void { if (typeof ai !== 'object' || ai === null) { throw new ManifestValidationError('ai must be an object', filePath, 'ai'); } const aiObj = ai as Record<string, unknown>; if (aiObj.focus !== undefined) { if (!VALID_AI_FOCUS_MODES.includes(aiObj.focus as AIFocusMode)) { throw new ManifestValidationError( `Invalid ai.focus: "${aiObj.focus}". Valid modes: ${VALID_AI_FOCUS_MODES.join(', ')}`, filePath, 'ai.focus' ); } } if (aiObj.instructions !== undefined && typeof aiObj.instructions !== 'string') { throw new ManifestValidationError( 'ai.instructions must be a string', filePath, 'ai.instructions' ); } if (aiObj.prompts_path !== undefined && typeof aiObj.prompts_path !== 'string') { throw new ManifestValidationError( 'ai.prompts_path must be a string', filePath, 'ai.prompts_path' ); } } /** * Valid safety modes for validation */ const VALID_SAFETY_MODES: SafetyMode[] = ['yolo', 'normal', 'cautious']; /** * Valid operation types for validation */ const VALID_OPERATION_TYPES: OperationType[] = [ 'create', 'update', 'delete', 'activate', 'deactivate', 'batch', ]; /** * Validate v2 safety section fields */ function validateSafetyV2Section(safety: unknown, filePath: string): void { if (typeof safety !== 'object' || safety === null) { return; // Already validated in v1 } const safetyObj = safety as Record<string, unknown>; // Validate mode if (safetyObj.mode !== undefined) { if (!VALID_SAFETY_MODES.includes(safetyObj.mode as SafetyMode)) { throw new ManifestValidationError( `Invalid safety.mode: "${safetyObj.mode}". Valid modes: ${VALID_SAFETY_MODES.join(', ')}`, filePath, 'safety.mode' ); } } // Validate max_batch_size if (safetyObj.max_batch_size !== undefined) { if (typeof safetyObj.max_batch_size !== 'number' || safetyObj.max_batch_size < 1) { throw new ManifestValidationError( 'safety.max_batch_size must be a positive number', filePath, 'safety.max_batch_size' ); } } // Validate allowed_operations if (safetyObj.allowed_operations !== undefined) { if (!Array.isArray(safetyObj.allowed_operations)) { throw new ManifestValidationError( 'safety.allowed_operations must be an array', filePath, 'safety.allowed_operations' ); } for (const op of safetyObj.allowed_operations) { if (!VALID_OPERATION_TYPES.includes(op as OperationType)) { throw new ManifestValidationError( `Invalid operation in safety.allowed_operations: "${op}". Valid types: ${VALID_OPERATION_TYPES.join(', ')}`, filePath, 'safety.allowed_operations' ); } } } // Validate blocked_operations if (safetyObj.blocked_operations !== undefined) { if (!Array.isArray(safetyObj.blocked_operations)) { throw new ManifestValidationError( 'safety.blocked_operations must be an array', filePath, 'safety.blocked_operations' ); } for (const op of safetyObj.blocked_operations) { if (!VALID_OPERATION_TYPES.includes(op as OperationType)) { throw new ManifestValidationError( `Invalid operation in safety.blocked_operations: "${op}". Valid types: ${VALID_OPERATION_TYPES.join(', ')}`, filePath, 'safety.blocked_operations' ); } } } } /** * Validate env section (environment overrides) */ function validateEnvSection(env: unknown, filePath: string): void { if (typeof env !== 'object' || env === null || Array.isArray(env)) { throw new ManifestValidationError('env must be an object', filePath, 'env'); } const envObj = env as Record<string, unknown>; for (const [envName, override] of Object.entries(envObj)) { if (typeof override !== 'object' || override === null) { throw new ManifestValidationError( `env.${envName} must be an object`, filePath, `env.${envName}` ); } const overrideObj = override as Record<string, unknown>; // Validate override sections if (overrideObj.tools !== undefined) { validateToolsSection(overrideObj.tools, filePath); } if (overrideObj.roles !== undefined) { validateRolesSection(overrideObj.roles, filePath); } if (overrideObj.ai !== undefined) { validateAISection(overrideObj.ai, filePath); } if (overrideObj.safety !== undefined) { validateSafetyV2Section(overrideObj.safety, filePath); } } } // ============================================================================= // Manifest Loading // ============================================================================= /** Manifest file names to search for (in priority order) */ const MANIFEST_FILE_NAMES = ['wpnavigator.jsonc', 'wpnavigator.json', '.wpnavigator.jsonc']; /** * Result of manifest loading */ export interface LoadManifestResult { /** Whether manifest was found and loaded */ found: boolean; /** Loaded manifest (if found) */ manifest?: WPNavManifestRuntime; /** Path to the manifest file (if found) */ path?: string; /** Error message (if validation failed) */ error?: string; /** Error details */ errorDetails?: { field?: string; }; } /** * Load wpnavigator.jsonc from project root * * @param projectRoot - Directory to search (defaults to cwd) * @returns Loading result with manifest or error */ export function loadManifest(projectRoot?: string): LoadManifestResult { const searchDir = projectRoot ? path.resolve(projectRoot) : process.cwd(); // Search for manifest file let manifestPath: string | undefined; for (const fileName of MANIFEST_FILE_NAMES) { const candidatePath = path.join(searchDir, fileName); if (fs.existsSync(candidatePath)) { manifestPath = candidatePath; break; } } // Not found - this is OK, manifest is optional if (!manifestPath) { return { found: false }; } // Read file let content: string; try { content = fs.readFileSync(manifestPath, 'utf8'); } catch (error) { return { found: true, path: manifestPath, error: `Failed to read manifest: ${error instanceof Error ? error.message : String(error)}`, }; } // Parse JSONC let parsed: unknown; try { parsed = parseJsonc(content); } catch (error) { return { found: true, path: manifestPath, error: `Invalid JSON in manifest: ${error instanceof Error ? error.message : String(error)}`, }; } // Validate try { const manifest = validateManifest(parsed, manifestPath); return { found: true, manifest, path: manifestPath, }; } catch (error) { if (error instanceof ManifestValidationError) { return { found: true, path: manifestPath, error: error.message, errorDetails: { field: error.field, }, }; } return { found: true, path: manifestPath, error: error instanceof Error ? error.message : String(error), }; } } // ============================================================================= // Default Values // ============================================================================= /** Default brand palette */ export const DEFAULT_BRAND_PALETTE: Required<BrandPalette> = { primary: '#1a73e8', secondary: '#4285f4', accent: '#ea4335', neutral: '#5f6368', }; /** Default brand fonts */ export const DEFAULT_BRAND_FONTS: Required<BrandFonts> = { heading: 'Inter', body: 'Open Sans', mono: 'Fira Code', fallback: 'system-ui, -apple-system, sans-serif', }; /** Default brand layout */ export const DEFAULT_BRAND_LAYOUT: Required<BrandLayout> = { containerWidth: '1200px', spacing: 'comfortable', borderRadius: 'subtle', }; /** Default backup reminder settings */ export const DEFAULT_BACKUP_REMINDERS: Required<BackupReminders> = { enabled: true, before_sync: true, frequency: 'first_sync_only', }; /** Default safety settings */ export const DEFAULT_MANIFEST_SAFETY: Required<ManifestSafety> = { allow_create_pages: true, allow_update_pages: true, allow_delete_pages: false, allow_plugin_changes: false, allow_theme_changes: false, require_confirmation: true, require_sync_confirmation: true, first_sync_acknowledged: false, backup_reminders: DEFAULT_BACKUP_REMINDERS, }; /** * Get brand palette with defaults applied */ export function getBrandPalette(brand?: ManifestBrand): Required<BrandPalette> { return { ...DEFAULT_BRAND_PALETTE, ...brand?.palette, }; } /** * Get brand fonts with defaults applied */ export function getBrandFonts(brand?: ManifestBrand): Required<BrandFonts> { return { ...DEFAULT_BRAND_FONTS, ...brand?.fonts, }; } /** * Get brand layout with defaults applied */ export function getBrandLayout(brand?: ManifestBrand): Required<BrandLayout> { return { ...DEFAULT_BRAND_LAYOUT, ...brand?.layout, }; } /** * Get safety settings with defaults applied */ export function getManifestSafety(manifest?: WPNavManifest): Required<ManifestSafety> { const baseSafety = { ...DEFAULT_MANIFEST_SAFETY, ...manifest?.safety, }; // Deep merge backup_reminders if (manifest?.safety?.backup_reminders) { baseSafety.backup_reminders = { ...DEFAULT_BACKUP_REMINDERS, ...manifest.safety.backup_reminders, }; } return baseSafety; } /** * Get backup reminder settings with defaults applied */ export function getBackupReminders(manifest?: WPNavManifest): Required<BackupReminders> { return { ...DEFAULT_BACKUP_REMINDERS, ...manifest?.safety?.backup_reminders, }; } // ============================================================================= // Schema v2 Default Values // ============================================================================= /** Default cookbook binding configuration */ export const DEFAULT_COOKBOOK_BINDING: Required<ManifestCookbookBinding> = { load: [], auto_detect: true, project_path: './cookbooks', }; /** Default tools configuration */ export const DEFAULT_MANIFEST_TOOLS: Required<ManifestTools> = { enabled: [ 'core', 'content', 'taxonomy', 'users', 'plugins', 'themes', 'workflows', 'cookbook', 'roles', 'batch', ], disabled: [], overrides: {}, cookbooks: DEFAULT_COOKBOOK_BINDING, }; /** Default role overrides */ export const DEFAULT_ROLE_OVERRIDES: Required<ManifestRoleOverrides> = { tools_allow: [], tools_deny: [], }; /** Default roles configuration */ export const DEFAULT_MANIFEST_ROLES: Required<ManifestRoles> = { active: '', auto_detect: true, project_path: './roles', overrides: DEFAULT_ROLE_OVERRIDES, }; /** Default AI configuration */ export const DEFAULT_MANIFEST_AI: Required<ManifestAI> = { focus: 'content-editing', instructions: '', prompts_path: './sample-prompts', detected_plugins: [], page_builder: '', recommended_cookbooks: [], recommended_role: '', }; /** Default v2 safety settings */ export const DEFAULT_MANIFEST_SAFETY_V2: Required<ManifestSafetyV2> = { ...DEFAULT_MANIFEST_SAFETY, mode: 'cautious', max_batch_size: 10, allowed_operations: ['create', 'update'], blocked_operations: ['delete'], }; // ============================================================================= // Schema v2 Getters // ============================================================================= /** * Get tools configuration with defaults applied */ export function getManifestTools(manifest?: WPNavManifestRuntime): Required<ManifestTools> { if (!manifest || !isManifestV2(manifest)) { return DEFAULT_MANIFEST_TOOLS; } const baseTools = { ...DEFAULT_MANIFEST_TOOLS, ...manifest.tools, }; // Deep merge cookbooks if (manifest.tools?.cookbooks) { baseTools.cookbooks = { ...DEFAULT_COOKBOOK_BINDING, ...manifest.tools.cookbooks, }; } return baseTools; } /** * Get roles configuration with defaults applied */ export function getManifestRoles(manifest?: WPNavManifestRuntime): Required<ManifestRoles> { if (!manifest || !isManifestV2(manifest)) { return DEFAULT_MANIFEST_ROLES; } const baseRoles = { ...DEFAULT_MANIFEST_ROLES, ...manifest.roles, }; // Deep merge overrides if (manifest.roles?.overrides) { baseRoles.overrides = { ...DEFAULT_ROLE_OVERRIDES, ...manifest.roles.overrides, }; } return baseRoles; } /** * Get AI configuration with defaults applied */ export function getManifestAI(manifest?: WPNavManifestRuntime): Required<ManifestAI> { if (!manifest || !isManifestV2(manifest)) { return DEFAULT_MANIFEST_AI; } return { ...DEFAULT_MANIFEST_AI, ...manifest.ai, }; } /** * Get v2 safety settings with defaults applied */ export function getManifestSafetyV2(manifest?: WPNavManifestRuntime): Required<ManifestSafetyV2> { if (!manifest || !isManifestV2(manifest)) { return DEFAULT_MANIFEST_SAFETY_V2; } const baseSafety = { ...DEFAULT_MANIFEST_SAFETY_V2, ...manifest.safety, }; // Deep merge backup_reminders if (manifest.safety?.backup_reminders) { baseSafety.backup_reminders = { ...DEFAULT_BACKUP_REMINDERS, ...manifest.safety.backup_reminders, }; } return baseSafety; } /** * Upgrade a v1 manifest to v2 format (non-destructive) * Returns a new object with v2 defaults applied */ export function asManifestV2(manifest: WPNavManifestRuntime): WPNavManifestV2 { if (isManifestV2(manifest)) { return manifest; } // Create v2 from v1 with defaults return { ...manifest, schema_version: 2, tools: DEFAULT_MANIFEST_TOOLS, roles: DEFAULT_MANIFEST_ROLES, ai: DEFAULT_MANIFEST_AI, safety: { ...DEFAULT_MANIFEST_SAFETY_V2, ...manifest.safety, }, }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/littlebearapps/wp-navigator-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server