Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
eea3a72
Add per-site mode and file access settings (RSM-3958)
bcotrim Jun 11, 2026
e1cfcd8
Let users pick the PHP runtime at site creation, rename mode to PHP r…
bcotrim Jun 12, 2026
92c50fe
Validate CLI runtime/file-access flags with zod, centralize the defau…
bcotrim Jun 12, 2026
9b42fb1
Reword the native file-access explainer to avoid overstating isolation
bcotrim Jun 12, 2026
84fa29e
Merge remote-tracking branch 'origin/trunk' into rsm-3958-add-php-mod…
bcotrim Jun 12, 2026
6ae6876
Track weekly active sites by PHP runtime
bcotrim Jun 12, 2026
b173b0f
Default new sites to native PHP, remove the beta toggle, and track ru…
bcotrim Jun 15, 2026
36d7686
Run native blueprint WP-CLI steps with the bundled PHP
bcotrim Jun 15, 2026
35de3c5
Fix WP-CLI calls from Studio Code
fredrikekelund Jun 16, 2026
21a6c0f
Transform stream to strip out shebang
fredrikekelund Jun 16, 2026
d1833e7
Address review comments
fredrikekelund Jun 16, 2026
bf8c304
`ensurePhpBinaryAvailable`
fredrikekelund Jun 17, 2026
f3f6dd2
Default existing sites to native PHP and apply runtime/file-access re…
bcotrim Jun 17, 2026
6ea9513
Move runtime adoption tracking into the CLI start path
bcotrim Jun 17, 2026
3678052
Fix prettier formatting in the server-manager wiring test
bcotrim Jun 17, 2026
6f34ea1
No custom types
fredrikekelund Jun 18, 2026
179150d
Use a loose CLI config schema and render runtime copy as components
bcotrim Jun 18, 2026
fbb9141
Merge branch 'rsm-3958-add-php-mode-switch-ui-and-cli' into f26d/stud…
fredrikekelund Jun 18, 2026
cd7f878
Merge branch 'trunk' into f26d/studio-code-wp-cli-native-php-fixes-ex…
fredrikekelund Jun 18, 2026
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
102 changes: 51 additions & 51 deletions apps/cli/ai/tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { runCommand as runUpdatePreviewCommand } from 'cli/commands/preview/upda
import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
import { runWpCliCommandWithMessaging } from 'cli/lib/run-wp-cli-command';
import { isServerRunning } from 'cli/lib/wordpress-server-manager';
import { getProgressCallback, setProgressCallback } from 'cli/logger';
import {
captureCommandOutput,
Expand Down Expand Up @@ -96,9 +97,12 @@ vi.mock( 'cli/lib/daemon-client', () => ( {
disconnectFromDaemon: vi.fn(),
} ) );

vi.mock( 'cli/lib/run-wp-cli-command', () => ( {
runWpCliCommandWithMessaging: vi.fn(),
} ) );

vi.mock( 'cli/lib/wordpress-server-manager', () => ( {
isServerRunning: vi.fn(),
sendWpCliCommand: vi.fn(),
} ) );

describe( 'Studio AI MCP tools', () => {
Expand Down Expand Up @@ -126,6 +130,18 @@ describe( 'Studio AI MCP tools', () => {
tool: ReturnType< typeof resolveStudioToolDefinitions >[ number ],
args: Record< string, unknown >
) => tool.execute( 'tool-call-1', args as never, new AbortController().signal, () => {} );
const mockWpCliResponse = ( {
stdout = '',
stderr = '',
exitCode = 0,
}: { stdout?: string; stderr?: string; exitCode?: number } = {} ) => ( {
response: {
exitCode: Promise.resolve( exitCode ),
stdoutText: Promise.resolve( stdout ),
stderrText: Promise.resolve( stderr ),
},
[ Symbol.dispose ]() {},
} );
const mockValidatedFix = ( fixedContent: string, blockName = 'core/paragraph' ) => {
vi.mocked( validateBlocks ).mockResolvedValue( {
totalBlocks: 1,
Expand Down Expand Up @@ -878,7 +894,7 @@ describe( 'Studio AI MCP tools', () => {
} as never )
).rejects.toThrow( /does not run in a shell/ );

expect( sendWpCliCommand ).not.toHaveBeenCalled();
expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled();
} );

it( 'treats unquoted post_content as a single trailing literal argument', async () => {
Expand All @@ -889,11 +905,9 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stdout: '123' } ) as never
);

await getTool( 'wp_cli' ).rawHandler( {
nameOrPath: 'My Site',
Expand All @@ -902,7 +916,7 @@ describe( 'Studio AI MCP tools', () => {
<!-- /wp:paragraph -->`,
} as never );

expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [
expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [
'post',
'create',
'--post_type=page',
Expand All @@ -920,18 +934,16 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stdout: '123' } ) as never
);

await getTool( 'wp_cli' ).rawHandler( {
nameOrPath: 'My Site',
command: 'post create --post_type=page --post_title="About" --post_content="Hello world"',
} as never );

expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [
expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [
'post',
'create',
'--post_type=page',
Expand All @@ -948,19 +960,17 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stdout: '123' } ) as never
);

await getTool( 'wp_cli' ).rawHandler( {
nameOrPath: 'My Site',
command:
'post create --post_type=page --post_title="About" --post_content="Hello world" --porcelain',
} as never );

expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [
expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [
'post',
'create',
'--post_type=page',
Expand All @@ -978,18 +988,16 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stdout: '123' } ) as never
);

await getTool( 'wp_cli' ).rawHandler( {
nameOrPath: 'My Site',
command: 'post create --post_type=page --post_title="About" --post_content="" --porcelain',
} as never );

expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [
expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [
'post',
'create',
'--post_type=page',
Expand All @@ -1007,11 +1015,9 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stdout: '123' } ) as never
);
const tool = resolveStudioToolDefinitions( {
emitChatArtifacts: true,
} ).find( ( definition ) => definition.name === 'wp_cli' );
Expand Down Expand Up @@ -1040,11 +1046,9 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '123',
stderr: '',
exitCode: 0,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stdout: '123' } ) as never
);
const tool = resolveStudioToolDefinitions( {
emitChatArtifacts: true,
} ).find( ( definition ) => definition.name === 'wp_cli' );
Expand Down Expand Up @@ -1082,7 +1086,7 @@ describe( 'Studio AI MCP tools', () => {
} as never )
).rejects.toThrow( /typographic dash/ );

expect( sendWpCliCommand ).not.toHaveBeenCalled();
expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled();
} );

describe( 'scaffold_theme', () => {
Expand Down Expand Up @@ -1232,18 +1236,16 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: "Success: Switched to 'Acme Studio' theme.",
stderr: '',
exitCode: 0,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stdout: "Success: Switched to 'Acme Studio' theme." } ) as never
);

const result = await getTool( 'scaffold_theme' ).rawHandler( {
nameOrPath: scaffoldSite.name,
name: 'Acme Studio',
} as never );

expect( sendWpCliCommand ).toHaveBeenCalledWith( scaffoldSite.id, [
expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( scaffoldSite, [
'theme',
'activate',
'acme-studio',
Expand All @@ -1269,7 +1271,7 @@ describe( 'Studio AI MCP tools', () => {
activate: false,
} as never );

expect( sendWpCliCommand ).not.toHaveBeenCalled();
expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled();
expect( getTextContent( result ) ).toContain(
'Activate with: wp theme activate acme-studio'
);
Expand All @@ -1283,7 +1285,7 @@ describe( 'Studio AI MCP tools', () => {
name: 'Acme Studio',
} as never );

expect( sendWpCliCommand ).not.toHaveBeenCalled();
expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled();
expect( getTextContent( result ) ).toContain( 'Activation skipped:' );
expect( getTextContent( result ) ).toContain( 'Site is not running' );
expect( getTextContent( result ) ).toContain(
Expand All @@ -1299,11 +1301,9 @@ describe( 'Studio AI MCP tools', () => {
pid: 1234,
runtime: SITE_RUNTIME_PLAYGROUND,
} );
vi.mocked( sendWpCliCommand ).mockResolvedValue( {
stdout: '',
stderr: 'Error: stylesheet missing.',
exitCode: 1,
} );
vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue(
mockWpCliResponse( { stderr: 'Error: stylesheet missing.', exitCode: 1 } ) as never
);

const result = await getTool( 'scaffold_theme' ).rawHandler( {
nameOrPath: scaffoldSite.name,
Expand Down
26 changes: 17 additions & 9 deletions apps/cli/ai/tools/scaffold-theme.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
import { mkdir, stat, writeFile } from 'fs/promises';
import path from 'path';
import { Type } from 'typebox';
import { SiteData } from 'cli/lib/cli-config/core';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
import { runWpCliCommandWithMessaging } from 'cli/lib/run-wp-cli-command';
import { isServerRunning } from 'cli/lib/wordpress-server-manager';
import { defineTool } from './define-tool';
import { resolveSite, textResult } from './utils';

async function activateTheme(
siteId: string,
site: SiteData,
slug: string
): Promise< { ok: boolean; message: string } > {
try {
await connectToDaemon();
try {
const running = await isServerRunning( siteId );
const running = await isServerRunning( site.id );
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();
await using command = await runWpCliCommandWithMessaging( site, [

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.

If you're not familiar with the using keyword, I recommend a quick read-up on JavaScript resource management.

In short, it saves us from having to call a cleanup function because the JS runtime automatically calls the method identified by Symbol.dispose when exiting the scope.

Why do we need a cleanup function, you ask? Because when the site is using the Playground runtime but isn't running, we have to instantiate a new PHP-WASM instance that needs to be killed when we're done. We could kill the PHP-WASM instance once all stdout/stderr content has been buffered, but because PHP-WASM also provides streams for stdout/stderr, and we sometimes want to use those streams instead of the buffered output, we need to do it this way.

'theme',
'activate',
slug,
] );
const exitCode = await command.response.exitCode;
const stderr = await command.response.stderrText;
const stdout = await command.response.stdoutText;
if ( exitCode !== 0 ) {
const detail = ( stderr || stdout || '' ).trim();
return {
ok: false,
message: `WP-CLI exited with code ${ result.exitCode }${ detail ? `: ${ detail }` : '' }`,
message: `WP-CLI exited with code ${ exitCode }${ detail ? `: ${ detail }` : '' }`,
};
}
const stdout = result.stdout.trim();
return { ok: true, message: stdout || `Activated theme '${ slug }'.` };
} finally {
await disconnectFromDaemon();
Expand Down Expand Up @@ -347,7 +355,7 @@ export const scaffoldThemeTool = defineTool(
}

const shouldActivate = args.activate ?? true;
const activation = shouldActivate ? await activateTheme( site.id, slug ) : null;
const activation = shouldActivate ? await activateTheme( site, slug ) : null;

const summaryLines = [
`Block theme '${ trimmedName }' scaffolded at wp-content/themes/${ slug }/.`,
Expand Down
35 changes: 15 additions & 20 deletions apps/cli/ai/tools/wp-cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Type } from 'typebox';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getUnsupportedWpCliPostContentMessage } from 'cli/lib/rewrite-wp-cli-post-content';
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
import { runWpCliCommandWithMessaging } from 'cli/lib/run-wp-cli-command';
import { defineTool } from './define-tool';
import { resolveSite } from './utils';
import type { StudioChatArtifactWidgetDraft } from '@studio/common/ai/chat-artifacts';
Expand Down Expand Up @@ -169,10 +169,9 @@ function getWpCliArtifacts(
return undefined;
}

// Note: wp.ts runCommand calls process.exit(), so we use the lower-level sendWpCliCommand directly.
export const runWpCliTool = defineTool(
'wp_cli',
'Runs a WP-CLI command on a specific WordPress site. The site must be running. ' +
'Runs a WP-CLI command on a specific WordPress site. ' +
'Examples: "plugin install woocommerce --activate", "option get blogname", "user list".',
{
nameOrPath: Type.String( { description: 'The site name or file system path to the site' } ),
Expand All @@ -188,13 +187,6 @@ export const runWpCliTool = defineTool(
try {
await connectToDaemon();

const runningProcess = await isServerRunning( site.id );
if ( ! runningProcess ) {
throw new Error(
`Site "${ site.name }" is not running. Start it first using site_start.`
);
}

const wpCliArgs = splitCommandArgs( args.command );
const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( wpCliArgs );
if ( unsupportedOptionMessage ) {
Expand All @@ -205,27 +197,30 @@ export const runWpCliTool = defineTool(
throw new Error( unsupportedPostContentMessage );
}

const result = await sendWpCliCommand( site.id, wpCliArgs );
await using command = await runWpCliCommandWithMessaging( site, wpCliArgs );
const exitCode = await command.response.exitCode;
const stdout = await command.response.stdoutText;
const stderr = await command.response.stderrText;

let output = '';
if ( result.stdout ) {
output += result.stdout;
if ( stdout ) {
output += stdout;
}
if ( result.stderr ) {
output += ( output ? '\n' : '' ) + `stderr: ${ result.stderr }`;
if ( stderr ) {
output += ( output ? '\n' : '' ) + `stderr: ${ stderr }`;
}
if ( result.exitCode !== 0 ) {
output += `\nExit code: ${ result.exitCode }`;
if ( exitCode !== 0 ) {
output += `\nExit code: ${ exitCode }`;
}

if ( result.exitCode !== 0 ) {
throw new Error( output || `WP-CLI exited with code ${ result.exitCode }` );
if ( exitCode !== 0 ) {
throw new Error( output || `WP-CLI exited with code ${ exitCode }` );
}
return {
content: [
{ type: 'text' as const, text: output || 'Command completed with no output.' },
],
studioArtifacts: getWpCliArtifacts( wpCliArgs, result.stdout ),
studioArtifacts: getWpCliArtifacts( wpCliArgs, stdout ),
};
} finally {
await disconnectFromDaemon();
Expand Down
Loading