diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index ee0a0e2a45..ffadfbcdc6 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -150,7 +150,7 @@ 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, review the site spec (from the site-spec skill) and the Design Guidelines below to 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. +3. **Write theme/plugin files**: For a brand new theme, call \`scaffold_theme\` first — it drops an unopinionated block-theme baseline (style.css with only the theme header, theme.json with appearanceTools only, functions.php with frontend + editor style enqueue, default templates and parts, empty assets/fonts and patterns dirs) and activates it by default. Then use Write and Edit to fill the scaffold (one part/template/file per turn). For plugins or for editing an existing theme, use Write and Edit directly 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. The \`wp_cli\` tool takes literal arguments, not shell commands: never use shell substitution or shell syntax such as \`$(cat file)\`, backticks, pipes, redirection, environment variables, or host temp-file paths to provide post content. Pass the literal content directly in \`--post_content=...\`, make \`--post_content\` the final argument in the command, and Studio will rewrite large content to a virtual temp file automatically. 5. **Check the misuse of HTML blocks**: Verify if HTML blocks were used as sections or not. If they were, convert them to regular core blocks 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. Pay particular attention to the navigation menu and the CTA buttons. The design needs to match your original expectations. **Width check**: any section that was meant to be full-width (heroes, banners, edge-to-edge galleries, full-bleed footers) must visibly span the entire viewport in the desktop screenshot. If a "full-width" section only spans the content column (~700px at 1280px viewport), the block markup is missing \`align: "full"\` on the outer group or has a mismatched inner \`layout\` type — see the block-theme layout cascade rules above. Fix in markup, not custom CSS. @@ -159,11 +159,11 @@ Then continue with: One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp_cli\` queries may be combined). Short prose between tools — no long design-plan essays. The CLI only renders complete assistant messages, so a turn that batches files or emits >~200 lines spins silently for minutes and can hit gateway timeouts. Cadence is also a quality lever: the screenshot-fix loop only works after small visible increments. -**After \`site_create\`** (or "redesign"/"rebuild"/"start over" triggers), the next turn MUST be small: \`site_info\` or a single ≤50-line \`Write\`. Never scaffold a whole theme in one turn. +**After \`site_create\`** (or "redesign"/"rebuild"/"start over" triggers), the next turn MUST be small: \`site_info\`, a single \`scaffold_theme\` call, or a single ≤50-line \`Write\`. Never *fill* a whole theme in one turn — \`scaffold_theme\` only ships a baseline; design content (custom templates, parts, CSS) still goes one Write/Edit per turn. **Long files (>~200 lines): skeleton first, then fill across Edits.** -- \`style.css\`: skeleton = \`:root { ... }\` custom properties + 6–10 anchor comments \`/* === === */\` (e.g. \`reset\`, \`typography\`, \`hero\`, \`features\`, \`cta\`, \`footer\`, \`responsive\`), <2KB total. Fill one anchor per Edit (300–2000B each) — \`old_string\` is the anchor line, \`new_string\` is \`\\n\\n\`. +- \`style.css\`: skeleton = \`:root { ... }\` custom properties + 6–10 anchor comments \`/* === === */\` (e.g. \`reset\`, \`typography\`, \`hero\`, \`features\`, \`cta\`, \`footer\`, \`responsive\`), <2KB total. Fill one anchor per Edit (300–2000B each) — \`old_string\` is the anchor line, \`new_string\` is \`\\n\\n\`. **When \`scaffold_theme\` was used, do NOT \`Write\` over the scaffolded \`style.css\`** — it already contains the required theme header. Instead, \`Edit\` the file to append the \`:root { ... }\` block and anchor comments below the existing content, then fill anchors as above. - Page content: create the page empty (\`wp_cli post create --post_content=""\`), write \`/tmp/page-.html\` (not inside the theme) with \`\` anchors (<1KB), fill one anchor per Edit using only core blocks (never wrap in \`core/html\`), then apply once with \`wp_cli eval '$content = file_get_contents(ABSPATH . "tmp/page-.html"); wp_update_post(["ID" => , "post_content" => $content]); echo "ok";'\`. Do NOT use \`--post_content-file=\` — \`wp_cli\` runs inside the PHP-WASM filesystem (the host site directory is mounted at \`/wordpress/\`, so \`ABSPATH === "/wordpress/"\`) and cannot read host paths; \`--post_content-file=\` silently updates the post to empty content. ## Available Studio Tools (prefixed with mcp__studio__) @@ -179,6 +179,7 @@ One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp - preview_update: Update an existing hosted WordPress.com preview from a local site; this can take a few minutes, so tell the user to wait - preview_delete: Delete a hosted WordPress.com preview by hostname - wp_cli: Run WP-CLI commands on a running site +- scaffold_theme: Scaffold a minimal block theme (style.css, theme.json, functions.php with frontend + editor enqueue, default templates and parts, empty assets/fonts and patterns dirs) into a site and activate it. Use as the first step when starting a new custom theme; the agent fills design-specific content afterwards. Block themes only. - validate_blocks: Validate block content for correctness on a running site (runs each block through its save() function in a real browser). Requires a site name or path. 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. - need_for_speed: Measure frontend performance metrics (TTFB, FCP, LCP, CLS, page weight, DOM size, JS/CSS/image/font asset breakdown) for a running site. Use this to identify performance bottlenecks and guide optimization. @@ -195,8 +196,7 @@ One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp - 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. -- Always enqueue the theme's style.css on the frontend from functions.php. +- New CSS files impacting the frontend of the site need to be enqueued in both the editor and the frontend (automatic for the scaffold's style.css when using \`scaffold_theme\`). - For theme and page content custom CSS, put the styles in the main style.css of the theme. No custom stylesheets. - Scroll animations must use progressive enhancement: CSS defines elements in their **final visible state** by default (full opacity, final position). JavaScript on the frontend adds the initial hidden state (e.g. \`opacity: 0\`, \`transform\`) and scroll-triggered transitions. This ensures elements are fully visible in the block editor (which loads theme CSS but not custom JS). - All animations and transitions must respect \`prefers-reduced-motion\`. Add a \`@media (prefers-reduced-motion: reduce)\` block that disables or simplifies animations (e.g. \`animation: none; transition: none; scroll-behavior: auto;\`). diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index cc024ccca1..b7c1756237 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -1,3 +1,6 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; import { vi } from 'vitest'; import { emitEvent } from 'cli/ai/json-events'; import { runCommand as runCreatePreviewCommand } from 'cli/commands/preview/create'; @@ -467,4 +470,233 @@ describe( 'Studio AI MCP tools', () => { expect( sendWpCliCommand ).not.toHaveBeenCalled(); } ); + + describe( 'scaffold_theme', () => { + let tempSiteRoot: string; + let scaffoldSite: typeof mockSite; + + beforeEach( async () => { + tempSiteRoot = await mkdtemp( path.join( os.tmpdir(), 'studio-scaffold-theme-' ) ); + await mkdir( path.join( tempSiteRoot, 'wp-content', 'themes' ), { recursive: true } ); + scaffoldSite = { ...mockSite, path: tempSiteRoot }; + vi.mocked( readCliConfig ).mockResolvedValue( { + sites: [ scaffoldSite ], + } as Awaited< ReturnType< typeof readCliConfig > > ); + vi.mocked( getSiteByFolder ).mockResolvedValue( scaffoldSite ); + } ); + + afterEach( async () => { + await rm( tempSiteRoot, { recursive: true, force: true } ); + } ); + + it( 'is registered in the tool definitions', () => { + expect( studioToolDefinitions.map( ( tool ) => tool.name ) ).toContain( 'scaffold_theme' ); + } ); + + it( 'creates the expected files and directories under wp-content/themes/', async () => { + const result = await getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + } as never ); + + const themeDir = path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio' ); + const expectedFiles = [ + 'style.css', + 'theme.json', + 'functions.php', + 'templates/index.html', + 'templates/single.html', + 'templates/page.html', + 'templates/archive.html', + 'templates/404.html', + 'parts/header.html', + 'parts/footer.html', + ]; + for ( const rel of expectedFiles ) { + await expect( stat( path.join( themeDir, rel ) ) ).resolves.toBeDefined(); + } + + const fontsDir = await stat( path.join( themeDir, 'assets', 'fonts' ) ); + expect( fontsDir.isDirectory() ).toBe( true ); + const patternsDir = await stat( path.join( themeDir, 'patterns' ) ); + expect( patternsDir.isDirectory() ).toBe( true ); + + const styleCss = await readFile( path.join( themeDir, 'style.css' ), 'utf8' ); + expect( styleCss ).toContain( 'Theme Name: Acme Studio' ); + expect( styleCss ).toContain( 'Text Domain: acme-studio' ); + + const themeJson = JSON.parse( + await readFile( path.join( themeDir, 'theme.json' ), 'utf8' ) + ) as Record< string, unknown >; + expect( themeJson.version ).toBe( 3 ); + expect( ( themeJson.settings as Record< string, unknown > ).appearanceTools ).toBe( true ); + + const functionsPhp = await readFile( path.join( themeDir, 'functions.php' ), 'utf8' ); + expect( functionsPhp ).toContain( "'acme-studio-style'" ); + expect( functionsPhp ).toContain( "add_editor_style( 'style.css' )" ); + + expect( getTextContent( result ) ).toContain( + "Block theme 'Acme Studio' scaffolded at wp-content/themes/acme-studio/." + ); + expect( getTextContent( result ) ).toContain( 'wp theme activate acme-studio' ); + } ); + + it( 'honors an explicit slug argument over the derived one', async () => { + await getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + slug: 'custom-slug', + } as never ); + + await expect( + stat( path.join( tempSiteRoot, 'wp-content', 'themes', 'custom-slug' ) ) + ).resolves.toBeDefined(); + await expect( + stat( path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio' ) ) + ).rejects.toThrow(); + } ); + + it( 'fails when the target theme directory already exists', async () => { + await mkdir( path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio' ), { + recursive: true, + } ); + await writeFile( + path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio', 'sentinel.txt' ), + 'preexisting' + ); + + await expect( + getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + } as never ) + ).rejects.toThrow( /already exists/ ); + + // Pre-existing file is untouched. + const sentinel = await readFile( + path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio', 'sentinel.txt' ), + 'utf8' + ); + expect( sentinel ).toBe( 'preexisting' ); + } ); + + it( 'fails when wp-content/themes is missing from the site', async () => { + await rm( path.join( tempSiteRoot, 'wp-content' ), { recursive: true, force: true } ); + + await expect( + getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + } as never ) + ).rejects.toThrow( /wp-content\/themes directory not found/ ); + } ); + + it( 'rejects invalid explicit slugs', async () => { + await expect( + getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + slug: 'Not Valid!', + } as never ) + ).rejects.toThrow( /slug must contain only/ ); + } ); + + it( 'rejects empty theme names', async () => { + await expect( + getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: ' ', + } as never ) + ).rejects.toThrow( /name must not be empty/ ); + } ); + + it( 'activates the theme by default when the site is running', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( { + name: scaffoldSite.id, + pmId: 1, + status: 'online', + pid: 1234, + } ); + vi.mocked( sendWpCliCommand ).mockResolvedValue( { + stdout: "Success: Switched to 'Acme Studio' theme.", + stderr: '', + exitCode: 0, + } ); + + const result = await getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + } as never ); + + expect( sendWpCliCommand ).toHaveBeenCalledWith( scaffoldSite.id, [ + 'theme', + 'activate', + 'acme-studio', + ] ); + expect( getTextContent( result ) ).toContain( + "Activated: Success: Switched to 'Acme Studio' theme." + ); + expect( getTextContent( result ) ).not.toContain( 'Activation skipped' ); + } ); + + it( 'skips activation when activate is false', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( { + name: scaffoldSite.id, + pmId: 1, + status: 'online', + pid: 1234, + } ); + + const result = await getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + activate: false, + } as never ); + + expect( sendWpCliCommand ).not.toHaveBeenCalled(); + expect( getTextContent( result ) ).toContain( + 'Activate with: wp theme activate acme-studio' + ); + } ); + + it( 'reports activation skipped when the site is not running', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( undefined ); + + const result = await getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + } as never ); + + expect( sendWpCliCommand ).not.toHaveBeenCalled(); + expect( getTextContent( result ) ).toContain( 'Activation skipped:' ); + expect( getTextContent( result ) ).toContain( 'Site is not running' ); + expect( getTextContent( result ) ).toContain( + 'Activate manually with: wp theme activate acme-studio' + ); + } ); + + it( 'reports activation failure when WP-CLI returns a non-zero exit code', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( { + name: scaffoldSite.id, + pmId: 1, + status: 'online', + pid: 1234, + } ); + vi.mocked( sendWpCliCommand ).mockResolvedValue( { + stdout: '', + stderr: 'Error: stylesheet missing.', + exitCode: 1, + } ); + + const result = await getTool( 'scaffold_theme' ).rawHandler( { + nameOrPath: scaffoldSite.name, + name: 'Acme Studio', + } as never ); + + expect( getTextContent( result ) ).toContain( + 'Activation skipped: WP-CLI exited with code 1' + ); + expect( getTextContent( result ) ).toContain( 'Error: stylesheet missing.' ); + } ); + } ); } ); diff --git a/apps/cli/ai/tools/index.ts b/apps/cli/ai/tools/index.ts index b8251ee49c..b9005c2788 100644 --- a/apps/cli/ai/tools/index.ts +++ b/apps/cli/ai/tools/index.ts @@ -16,6 +16,7 @@ import { previewReloadTool } from './preview-reload'; import { pullSiteTool } from './pull-site'; import { pushSiteTool } from './push-site'; import { auditSeoTool } from './rank-me-up'; +import { scaffoldThemeTool } from './scaffold-theme'; import { shareScreenshotTool } from './share-screenshot'; import { getSiteInfoTool } from './site-info'; import { startSiteTool } from './start-site'; @@ -45,6 +46,7 @@ export const studioToolDefinitions = [ updatePreviewTool, deletePreviewTool, runWpCliTool, + scaffoldThemeTool, validateBlocksTool, takeScreenshotTool, shareScreenshotTool, diff --git a/apps/cli/ai/tools/scaffold-theme.ts b/apps/cli/ai/tools/scaffold-theme.ts new file mode 100644 index 0000000000..23b52b1c7e --- /dev/null +++ b/apps/cli/ai/tools/scaffold-theme.ts @@ -0,0 +1,382 @@ +import { mkdir, stat, writeFile } from 'fs/promises'; +import path from 'path'; +import { Type } from 'typebox'; +import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; +import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; +import { defineTool } from './define-tool'; +import { resolveSite, textResult } from './utils'; + +async function activateTheme( + siteId: string, + slug: string +): Promise< { ok: boolean; message: string } > { + try { + await connectToDaemon(); + try { + const running = await isServerRunning( siteId ); + if ( ! running ) { + return { + ok: false, + message: `Site is not running. Start it (site_start) then run \`wp theme activate ${ slug }\`.`, + }; + } + const result = await sendWpCliCommand( siteId, [ 'theme', 'activate', slug ] ); + if ( result.exitCode !== 0 ) { + const detail = ( result.stderr || result.stdout || '' ).trim(); + return { + ok: false, + message: `WP-CLI exited with code ${ result.exitCode }${ detail ? `: ${ detail }` : '' }`, + }; + } + const stdout = result.stdout.trim(); + return { ok: true, message: stdout || `Activated theme '${ slug }'.` }; + } finally { + await disconnectFromDaemon(); + } + } catch ( error ) { + return { + ok: false, + message: error instanceof Error ? error.message : String( error ), + }; + } +} + +function deriveSlug( input: string ): string { + return input + .toLowerCase() + .replace( /[^a-z0-9]+/g, '-' ) + .replace( /^-+|-+$/g, '' ); +} + +async function pathExists( p: string ): Promise< boolean > { + try { + await stat( p ); + return true; + } catch { + return false; + } +} + +function renderStyleCss( name: string, slug: string ): string { + return `/* +Theme Name: ${ name } +Description: A custom block theme. +Requires at least: 6.7 +Tested up to: 6.9 +Requires PHP: 7.2 +Version: 0.1.0 +License: GNU General Public License v2 or later +License URI: http://www.gnu.org/licenses/gpl-2.0.html +Text Domain: ${ slug } +Tags: full-site-editing, block-patterns, block-styles, wide-blocks, accessibility-ready, style-variations +*/ +`; +} + +function renderThemeJson(): string { + const data = { + $schema: 'https://schemas.wp.org/wp/6.7/theme.json', + version: 3, + settings: { + appearanceTools: true, + }, + }; + return JSON.stringify( data, null, '\t' ) + '\n'; +} + +function renderFunctionsPhp( name: string, slug: string ): string { + return `get( 'Version' ) + ); +} ); + +add_action( 'after_setup_theme', function () { + add_editor_style( 'style.css' ); +} ); +`; +} + +const TEMPLATE_INDEX = ` + + +
+ +
+ +
+ + + + + + + + + + + + + + +

No posts were found.

+ + +
+ +
+ +
+ + + +`; + +const TEMPLATE_SINGLE = ` + + +
+ +
+ + + +
+ + + + + +
+ +
+ +
+ + + +`; + +const TEMPLATE_PAGE = ` + + +
+ +
+ +
+ + + +
+ + + +`; + +const TEMPLATE_ARCHIVE = ` + + +
+ +
+ + + + +
+ + + + + + + + + + + +
+ +
+ +
+ + + +`; + +const TEMPLATE_404 = ` + + +
+ +
+ +

Page not found

+ + + +

The page you were looking for doesn't exist. Try a search instead.

+ + + +
+ +
+ + + +`; + +const PART_HEADER = ` +
+ +
+ + +
+ +
+ +`; + +const PART_FOOTER = ` +
+ +
+ +

©

+ + +
+ +
+ +`; + +export const scaffoldThemeTool = defineTool( + 'scaffold_theme', + 'Scaffolds a minimal block theme into the given site at wp-content/themes// and activates it by default. ' + + 'Drops in style.css (theme header only), theme.json (appearanceTools only), ' + + 'functions.php (frontend + editor style enqueue), default templates (index, single, page, archive, 404), ' + + 'header/footer parts, and empty assets/fonts and patterns directories. ' + + 'Use when the user wants to start a new custom theme — the agent fills in design-specific content afterwards. ' + + 'Block themes only; does not support classic (PHP template) themes. ' + + 'Fails if the target theme directory already exists. ' + + 'When the site is not running or activation fails, the scaffold still succeeds and the result reports the manual activation command.', + { + nameOrPath: Type.String( { + description: 'The site name or filesystem path of the site to scaffold the theme into.', + } ), + name: Type.String( { + description: + 'Display name of the theme (e.g. "Acme Studio"). Used in style.css Theme Name header.', + } ), + slug: Type.Optional( + Type.String( { + description: + 'Optional theme slug (lowercase letters, digits, dashes). Used as the directory name and text domain. Derived from the name when omitted.', + } ) + ), + activate: Type.Optional( + Type.Boolean( { + description: + 'Whether to activate the theme after scaffolding via WP-CLI (`theme activate `). Defaults to true. Set to false to leave the theme inactive.', + } ) + ), + }, + async ( args ) => { + try { + const site = await resolveSite( args.nameOrPath ); + + const trimmedName = args.name.trim(); + if ( ! trimmedName ) { + throw new Error( 'Theme name must not be empty.' ); + } + + const slug = ( args.slug ?? deriveSlug( trimmedName ) ).trim(); + if ( ! slug || ! /^[a-z0-9][a-z0-9-]*$/.test( slug ) ) { + throw new Error( + 'Theme slug must contain only lowercase letters, digits and dashes, and start with a letter or digit.' + ); + } + + const themesDir = path.join( site.path, 'wp-content', 'themes' ); + if ( ! ( await pathExists( themesDir ) ) ) { + throw new Error( `wp-content/themes directory not found in site: ${ themesDir }` ); + } + + const themeDir = path.join( themesDir, slug ); + if ( await pathExists( themeDir ) ) { + throw new Error( + `A theme already exists at wp-content/themes/${ slug }. Choose a different slug or remove the existing directory first.` + ); + } + + await mkdir( path.join( themeDir, 'templates' ), { recursive: true } ); + await mkdir( path.join( themeDir, 'parts' ), { recursive: true } ); + await mkdir( path.join( themeDir, 'assets', 'fonts' ), { recursive: true } ); + await mkdir( path.join( themeDir, 'patterns' ), { recursive: true } ); + + const files: Array< [ string, string ] > = [ + [ 'style.css', renderStyleCss( trimmedName, slug ) ], + [ 'theme.json', renderThemeJson() ], + [ 'functions.php', renderFunctionsPhp( trimmedName, slug ) ], + [ path.join( 'templates', 'index.html' ), TEMPLATE_INDEX ], + [ path.join( 'templates', 'single.html' ), TEMPLATE_SINGLE ], + [ path.join( 'templates', 'page.html' ), TEMPLATE_PAGE ], + [ path.join( 'templates', 'archive.html' ), TEMPLATE_ARCHIVE ], + [ path.join( 'templates', '404.html' ), TEMPLATE_404 ], + [ path.join( 'parts', 'header.html' ), PART_HEADER ], + [ path.join( 'parts', 'footer.html' ), PART_FOOTER ], + ]; + + for ( const [ relPath, content ] of files ) { + await writeFile( path.join( themeDir, relPath ), content, 'utf8' ); + } + + const shouldActivate = args.activate ?? true; + const activation = shouldActivate ? await activateTheme( site.id, slug ) : null; + + const summaryLines = [ + `Block theme '${ trimmedName }' scaffolded at wp-content/themes/${ slug }/.`, + '', + 'Created files:', + ...files.map( ( [ relPath ] ) => ` ${ relPath }` ), + '', + 'Empty directories:', + ' assets/fonts/', + ' patterns/', + '', + ]; + + if ( ! activation ) { + summaryLines.push( `Activate with: wp theme activate ${ slug }` ); + } else if ( activation.ok ) { + summaryLines.push( `Activated: ${ activation.message }` ); + } else { + summaryLines.push( + `Activation skipped: ${ activation.message }`, + `Activate manually with: wp theme activate ${ slug }` + ); + } + + return textResult( summaryLines.join( '\n' ) ); + } catch ( error ) { + throw new Error( + `Failed to scaffold theme: ${ error instanceof Error ? error.message : String( error ) }` + ); + } + } +); diff --git a/tools/common/ai/tools.ts b/tools/common/ai/tools.ts index b915b66542..41a67ce4bf 100644 --- a/tools/common/ai/tools.ts +++ b/tools/common/ai/tools.ts @@ -29,6 +29,7 @@ export function getToolDisplayName( name: string ): string { preview_update: __( 'Update preview' ), preview_delete: __( 'Delete preview' ), wp_cli: __( 'Run WP-CLI' ), + scaffold_theme: __( 'Scaffold theme' ), validate_blocks: __( 'Validate blocks' ), take_screenshot: __( 'Take screenshot' ), share_screenshot: __( 'Share screenshot' ), @@ -84,6 +85,8 @@ export function getToolDetail( name: string, input?: Record< string, unknown > ) return typeof input.host === 'string' ? input.host : ''; case 'wp_cli': return typeof input.command === 'string' ? `wp ${ input.command }` : ''; + case 'scaffold_theme': + return typeof input.name === 'string' ? input.name : ''; case 'validate_blocks': if ( typeof input.filePath === 'string' ) { return input.filePath.split( '/' ).slice( -2 ).join( '/' );