Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion apps/cli/ai/runtimes/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -276,7 +301,11 @@ async function createStudioAgentSession(
},
remoteSession,
}
: { chatArtifactsEnabled, remoteSession }
: {
chatArtifactsEnabled,
remoteSession,
runtime: await resolveActiveSiteRuntime( config.activeSite ),
}
);

const tools = buildAgentTools( config, chatArtifactsEnabled, remoteSession );
Expand Down
31 changes: 29 additions & 2 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -35,6 +39,7 @@ ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }

return `${ buildLocalIntro( {
chatArtifactsEnabled: options?.chatArtifactsEnabled ?? false,
runtime: options?.runtime,
} ) }

${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum }
Expand Down Expand Up @@ -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.';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed for the php runtime?

@katinthehatsite katinthehatsite Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my testing, it seems to be needed: The wp_cli tool uses spawn() without a shell on both runtimes, so shell syntax like $(cat file) is passed as literal text. I verified this by creating a post with $(echo INJECTED) as content on a native PHP site and it was stored verbatim, not executed.


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 <file>\` (or \`wp post update <id> <file>\`) — 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
? `

Expand Down Expand Up @@ -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 \`<site>/tmp/page-<slug>.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
Expand Down
37 changes: 37 additions & 0 deletions apps/cli/ai/tests/system-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 <file>' );
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 } );

Expand Down
6 changes: 5 additions & 1 deletion apps/cli/commands/site/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ),
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/commands/site/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ),
Expand Down
10 changes: 5 additions & 5 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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 ) => {
Expand Down Expand Up @@ -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 );
}
Expand Down
9 changes: 5 additions & 4 deletions apps/studio/src/modules/agent-instructions/lib/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 ) {
Expand All @@ -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 > {
Expand Down
Loading
Loading