diff --git a/apps/cli/ai/agent.ts b/apps/cli/ai/agent.ts new file mode 100644 index 0000000000..a7a6b00057 --- /dev/null +++ b/apps/cli/ai/agent.ts @@ -0,0 +1,80 @@ +import { query, type Query } from '@anthropic-ai/claude-agent-sdk'; +import { buildSystemPrompt } from 'cli/ai/system-prompt'; +import { createStudioTools } from 'cli/ai/tools'; + +export interface AskUserQuestion { + question: string; + options: { label: string; description: string }[]; +} + +export interface AiAgentConfig { + prompt: string; + apiKey: string; + maxTurns?: number; + resume?: string; + onAskUser?: ( questions: AskUserQuestion[] ) => Promise< Record< string, string > >; +} + +export interface AiAgentResult { + sessionId: string; + success: boolean; +} + +export const AI_MODEL = 'claude-sonnet-4-6'; +export const AI_MODEL_DISPLAY = 'Sonnet 4.6'; + +function buildEnv( apiKey: string ): Record< string, string > { + const env: Record< string, string > = {}; + for ( const [ key, value ] of Object.entries( process.env ) ) { + if ( value !== undefined ) { + env[ key ] = value; + } + } + env.ANTHROPIC_API_KEY = apiKey; + return env; +} + +/** + * Start the AI agent and return the Query object. + * Caller can iterate messages with `for await` and call `interrupt()` to stop. + */ +export function startAiAgent( config: AiAgentConfig ): Query { + const { prompt, apiKey, maxTurns = 30, resume, onAskUser } = config; + + return query( { + prompt, + options: { + env: buildEnv( apiKey ), + systemPrompt: { + type: 'preset', + preset: 'claude_code', + append: buildSystemPrompt(), + }, + mcpServers: { + studio: createStudioTools(), + }, + maxTurns, + cwd: process.cwd(), + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + canUseTool: async ( toolName, input ) => { + if ( toolName === 'AskUserQuestion' && onAskUser ) { + const typedInput = input as { + questions?: AskUserQuestion[]; + answers?: Record< string, string >; + }; + const questions = typedInput.questions ?? []; + const answers = await onAskUser( questions ); + return { + behavior: 'allow' as const, + updatedInput: { ...input, answers }, + }; + } + return { behavior: 'allow' as const, updatedInput: input }; + }, + allowedTools: [ 'mcp__studio__*', 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep' ], + model: 'claude-sonnet-4-6', + resume, + }, + } ); +} diff --git a/apps/cli/ai/block-validator.ts b/apps/cli/ai/block-validator.ts new file mode 100644 index 0000000000..67df1b37e6 --- /dev/null +++ b/apps/cli/ai/block-validator.ts @@ -0,0 +1,318 @@ +let initialized = false; +let initError: string | null = null; + +interface BlockValidationResult { + blockName: string; + isValid: boolean; + issues: string[]; + originalContent: string; + expectedContent?: string; +} + +export interface ValidationReport { + totalBlocks: number; + validBlocks: number; + invalidBlocks: number; + results: BlockValidationResult[]; + error?: string; +} + +/** + * Set up jsdom globals so WordPress packages (especially hpq) can use DOM APIs. + * Must be called before importing any @wordpress/* packages. + */ +function setupDomEnvironment(): void { + const { JSDOM } = require( 'jsdom' ); + const dom = new JSDOM( '
', { + // Suppress CSS parsing errors from @wordpress/ui CSS modules + pretendToBeVisual: false, + } ); + + const g = global as Record< string, unknown >; + + // Helper to set global properties, using Object.defineProperty for read-only ones + // (e.g., `navigator` is getter-only in Node.js 21+) + function setGlobal( key: string, value: unknown ): void { + try { + g[ key ] = value; + } catch { + Object.defineProperty( globalThis, key, { + value, + writable: true, + configurable: true, + } ); + } + } + + // Copy DOM globals needed by WordPress packages + setGlobal( 'window', dom.window ); + setGlobal( 'document', dom.window.document ); + setGlobal( 'navigator', dom.window.navigator ); + setGlobal( 'MutationObserver', dom.window.MutationObserver ); + setGlobal( 'Node', dom.window.Node ); + setGlobal( 'HTMLElement', dom.window.HTMLElement ); + setGlobal( 'CustomEvent', dom.window.CustomEvent ); + setGlobal( 'URL', dom.window.URL ); + setGlobal( 'Element', dom.window.Element ); + + // Mock matchMedia (used by @wordpress/components and others) + if ( ! dom.window.matchMedia ) { + dom.window.matchMedia = () => + ( { + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + } ) as unknown as MediaQueryList; + } + + // Mock requestAnimationFrame / requestIdleCallback + if ( ! g.requestAnimationFrame ) { + g.requestAnimationFrame = ( cb: () => void ) => setTimeout( cb, 0 ); + g.cancelAnimationFrame = ( id: number ) => clearTimeout( id ); + } + if ( ! g.requestIdleCallback ) { + g.requestIdleCallback = ( cb: ( deadline: { timeRemaining: () => number } ) => void ) => + setTimeout( () => cb( { timeRemaining: () => 50 } ), 0 ); + g.cancelIdleCallback = ( id: number ) => clearTimeout( id ); + } + + // Some WP packages check for ResizeObserver + if ( ! g.ResizeObserver ) { + g.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + + // IntersectionObserver mock + if ( ! g.IntersectionObserver ) { + g.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } +} + +/** + * Lazy initialization: set up DOM environment and register blocks on first use. + */ +function ensureInitialized(): void { + if ( initialized ) { + return; + } + + try { + setupDomEnvironment(); + } catch ( error ) { + initError = `Failed to set up DOM environment: ${ + error instanceof Error ? error.stack || error.message : String( error ) + }. Make sure 'jsdom' is installed.`; + // Error stored in initError, returned via ValidationReport.error + initialized = true; + return; + } + + // Suppress jsdom CSS parsing errors during @wordpress/block-library import + // (jsdom can't parse CSS modules used by @wordpress/ui and @wordpress/components) + const origError = console.error; + console.error = ( ...args: unknown[] ) => { + const msg = String( args[ 0 ] ); + if ( msg.includes( 'Could not parse CSS stylesheet' ) ) { + return; + } + origError( ...args ); + }; + + try { + const { registerCoreBlocks } = require( '@wordpress/block-library' ); + registerCoreBlocks(); + } catch ( error ) { + initError = `Failed to register core blocks: ${ + error instanceof Error ? error.stack || error.message : String( error ) + }. Make sure '@wordpress/block-library' is installed.`; + // Error stored in initError, returned via ValidationReport.error + } finally { + console.error = origError; + } + + // Verify that blocks were actually registered + if ( ! initError ) { + try { + const { getBlockTypes } = require( '@wordpress/blocks' ); + const types = getBlockTypes(); + if ( ! types || types.length === 0 ) { + initError = 'No block types were registered. Block validation will not work correctly.'; + // Error stored in initError, returned via ValidationReport.error + } else { + // Blocks registered successfully + } + } catch { + // If we can't even check, something is very wrong + initError = 'Failed to verify block registration.'; + // Error stored in initError, returned via ValidationReport.error + } + } + + initialized = true; +} + +function formatLogItem( item: { args?: unknown[] } ): string { + if ( ! item.args || item.args.length === 0 ) { + return ''; + } + + const formatStr = String( item.args[ 0 ] ); + + // Skip the verbose "Block validation failed for `name` (blockType)" message — + // we already show the block name and the per-attribute issues are more useful. + if ( formatStr.startsWith( 'Block validation failed' ) ) { + return ''; + } + + let message = formatStr; + let argIndex = 1; + message = message.replace( /%[so]/g, () => { + if ( argIndex < ( item.args?.length ?? 0 ) ) { + const val = item.args![ argIndex++ ]; + if ( typeof val === 'object' ) { + return JSON.stringify( val ).slice( 0, 200 ); + } + return String( val ).slice( 0, 500 ); + } + return ''; + } ); + + return message; +} + +function tryGetSaveContent( block: { + name: string; + attributes: Record< string, unknown >; +} ): string | undefined { + try { + const { getSaveContent, getBlockType } = require( '@wordpress/blocks' ); + const blockType = getBlockType( block.name ); + if ( blockType ) { + return getSaveContent( blockType, block.attributes ); + } + } catch { + // If save content generation fails, skip + } + return undefined; +} + +interface ParsedBlock { + name: string | null; + attributes: Record< string, unknown >; + originalContent?: string; + innerBlocks?: ParsedBlock[]; +} + +function validateBlockList( blocks: ParsedBlock[], results: BlockValidationResult[] ): void { + const { validateBlock } = require( '@wordpress/blocks' ); + + for ( const block of blocks ) { + // Skip freeform blocks (raw HTML) and missing blocks (unregistered/PHP content) + if ( ! block.name || block.name === 'core/freeform' || block.name === 'core/missing' ) { + continue; + } + + try { + const [ isValid, logItems ] = validateBlock( block ) as [ + boolean, + Array< { args?: unknown[] } >, + ]; + + const issues: string[] = []; + for ( const item of logItems ) { + const msg = formatLogItem( item ); + if ( msg ) { + issues.push( msg ); + } + } + + results.push( { + blockName: block.name, + isValid, + issues, + originalContent: block.originalContent || '', + expectedContent: isValid + ? undefined + : tryGetSaveContent( block as { name: string; attributes: Record< string, unknown > } ), + } ); + } catch ( error ) { + results.push( { + blockName: block.name, + isValid: false, + issues: [ + `Validation error: ${ error instanceof Error ? error.message : String( error ) }`, + ], + originalContent: block.originalContent || '', + } ); + } + + // Recursively validate inner blocks + if ( block.innerBlocks && block.innerBlocks.length > 0 ) { + validateBlockList( block.innerBlocks, results ); + } + } +} + +/** + * Validate WordPress block content. + * Parses the content into blocks and validates each one against its registered save() function. + */ +export function validateBlocks( content: string ): ValidationReport { + ensureInitialized(); + + if ( initError ) { + return { + totalBlocks: 0, + validBlocks: 0, + invalidBlocks: 0, + results: [], + error: initError, + }; + } + + // Suppress ALL console output during parse/validation. + // WordPress logs verbose messages (e.g., "Updated Block:", block type object dumps) + // via console.log/info/warn/error that would pollute the agent output. + const origLog = console.log; + const origInfo = console.info; + const origWarn = console.warn; + const origError = console.error; + console.log = () => {}; + console.info = () => {}; + console.warn = () => {}; + console.error = () => {}; + + try { + const { parse } = require( '@wordpress/blocks' ); + const blocks = parse( content ) as ParsedBlock[]; + const results: BlockValidationResult[] = []; + + validateBlockList( blocks, results ); + + const validCount = results.filter( ( r ) => r.isValid ).length; + + return { + totalBlocks: results.length, + validBlocks: validCount, + invalidBlocks: results.length - validCount, + results, + }; + } finally { + console.log = origLog; + console.info = origInfo; + console.warn = origWarn; + console.error = origError; + } +} diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts new file mode 100644 index 0000000000..4dc3f6fa3d --- /dev/null +++ b/apps/cli/ai/system-prompt.ts @@ -0,0 +1,96 @@ +export function buildSystemPrompt(): string { + return `You are the AI assistant built into WordPress Studio CLI. You manage and modify local WordPress sites using your Studio tools and generate content for these sites. + +IMPORTANT: You MUST use your mcp__studio__ tools to manage WordPress sites. Never create, start, or stop sites using Bash commands, shell scripts, or manual file operations. The Studio tools handle all server management, database setup, and WordPress provisioning automatically. +IMPORTANT: For any generated content for the site, these tree pinciples are mandatory: + +- Gorgeous design: More details on the guidelines bellow. +- Editable content and sections: HTML blocks and raw HTML must be avoided. Check the block content guidlines bellow. +- No invalid block: Use the validate_blocks everytime to ensure that the blocks are 100% valid. + +## Workflow + +For any request that involves a WordPress site, you MUST first determine which site to use: + +- **"Create" / "build" / "make" a site**: Call site_create with a name as your FIRST tool call. Do NOT call site_list first. Do NOT reuse or repurpose any existing site. Every new project gets a fresh site. +- **User names a specific existing site**: Call site_list to find it. +- **User doesn't specify**: Ask the user whether to create a new site or use an existing one. + +Then continue with: + +1. **Get site details**: Use site_info to get the site path, URL, and credentials. +2. **Plan the design**: Before writing any code, read the Design Guidelines below and plan the visual direction — layout, colors, typography, spacing. +3. **Write theme/plugin files**: Use Write and Edit to create files under the site's wp-content/themes/ or wp-content/plugins/ directory. +4. **Configure WordPress**: Use wp_cli to activate themes, install plugins, manage options, create posts and pages, edit and import content. The site must be running. Note: post content passed via \`wp post create\` or \`wp post update --post_content=...\` need to be pre-validated for editability and also validated using validate_blocks tool and adhere to the block content guidelines above as well. +5. **Check the use of HTML blocks**: Check whether the use of HTML blocks was abused or not. If it was, fix it and run block validation again. +6. **Check the result**: Use take_screenshot to capture the site's landing page on desktop and mobile and verify the design visually on both viewports, check for wrong spacing, alignment, colors, contrast, borders, hover styles and other visual issues. Fix any issues found. + +## Available Studio Tools (prefixed with mcp__studio__) + +- site_create: Create a new WordPress site (name only — handles everything automatically) +- site_list: List all local WordPress sites with their status +- site_info: Get details about a specific site (path, URL, credentials, running status) +- site_start: Start a stopped site +- site_stop: Stop a running site +- wp_cli: Run WP-CLI commands on a running site +- validate_blocks: Validate a single file's block content for correctness (checks block markup matches expected save output). Call after every file write/edit that contains block content. +- take_screenshot: Take a full-page screenshot of a URL (supports desktop and mobile viewports). Use this to visually check the site after building it. + +## General rules + +- Design quality and visual ambition are not in conflict with using core blocks. Custom CSS targeting block classNames can achieve any visual design. The block structure is for editability; the CSS is for aesthetics. +- Do NOT modify WordPress core files. Only work within wp-content/. +- Before running wp_cli, ensure the site is running (site_start if needed). +- When building themes, always build block themes (NO CLASSIC THEMES). +- Always add the style.css as editor styles in the functions.php of the theme to make the editor match the frontend. +- For theme and page content custom CSS, put the styles in the main style.css of the theme. No custom stylesheets. + +## Block content guidelines + +- Only use \`core/html\` blocks for: + - Inline SVGs + - \`