diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 64289648ae..6361fc4b66 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -27,6 +27,11 @@ import { type AiModelFamily, type AiModelId, } from '@studio/common/ai/models'; +import { + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { getAiPayloadsPath, getConfigDirectory } from '@studio/common/lib/well-known-paths'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; @@ -36,6 +41,7 @@ import { pullSiteTool } from 'cli/ai/tools/pull-site'; import { createSkillTool } from 'cli/ai/tools/skill'; import { takeScreenshotTool } from 'cli/ai/tools/take-screenshot'; import { createWpcomRequestTool } from 'cli/ai/tools/wpcom-request'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; import { stripStaleImagesFromContext } from './strip-stale-images'; import { @@ -255,6 +261,25 @@ async function runAgentSessionTurn( } } +// Resolve the runtime of the active local site so the system prompt can drop +// Playground-specific WP-CLI guidance for native PHP sites. The active site +// (a SiteInfo) doesn't carry the runtime, so look it up by path in the CLI +// config. Falls back to native-php (the default runtime) for unknown, remote, +// or unreadable sites. +async function resolveActiveSiteRuntime( + activeSite: SiteInfo | null | undefined +): Promise< SiteRuntime > { + if ( ! activeSite || activeSite.remote || ! activeSite.path ) { + return SITE_RUNTIME_NATIVE_PHP; + } + try { + const site = await getSiteByFolder( activeSite.path ); + return getSiteRuntime( site ); + } catch { + return SITE_RUNTIME_NATIVE_PHP; + } +} + async function createStudioAgentSession( config: ResolvedStudioAgentTurnConfig, family: AiModelFamily, @@ -276,7 +301,11 @@ async function createStudioAgentSession( }, remoteSession, } - : { chatArtifactsEnabled, remoteSession } + : { + chatArtifactsEnabled, + remoteSession, + runtime: await resolveActiveSiteRuntime( config.activeSite ), + } ); const tools = buildAgentTools( config, chatArtifactsEnabled, remoteSession ); diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index fd54039466..11f0e7ee7a 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -2,6 +2,7 @@ import { getStudioPresentationRulesPrompt, getStudioWidgetPromptManifest, } from '@studio/common/ai/studio-widgets'; +import { SITE_RUNTIME_PLAYGROUND, type SiteRuntime } from '@studio/common/lib/site-runtime'; interface RemoteSiteContext { name: string; @@ -19,6 +20,9 @@ export interface BuildSystemPromptOptions { // Adds guidance about delivering screenshots via `share_screenshot` and // offering a preview-site follow-up. remoteSession?: boolean; + // Runtime of the active local site. Playground (PHP WASM) needs extra WP-CLI + // constraints that the native PHP runtime does not. Defaults to native-php. + runtime?: SiteRuntime; } export function buildSystemPrompt( options?: BuildSystemPromptOptions ): string { @@ -35,6 +39,7 @@ ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum } return `${ buildLocalIntro( { chatArtifactsEnabled: options?.chatArtifactsEnabled ?? false, + runtime: options?.runtime, } ) } ${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum } @@ -72,7 +77,29 @@ IMPORTANT: ${ PLAN_DATA_GUARDRAIL } - Explore the API — if you're unsure about an endpoint, load the \`wpcom-remote-management\` skill and try a lightweight GET request first to discover available data.`; } -function buildLocalIntro( options: { chatArtifactsEnabled: boolean } ): string { +// Guidance for delivering `--post_content` to `wp_cli`. The shared part applies +// to both runtimes (the tool never runs a shell). The runtime-specific part +// differs: Playground runs in a WASM sandbox that cannot see the host +// filesystem, so content must be passed inline and Studio rewrites large +// content to a virtual temp file. The native PHP runtime reads the real +// filesystem, so a scratch file is allowed and is the better choice for large +// content (inline args can hit the OS command-length limit). +function getPostContentGuidance( runtime?: SiteRuntime ): string { + const shared = + 'The `wp_cli` tool takes literal arguments, not shell commands — never use shell substitution or shell syntax such as `$(cat file)`, backticks, pipes, redirection, or environment variables to provide post content.'; + + if ( runtime === SITE_RUNTIME_PLAYGROUND ) { + return `${ shared } Do not use host temp-file paths for post content — this site runs in a sandbox that cannot read your machine's filesystem. Pass the 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.`; + } + + return `${ shared } For large post content, write the validated markup to a scratch file inside the site directory and pass its path to \`wp post create \` (or \`wp post update \`) — this avoids the OS command-length limit. For smaller content you may instead pass it inline with \`--post_content=...\` as the final argument.`; +} + +function buildLocalIntro( options: { + chatArtifactsEnabled: boolean; + runtime?: SiteRuntime; +} ): string { + const postContentGuidance = getPostContentGuidance( options.runtime ); const automaticArtifactSection = options.chatArtifactsEnabled ? ` @@ -122,7 +149,7 @@ Then continue with: 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. **Provision the site**: Use wp_cli to activate the theme, install and activate any plugins the design needs, and set options. Do this before validating — the live editor only recognizes the active theme and registered plugin blocks. The site must be running. 5. **Validate block content**: Any block content you generate MUST pass validate_blocks before it reaches the site — before \`wp post create/update\` and before \`wp_cli eval\` that imports a scratch file such as \`/tmp/page-.html\`. Call validate_blocks with \`filePath\` for file content, or pass inline content. It runs a static core/html policy check first: if that reports invalid core/html blocks, editor validation is skipped — rewrite those as editable core or plugin blocks and call again. Once the policy passes it validates in the live editor. If an auto-fix was applied, the file already holds the fixed content; do not replace markup or re-validate unless you change the markup. Use the diff only to update CSS selectors for class/nesting changes. For inline content, use the returned fixed content exactly. Never apply unvalidated block content — a build that skips validate_blocks is incomplete. -6. **Apply content**: Once it passes validation, create/update/import the posts and pages with the validated content. 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. +6. **Apply content**: Once it passes validation, create/update/import the posts and pages with the validated content. ${ postContentGuidance } 7. **Check and polish the result**: Load the \`visual-polish\` skill and run it to polish the design. The design must match your original expectations. ## Working cadence diff --git a/apps/cli/ai/tests/system-prompt.test.ts b/apps/cli/ai/tests/system-prompt.test.ts index 430671f167..04153a1b3a 100644 --- a/apps/cli/ai/tests/system-prompt.test.ts +++ b/apps/cli/ai/tests/system-prompt.test.ts @@ -1,3 +1,8 @@ +import { + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { describe, expect, it } from 'vitest'; import { loadSkills } from '../skills'; import { buildSystemPrompt } from '../system-prompt'; @@ -104,6 +109,38 @@ describe( 'buildSystemPrompt', () => { expect( missingSkillNames ).toEqual( [] ); } ); + it( 'gives Playground sites the inline post_content guidance', () => { + const prompt = buildSystemPrompt( { runtime: SITE_RUNTIME_PLAYGROUND } ); + + expect( prompt ).toContain( 'rewrite large content to a virtual temp file' ); + expect( prompt ).toContain( 'cannot read your machine' ); + expect( prompt ).not.toContain( 'write the validated markup to a scratch file' ); + } ); + + it( 'lets native PHP sites use a scratch file for post_content', () => { + const prompt = buildSystemPrompt( { runtime: SITE_RUNTIME_NATIVE_PHP } ); + + expect( prompt ).toContain( 'write the validated markup to a scratch file' ); + expect( prompt ).toContain( 'wp post create ' ); + expect( prompt ).not.toContain( 'virtual temp file' ); + expect( prompt ).not.toContain( 'cannot read your machine' ); + } ); + + it( 'defaults to native PHP post_content guidance when no runtime is given', () => { + const prompt = buildSystemPrompt( {} ); + + expect( prompt ).toContain( 'write the validated markup to a scratch file' ); + expect( prompt ).not.toContain( 'virtual temp file' ); + } ); + + it( 'keeps the shared no-shell post_content rule for both runtimes', () => { + const runtimes: SiteRuntime[] = [ SITE_RUNTIME_PLAYGROUND, SITE_RUNTIME_NATIVE_PHP ]; + for ( const runtime of runtimes ) { + const prompt = buildSystemPrompt( { runtime } ); + expect( prompt ).toContain( 'takes literal arguments, not shell commands' ); + } + } ); + it( 'omits Studio presentation rules when chat artifacts are disabled', () => { const prompt = buildSystemPrompt( { chatArtifactsEnabled: false } ); diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 353dd47bfd..6c2b2f9a09 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -267,7 +267,11 @@ export async function runCommand( try { const sharedConfig = await readSharedConfig(); const selectedSkills = sharedConfig.selectedSkills ?? []; - await installAiInstructionsToSite( sitePath, getAiInstructionsPath(), selectedSkills ); + await installAiInstructionsToSite( + { path: sitePath, runtime: siteRuntime }, + getAiInstructionsPath(), + selectedSkills + ); } catch ( error ) { logger.reportError( new LoggerError( __( 'Failed to install AI instructions. Proceeding anyway…' ), error ), diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts index 3a80a5e3f2..5d6cab9c2e 100644 --- a/apps/cli/commands/site/start.ts +++ b/apps/cli/commands/site/start.ts @@ -71,7 +71,7 @@ export async function runCommand( logger.reportSuccess( __( 'SQLite integration configured as needed' ) ); try { - await updateManagedInstructionFiles( sitePath, getAiInstructionsPath() ); + await updateManagedInstructionFiles( site, getAiInstructionsPath() ); } catch ( error ) { logger.reportError( new LoggerError( __( 'Failed to update AI instructions. Proceeding anyway…' ), error ), diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index d1e679aad1..65432d1169 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -558,7 +558,7 @@ export async function installWordPressSkills( throw new Error( `Site not found: ${ siteId }` ); } const overwrite = options?.overwrite ?? false; - await installAllSkills( server.details.path, overwrite ); + await installAllSkills( server.details, overwrite ); } export async function installWordPressSkillById( @@ -572,7 +572,7 @@ export async function installWordPressSkillById( throw new Error( `Site not found: ${ siteId }` ); } const overwrite = options?.overwrite ?? false; - await installSkillById( server.details.path, skillId, overwrite ); + await installSkillById( server.details, skillId, overwrite ); } export async function removeWordPressSkillById( @@ -606,7 +606,7 @@ export async function installWordPressSkillsToAllSites( const overwrite = options.overwrite ?? false; const bundledPath = getAiInstructionsPath(); const tasks = sites.map( ( site ) => - installSkillToSite( site.details.path, bundledPath, options.skillId, overwrite ) + installSkillToSite( site.details, bundledPath, options.skillId, overwrite ) ); const results = await Promise.allSettled( tasks ); results.forEach( ( result ) => { @@ -1001,8 +1001,8 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom void loadSiteIcon( event, id ); } - // Keep managed instruction files (STUDIO.md, CLAUDE.md) up-to-date - void updateManagedInstructionFiles( server.details.path, getAiInstructionsPath() ).catch( + // Keep managed instruction files (STUDIO.md) up-to-date + void updateManagedInstructionFiles( server.details, getAiInstructionsPath() ).catch( ( error ) => { console.error( '[ai-instructions] Failed to update managed instruction files:', error ); } diff --git a/apps/studio/src/modules/agent-instructions/lib/skills.ts b/apps/studio/src/modules/agent-instructions/lib/skills.ts index 66fc2c4630..cb6d87fb58 100644 --- a/apps/studio/src/modules/agent-instructions/lib/skills.ts +++ b/apps/studio/src/modules/agent-instructions/lib/skills.ts @@ -3,6 +3,7 @@ import { installSkillToSite, removeSkillFromSite } from '@studio/common/lib/agen import { pathExists } from '@studio/common/lib/fs-utils'; import { getAiInstructionsPath } from 'src/lib/server-files-paths'; import { BUNDLED_SKILLS, type SkillStatus } from './skills-constants'; +import type { SiteRuntime } from '@studio/common/lib/site-runtime'; export { BUNDLED_SKILLS, type SkillConfig, type SkillStatus } from './skills-constants'; @@ -17,12 +18,12 @@ export async function getSkillsStatus( sitePath: string ): Promise< SkillStatus[ } export async function installAllSkills( - sitePath: string, + site: { path: string; runtime?: SiteRuntime }, overwrite: boolean = false ): Promise< void > { const bundledPath = getAiInstructionsPath(); const tasks = BUNDLED_SKILLS.map( ( skill ) => - installSkillToSite( sitePath, bundledPath, skill.id, overwrite ) + installSkillToSite( site, bundledPath, skill.id, overwrite ) ); const results = await Promise.allSettled( tasks ); for ( const result of results ) { @@ -33,11 +34,11 @@ export async function installAllSkills( } export async function installSkillById( - sitePath: string, + site: { path: string; runtime?: SiteRuntime }, skillId: string, overwrite: boolean = false ): Promise< void > { - await installSkillToSite( sitePath, getAiInstructionsPath(), skillId, overwrite ); + await installSkillToSite( site, getAiInstructionsPath(), skillId, overwrite ); } export async function removeSkillById( sitePath: string, skillId: string ): Promise< void > { diff --git a/packages/common/lib/agent-skills.ts b/packages/common/lib/agent-skills.ts index 9980211421..e08dfec1d7 100644 --- a/packages/common/lib/agent-skills.ts +++ b/packages/common/lib/agent-skills.ts @@ -2,6 +2,12 @@ import fs from 'fs'; import path from 'path'; import { pathExists, recursiveCopyDirectory } from './fs-utils'; import { isErrnoException } from './is-errno-exception'; +import { + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + type SiteRuntime, +} from './site-runtime'; /** * Managed instruction files that are always kept up-to-date on server start. @@ -9,11 +15,41 @@ import { isErrnoException } from './is-errno-exception'; */ const MANAGED_INSTRUCTION_FILES = [ 'STUDIO.md' ]; +const RUNTIME_MARKERS: SiteRuntime[] = [ SITE_RUNTIME_PLAYGROUND, SITE_RUNTIME_NATIVE_PHP ]; + +/** + * Render runtime-conditional blocks in a managed instruction file. Content + * wrapped in `` / `` line markers + * is kept only for the matching runtime and stripped for the others, so e.g. + * Playground-specific WP-CLI notes don't reach native-php sites. Files with no + * markers pass through unchanged. + */ +export function renderRuntimeInstructions( content: string, runtime: SiteRuntime ): string { + let rendered = content; + for ( const marker of RUNTIME_MARKERS ) { + const block = new RegExp( + `^[ \\t]*[ \\t]*\\r?\\n([\\s\\S]*?)^[ \\t]*[ \\t]*\\r?\\n`, + 'gm' + ); + rendered = rendered.replace( block, ( _match, inner ) => ( marker === runtime ? inner : '' ) ); + } + return rendered; +} + +async function writeRenderedInstructionFile( + src: string, + dest: string, + runtime: SiteRuntime +): Promise< void > { + const content = await fs.promises.readFile( src, 'utf8' ); + await fs.promises.writeFile( dest, renderRuntimeInstructions( content, runtime ) ); +} + /** * Install all bundled AI instructions and skills from a source directory into a site. * * Source directory layout (flat): - * AGENTS.md, CLAUDE.md, STUDIO.md — loose .md files copied to site root + * AGENTS.md, CLAUDE.md, STUDIO.md — loose .md files rendered and written to site root * studio-cli/SKILL.md — directories are skills, installed to .agents/skills/ * wp-plugin-development/SKILL.md * @@ -21,9 +57,13 @@ const MANAGED_INSTRUCTION_FILES = [ 'STUDIO.md' ]; * AGENTS.md, CLAUDE.md, STUDIO.md * .agents/skills//SKILL.md * .claude/skills/ -> ../../.agents/skills/ + * + * Loose `.md` files are rendered for the site's runtime (see + * `renderRuntimeInstructions`) so runtime-specific guidance is dropped when it + * doesn't apply. */ export async function installAiInstructionsToSite( - sitePath: string, + site: { path: string; runtime?: SiteRuntime }, bundledPath: string, userSelectedGlobalSkills: string[] = [], overwrite: boolean = false @@ -32,14 +72,17 @@ export async function installAiInstructionsToSite( return; } + const runtime = getSiteRuntime( site ); const entries = await fs.promises.readdir( bundledPath, { withFileTypes: true } ); const tasks: Promise< void >[] = []; for ( const entry of entries ) { if ( entry.isFile() && entry.name.endsWith( '.md' ) ) { - tasks.push( installInstructionFile( sitePath, bundledPath, entry.name, overwrite ) ); + tasks.push( + installInstructionFile( site.path, bundledPath, entry.name, runtime, overwrite ) + ); } else if ( entry.isDirectory() && userSelectedGlobalSkills.includes( entry.name ) ) { - tasks.push( installSkillToSite( sitePath, bundledPath, entry.name, overwrite ) ); + tasks.push( installSkillToSite( site, bundledPath, entry.name, overwrite ) ); } } @@ -56,18 +99,19 @@ export async function installAiInstructionsToSite( * with the bundled version. Called on server start to keep Studio-managed instructions current. */ export async function updateManagedInstructionFiles( - sitePath: string, + site: { path: string; runtime?: SiteRuntime }, bundledPath: string ): Promise< void > { + const runtime = getSiteRuntime( site ); for ( const fileName of MANAGED_INSTRUCTION_FILES ) { - const dest = path.join( sitePath, fileName ); + const dest = path.join( site.path, fileName ); const src = path.join( bundledPath, fileName ); if ( ! ( await pathExists( dest ) ) || ! ( await pathExists( src ) ) ) { continue; } - await fs.promises.copyFile( src, dest ); + await writeRenderedInstructionFile( src, dest, runtime ); } } @@ -75,13 +119,14 @@ async function installInstructionFile( sitePath: string, bundledPath: string, fileName: string, + runtime: SiteRuntime, overwrite: boolean ): Promise< void > { const dest = path.join( sitePath, fileName ); if ( ( await pathExists( dest ) ) && ! overwrite ) { return; } - await fs.promises.copyFile( path.join( bundledPath, fileName ), dest ); + await writeRenderedInstructionFile( path.join( bundledPath, fileName ), dest, runtime ); } export async function removeSkillFromSite( sitePath: string, skillId: string ): Promise< void > { @@ -92,7 +137,7 @@ export async function removeSkillFromSite( sitePath: string, skillId: string ): } export async function installSkillToSite( - sitePath: string, + site: { path: string; runtime?: SiteRuntime }, bundledPath: string, skillId: string, overwrite: boolean @@ -102,8 +147,9 @@ export async function installSkillToSite( return; } - const agentsSkillPath = path.join( sitePath, '.agents', 'skills', skillId ); - const claudeSkillPath = path.join( sitePath, '.claude', 'skills', skillId ); + const runtime = getSiteRuntime( site ); + const agentsSkillPath = path.join( site.path, '.agents', 'skills', skillId ); + const claudeSkillPath = path.join( site.path, '.claude', 'skills', skillId ); const isInstalled = await pathExists( path.join( agentsSkillPath, 'SKILL.md' ) ); @@ -112,9 +158,24 @@ export async function installSkillToSite( await fs.promises.rm( agentsSkillPath, { recursive: true, force: true } ); } await recursiveCopyDirectory( src, agentsSkillPath ); + await renderSkillMarkdownFiles( agentsSkillPath, runtime ); } - await ensureSkillSymlink( sitePath, agentsSkillPath, claudeSkillPath, overwrite ); + await ensureSkillSymlink( site.path, agentsSkillPath, claudeSkillPath, overwrite ); +} + +async function renderSkillMarkdownFiles( + skillPath: string, + runtime: SiteRuntime +): Promise< void > { + const entries = await fs.promises.readdir( skillPath, { withFileTypes: true } ); + for ( const entry of entries ) { + if ( entry.isFile() && entry.name.endsWith( '.md' ) ) { + const filePath = path.join( skillPath, entry.name ); + const content = await fs.promises.readFile( filePath, 'utf8' ); + await fs.promises.writeFile( filePath, renderRuntimeInstructions( content, runtime ) ); + } + } } async function ensureSkillSymlink( diff --git a/packages/common/lib/tests/agent-skills.test.ts b/packages/common/lib/tests/agent-skills.test.ts index c7928942aa..4f9f9f0886 100644 --- a/packages/common/lib/tests/agent-skills.test.ts +++ b/packages/common/lib/tests/agent-skills.test.ts @@ -4,9 +4,11 @@ import { vi } from 'vitest'; import { installAiInstructionsToSite, installSkillToSite, + renderRuntimeInstructions, updateManagedInstructionFiles, } from '../agent-skills'; import { pathExists, recursiveCopyDirectory } from '../fs-utils'; +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '../site-runtime'; vi.mock( 'fs' ); @@ -26,10 +28,13 @@ describe( 'installAiInstructionsToSite', () => { vi.mocked( fs.promises.mkdir ).mockResolvedValue( undefined ); vi.mocked( fs.promises.rm ).mockResolvedValue( undefined ); vi.mocked( fs.promises.symlink ).mockResolvedValue( undefined ); - vi.mocked( fs.promises.copyFile ).mockResolvedValue( undefined ); + // readFile echoes the source path back as the "content" so writeFile + // assertions can verify the source→destination mapping in one call. + vi.mocked( fs.promises.readFile ).mockImplementation( async ( p ) => p as string ); + vi.mocked( fs.promises.writeFile ).mockResolvedValue( undefined ); } ); - it( 'copies loose .md files to site root', async () => { + it( 'writes loose .md files to site root', async () => { vi.mocked( pathExists ).mockImplementation( async ( p: string ) => { if ( p === BUNDLED_PATH ) { return true; @@ -41,15 +46,18 @@ describe( 'installAiInstructionsToSite', () => { { name: 'STUDIO.md', isFile: () => true, isDirectory: () => false }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH + ); - expect( fs.promises.copyFile ).toHaveBeenCalledWith( - path.join( BUNDLED_PATH, 'AGENTS.md' ), - path.join( SITE_PATH, 'AGENTS.md' ) + expect( fs.promises.writeFile ).toHaveBeenCalledWith( + path.join( SITE_PATH, 'AGENTS.md' ), + path.join( BUNDLED_PATH, 'AGENTS.md' ) ); - expect( fs.promises.copyFile ).toHaveBeenCalledWith( - path.join( BUNDLED_PATH, 'STUDIO.md' ), - path.join( SITE_PATH, 'STUDIO.md' ) + expect( fs.promises.writeFile ).toHaveBeenCalledWith( + path.join( SITE_PATH, 'STUDIO.md' ), + path.join( BUNDLED_PATH, 'STUDIO.md' ) ); } ); @@ -68,12 +76,17 @@ describe( 'installAiInstructionsToSite', () => { { name: 'STUDIO.md', isFile: () => true, isDirectory: () => false }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH, [], false ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH, + [], + false + ); - expect( fs.promises.copyFile ).toHaveBeenCalledTimes( 1 ); - expect( fs.promises.copyFile ).toHaveBeenCalledWith( - path.join( BUNDLED_PATH, 'STUDIO.md' ), - path.join( SITE_PATH, 'STUDIO.md' ) + expect( fs.promises.writeFile ).toHaveBeenCalledTimes( 1 ); + expect( fs.promises.writeFile ).toHaveBeenCalledWith( + path.join( SITE_PATH, 'STUDIO.md' ), + path.join( BUNDLED_PATH, 'STUDIO.md' ) ); } ); @@ -88,7 +101,10 @@ describe( 'installAiInstructionsToSite', () => { { name: 'studio-cli', isFile: () => false, isDirectory: () => true }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH + ); expect( recursiveCopyDirectory ).not.toHaveBeenCalled(); expect( fs.promises.symlink ).not.toHaveBeenCalled(); @@ -105,7 +121,12 @@ describe( 'installAiInstructionsToSite', () => { { name: 'wp-rest-api', isFile: () => false, isDirectory: () => true }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH, [], true ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH, + [], + true + ); expect( fs.promises.rm ).not.toHaveBeenCalled(); expect( recursiveCopyDirectory ).not.toHaveBeenCalled(); @@ -114,14 +135,17 @@ describe( 'installAiInstructionsToSite', () => { it( 'skips when bundled path does not exist', async () => { vi.mocked( pathExists ).mockResolvedValue( false ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH + ); expect( recursiveCopyDirectory ).not.toHaveBeenCalled(); expect( fs.promises.symlink ).not.toHaveBeenCalled(); - expect( fs.promises.copyFile ).not.toHaveBeenCalled(); + expect( fs.promises.writeFile ).not.toHaveBeenCalled(); } ); - it( 'copies .md files but skips unselected skill directories', async () => { + it( 'writes .md files but skips unselected skill directories', async () => { vi.mocked( pathExists ).mockImplementation( async ( p: string ) => { if ( p === BUNDLED_PATH || p === path.join( BUNDLED_PATH, 'studio-cli' ) ) { return true; @@ -133,9 +157,12 @@ describe( 'installAiInstructionsToSite', () => { { name: 'studio-cli', isFile: () => false, isDirectory: () => true }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH + ); - expect( fs.promises.copyFile ).toHaveBeenCalledTimes( 1 ); + expect( fs.promises.writeFile ).toHaveBeenCalledTimes( 1 ); expect( recursiveCopyDirectory ).not.toHaveBeenCalled(); expect( fs.promises.symlink ).not.toHaveBeenCalled(); } ); @@ -153,16 +180,19 @@ describe( 'installAiInstructionsToSite', () => { { name: 'raw-imports.d.ts', isFile: () => true, isDirectory: () => false }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH + ); - expect( fs.promises.copyFile ).toHaveBeenCalledTimes( 1 ); - expect( fs.promises.copyFile ).toHaveBeenCalledWith( - path.join( BUNDLED_PATH, 'AGENTS.md' ), - path.join( SITE_PATH, 'AGENTS.md' ) + expect( fs.promises.writeFile ).toHaveBeenCalledTimes( 1 ); + expect( fs.promises.writeFile ).toHaveBeenCalledWith( + path.join( SITE_PATH, 'AGENTS.md' ), + path.join( BUNDLED_PATH, 'AGENTS.md' ) ); } ); - it( 'copies only .md files when skill directories are not user-selected', async () => { + it( 'writes only .md files when skill directories are not user-selected', async () => { vi.mocked( pathExists ).mockImplementation( async ( p: string ) => { if ( p === BUNDLED_PATH ) { return true; @@ -177,10 +207,13 @@ describe( 'installAiInstructionsToSite', () => { { name: 'wp-block-development', isFile: () => false, isDirectory: () => true }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH + ); - // 2 .md files copied to site root - expect( fs.promises.copyFile ).toHaveBeenCalledTimes( 2 ); + // 2 .md files written to site root + expect( fs.promises.writeFile ).toHaveBeenCalledTimes( 2 ); // No skill directories installed expect( recursiveCopyDirectory ).not.toHaveBeenCalled(); @@ -199,7 +232,10 @@ describe( 'installAiInstructionsToSite', () => { { name: 'good-skill', isFile: () => false, isDirectory: () => true }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH + ); expect( recursiveCopyDirectory ).not.toHaveBeenCalled(); expect( fs.promises.symlink ).not.toHaveBeenCalled(); @@ -216,11 +252,44 @@ describe( 'installAiInstructionsToSite', () => { { name: 'wp-plugin-development', isFile: () => false, isDirectory: () => true }, ] as never ); - await installAiInstructionsToSite( SITE_PATH, BUNDLED_PATH, [], false ); + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_PLAYGROUND }, + BUNDLED_PATH, + [], + false + ); expect( recursiveCopyDirectory ).not.toHaveBeenCalled(); expect( fs.promises.symlink ).not.toHaveBeenCalled(); } ); + + it( 'renders runtime-conditional content for the site runtime', async () => { + const studioContent = [ + 'Shared line.', + '', + 'PLAYGROUND NOTE', + '', + '', + 'NATIVE NOTE', + '', + '', + ].join( '\n' ); + vi.mocked( pathExists ).mockImplementation( async ( p: string ) => p === BUNDLED_PATH ); + vi.mocked( fs.promises.readdir ).mockResolvedValue( [ + { name: 'STUDIO.md', isFile: () => true, isDirectory: () => false }, + ] as never ); + vi.mocked( fs.promises.readFile ).mockResolvedValue( studioContent ); + + await installAiInstructionsToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_NATIVE_PHP }, + BUNDLED_PATH + ); + + const written = vi.mocked( fs.promises.writeFile ).mock.calls[ 0 ][ 1 ] as string; + expect( written ).toContain( 'NATIVE NOTE' ); + expect( written ).not.toContain( 'PLAYGROUND NOTE' ); + expect( written ).not.toContain( '', + 'PLAYGROUND SKILL NOTE', + '', + '', + 'NATIVE SKILL NOTE', + '', + '', + ].join( '\n' ); + + vi.mocked( pathExists ).mockImplementation( async ( p: string ) => { + if ( p === path.join( BUNDLED_PATH, 'my-skill' ) ) { + return true; + } + return false; + } ); + vi.mocked( fs.promises.readdir ).mockResolvedValue( [ + { name: 'SKILL.md', isFile: () => true, isDirectory: () => false }, + ] as never ); + vi.mocked( fs.promises.readFile ).mockResolvedValue( skillContent ); + + await installSkillToSite( + { path: SITE_PATH, runtime: SITE_RUNTIME_NATIVE_PHP }, + BUNDLED_PATH, + 'my-skill', + false + ); + + const written = vi.mocked( fs.promises.writeFile ).mock.calls[ 0 ][ 1 ] as string; + expect( written ).toContain( 'NATIVE SKILL NOTE' ); + expect( written ).not.toContain( 'PLAYGROUND SKILL NOTE' ); + expect( written ).not.toContain( '', + 'PLAYGROUND ONLY', + '', + '', + 'NATIVE ONLY', + '', + 'Outro line.', + '', + ].join( '\n' ); + + it( 'keeps Playground blocks and strips native blocks for playground', () => { + const rendered = renderRuntimeInstructions( content, SITE_RUNTIME_PLAYGROUND ); + + expect( rendered ).toContain( 'PLAYGROUND ONLY' ); + expect( rendered ).not.toContain( 'NATIVE ONLY' ); + expect( rendered ).not.toContain( '', + '| Use `wp shell` | Use `studio wp eval` |', + '', + '| Hardcode ports | Use `studio status` |', + '', + ].join( '\n' ); + + expect( renderRuntimeInstructions( table, SITE_RUNTIME_PLAYGROUND ) ).toContain( + '| Use `wp shell` | Use `studio wp eval` |' + ); + expect( renderRuntimeInstructions( table, SITE_RUNTIME_NATIVE_PHP ) ).not.toContain( + 'wp shell' + ); } ); } ); diff --git a/skills/STUDIO.md b/skills/STUDIO.md index cee5d735b7..84206e800c 100644 --- a/skills/STUDIO.md +++ b/skills/STUDIO.md @@ -6,7 +6,13 @@ When following any WordPress skill or documentation that references `wp` command - Skill says: `wp db export` → Run: `studio wp db export` - Skill says: `wp search-replace` → Run: `studio wp search-replace` -This applies to ALL `wp` commands. Studio runs WordPress through PHP WASM, and a standalone `wp` binary will NOT work. `wp shell` is NOT supported — use `studio wp eval` instead. +This applies to ALL `wp` commands — always run them through `studio wp` so they target this site's PHP runtime and database. + +Studio runs these sites on WordPress Playground (PHP WASM), so a standalone `wp` binary will NOT work, and `wp shell` is NOT supported — use `studio wp eval` instead. + + +A standalone `wp` binary is not the entry point; `wp shell` works here too (e.g. `studio wp shell`). + ## Prerequisites @@ -125,7 +131,9 @@ Run `studio mcp --help` for per-assistant install commands and config paths. Doc |-------|-----------| | Edit `wp-includes/` or `wp-admin/` | Use actions/filters in a plugin or child theme | | Use bare `wp` CLI | Use `studio wp` | + | Use `wp shell` | Use `studio wp eval` | + | Reference `DB_HOST`, `DB_NAME`, etc. | Use `$wpdb` directly (SQLite handles it) | | Delete `wp-content/db.php` | It's the SQLite drop-in — leave it | | Delete `wp-content/mu-plugins/sqlite-*` | Required for database to work | @@ -187,7 +195,13 @@ studio auth logout # Clear stored credentials **Themes and plugins:** Add custom themes to `wp-content/themes/` and plugins to `wp-content/plugins/`. To customise an existing theme, create a child theme rather than modifying the parent directly. -**Use hooks, not direct edits:** Extend WordPress via actions and filters. NEVER edit core files — Studio runs on WordPress Playground and core changes will NOT persist across server restarts. +**Use hooks, not direct edits:** Extend WordPress via actions and filters. NEVER edit core files in `wp-includes/` or `wp-admin/`. + +Studio runs these sites on WordPress Playground (PHP WASM), so core changes will NOT persist across server restarts. + + +Core edits are overwritten on every WordPress update, so extend through hooks instead. + ```php // Correct: extend via hooks @@ -261,7 +275,10 @@ exported MySQL dump above) ## Studio-Specific Notes -**WordPress core:** Do NOT modify files inside `wp-includes/` or `wp-admin/`. Studio sites run on WordPress Playground (PHP WASM), and core changes will NOT persist as expected. +**WordPress core:** Do NOT modify files inside `wp-includes/` or `wp-admin/`. + +Studio sites run on WordPress Playground (PHP WASM), and core changes will NOT persist as expected. + **Must-use plugins:** The `wp-content/mu-plugins/` directory contains the SQLite integration. Do NOT remove files from this directory. @@ -269,7 +286,12 @@ exported MySQL dump above) **Multisite:** WordPress Multisite is supported in Studio sites when the site was created from a blueprint that includes the `enableMultisite` step. Multisite requires a custom domain: Studio will prompt for one during site creation when the blueprint includes that step. + **Persistence:** The site runs in-process using PHP WASM. File writes to `wp-content/` persist to disk normally. Server-side cron is emulated; long-running background processes are not supported. + + +**Persistence:** The site runs on a native PHP server, so all file writes persist to disk normally and standard WP-Cron behavior applies. + ## Available Skills diff --git a/skills/studio-cli/SKILL.md b/skills/studio-cli/SKILL.md index fdaf63218f..31c5d0aa19 100644 --- a/skills/studio-cli/SKILL.md +++ b/skills/studio-cli/SKILL.md @@ -5,7 +5,7 @@ description: Use the Studio CLI to manage local WordPress sites, authentication, # Studio CLI -The `studio` command manages local WordPress sites powered by WordPress Playground (PHP WASM). +The `studio` command manages local WordPress sites. ## Global Options @@ -126,7 +126,7 @@ studio preview delete # Delete a preview site ## WP-CLI -Run WP-CLI commands inside the site's PHP WASM environment: +Run WP-CLI commands against the site's PHP runtime: ```bash studio wp --path ~/Studio/my-site core version