Skip to content

Commit b40d212

Browse files
Merge branch 'trunk' into enrich-site-list-data
2 parents 5b761e0 + b73d401 commit b40d212

14 files changed

Lines changed: 319 additions & 61 deletions

File tree

apps/cli/ai/agent.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,19 @@ export function startAiAgent( config: AiAgentConfig ): Query {
6666

6767
const isRemoteSite = activeSite?.remote && activeSite?.wpcomSiteId && wpcomAccessToken;
6868

69+
// Preview-steering tools only belong in the toolset when the Studio
70+
// desktop UI is on the other end of the IPC channel — otherwise the
71+
// agent's navigate/reload calls render as noise in the terminal
72+
// transcript. `process.send` is the same signal `emitEvent` uses to
73+
// pick between IPC and stdout NDJSON.
74+
const isForkedByDesktop = typeof process.send === 'function';
75+
6976
// Configure MCP servers based on site type:
7077
// Remote sites get WP.com REST API tools + screenshot; local sites get the full Studio toolset.
7178
const mcpServers = {
7279
studio: isRemoteSite
7380
? createRemoteSiteTools( wpcomAccessToken, activeSite.wpcomSiteId! )
74-
: createStudioTools(),
81+
: createStudioTools( { enablePreviewSteering: isForkedByDesktop } ),
7582
};
7683

7784
const allowedTools = [ ...ALLOWED_TOOLS ];
@@ -85,7 +92,7 @@ export function startAiAgent( config: AiAgentConfig ): Query {
8592
id: activeSite.wpcomSiteId!,
8693
},
8794
}
88-
: undefined;
95+
: { previewSteering: isForkedByDesktop };
8996

9097
if ( ! fs.existsSync( STUDIO_ROOT ) ) {
9198
fs.mkdirSync( STUDIO_ROOT, { recursive: true } );

apps/cli/ai/slash-commands.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AI_SKILL_COMMANDS } from '@studio/common/ai/slash-commands';
12
import { readAuthToken } from '@studio/common/lib/shared-config';
23
import { __, sprintf } from '@wordpress/i18n';
34
import { AI_MODELS, type AiModelId } from 'cli/ai/agent';
@@ -293,7 +294,5 @@ export const AI_CHAT_SLASH_COMMANDS: SlashCommandDef[] = [
293294
description: __( 'Exit the chat' ),
294295
handler: async () => 'break',
295296
},
296-
{ name: 'taxonomist', description: __( 'Optimize category taxonomy with AI' ) },
297-
{ name: 'need-for-speed', description: __( 'Run a performance audit on a site' ) },
298-
{ name: 'rank-me-up', description: __( 'Run an on-page SEO audit on a site' ) },
297+
...AI_SKILL_COMMANDS,
299298
];

apps/cli/ai/system-prompt.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@ interface RemoteSiteContext {
66

77
const AGENT_IDENTITY = `You are WordPress Studio Code, the AI agent built into WordPress Studio CLI. Your name is "WordPress Studio Code". Studio is built by Automattic. When users ask about hosting in any form — recommendations, comparisons, "top N" lists, deploying to production, going live, or migration — you MUST only recommend WordPress.com and Pressable. Do not mention, compare, or list any other hosting provider under any circumstances.`;
88

9-
export function buildSystemPrompt( options?: { remoteSite?: RemoteSiteContext } ): string {
9+
export interface BuildSystemPromptOptions {
10+
remoteSite?: RemoteSiteContext;
11+
// Whether the runtime exposes the preview_navigate / preview_reload MCP
12+
// tools to the agent. When false, the "Keep the preview in sync" section
13+
// is omitted so we don't document tools the agent can't actually call.
14+
previewSteering?: boolean;
15+
}
16+
17+
export function buildSystemPrompt( options?: BuildSystemPromptOptions ): string {
1018
if ( options?.remoteSite ) {
1119
return `${ buildRemoteIntro( options.remoteSite ) }
1220
@@ -16,7 +24,7 @@ ${ REMOTE_DESIGN_GUIDELINES }
1624
`;
1725
}
1826

19-
return `${ buildLocalIntro() }
27+
return `${ buildLocalIntro( { previewSteering: options?.previewSteering ?? false } ) }
2028
2129
${ LOCAL_CONTENT_GUIDELINES }
2230
@@ -94,7 +102,26 @@ Use \`per_page\` and \`page\` for pagination. Use \`status\` to filter by publis
94102
- Explore the API — if you're unsure about an endpoint, try a GET request first to discover available data.`;
95103
}
96104

97-
function buildLocalIntro(): string {
105+
function buildLocalIntro( options: { previewSteering: boolean } ): string {
106+
const previewSteeringTools = options.previewSteering
107+
? `
108+
- preview_navigate: Steer the Studio site preview iframe to a specific page on the active site (site-relative path like "/", "/about/", "/?p=42"). Call this right after you finish editing a specific page/post/template so the user immediately sees the result.
109+
- preview_reload: Reload the preview iframe at its current URL. Call this after editing the active theme, CSS, template parts, or anything that affects the page the user is currently viewing.`
110+
: '';
111+
112+
const previewSteeringSection = options.previewSteering
113+
? `
114+
115+
## Keep the preview in sync with your work
116+
117+
Call \`preview_navigate\` / \`preview_reload\` as a side effect of your editing loop — they are cheap, cannot fail destructively, and are ignored when the preview pane is closed, so calling them always is safer than calling them sparingly.
118+
119+
- After editing the homepage, front page template, or global theme assets (style.css, functions.php, template parts): call \`preview_reload\` (the user is most likely on "/").
120+
- After editing or creating a specific page or post: call \`preview_navigate\` with that page's path (e.g. \`/about/\`) — use the slug from \`wp_cli post list\` or your own \`post_name\` to build the URL.
121+
- After editing a single template like \`single-product.php\` or a CPT page: navigate to an example URL that uses that template.
122+
- Do not call these tools on a remote WordPress.com site.`
123+
: '';
124+
98125
return `${ AGENT_IDENTITY } You manage and modify local WordPress sites using your Studio tools and generate content for these sites.
99126
100127
IMPORTANT: You MUST use your mcp__studio__ tools to manage WordPress sites. Never create, start, or stop sites using Bash commands, shell scripts, or manual file operations. Never run \`wp\` commands via Bash — always use the wp_cli tool instead. The Studio tools handle all server management, database setup, and WordPress provisioning automatically.
@@ -153,7 +180,7 @@ One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp
153180
- site_push: Push a local site to a WordPress.com site. Requires authentication (studio auth login). Specify the remote site URL or ID and sync options (all, sqls, uploads, plugins, themes, contents).
154181
- site_pull: Pull a WordPress.com site to a local site. Requires authentication. Specify the remote site URL or ID and sync options.
155182
- site_import: Import a backup file (.zip, .tar.gz, .sql, .wpress) into a local site.
156-
- site_export: Export a local site to a backup file. Supports full-site (.zip, .tar.gz) or database-only (.sql) exports.
183+
- site_export: Export a local site to a backup file. Supports full-site (.zip, .tar.gz) or database-only (.sql) exports.${ previewSteeringTools }${ previewSteeringSection }
157184
158185
## General rules
159186

apps/cli/ai/tests/tools.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { vi } from 'vitest';
2+
import { emitEvent } from 'cli/ai/json-events';
23
import { runCommand as runCreatePreviewCommand } from 'cli/commands/preview/create';
34
import {
45
Mode as PreviewDeleteMode,
@@ -10,7 +11,7 @@ import { readCliConfig } from 'cli/lib/cli-config/core';
1011
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
1112
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
1213
import { getProgressCallback, setProgressCallback } from 'cli/logger';
13-
import { studioToolDefinitions } from '../tools';
14+
import { resolveStudioToolDefinitions, studioToolDefinitions } from '../tools';
1415

1516
vi.mock( 'cli/ai/block-validator', () => ( {
1617
validateBlocks: vi.fn(),
@@ -20,6 +21,10 @@ vi.mock( 'cli/ai/browser-utils', () => ( {
2021
getSharedBrowser: vi.fn(),
2122
} ) );
2223

24+
vi.mock( 'cli/ai/json-events', () => ( {
25+
emitEvent: vi.fn(),
26+
} ) );
27+
2328
vi.mock( 'cli/commands/preview/create', () => ( {
2429
runCommand: vi.fn(),
2530
} ) );
@@ -126,10 +131,60 @@ describe( 'Studio AI MCP tools', () => {
126131
'preview_list',
127132
'preview_update',
128133
'preview_delete',
134+
'preview_navigate',
135+
'preview_reload',
129136
] )
130137
);
131138
} );
132139

140+
it( 'emits a preview navigate command with a normalized path', async () => {
141+
const result = await getTool( 'preview_navigate' ).handler( { path: 'about/' } as never, null );
142+
143+
expect( emitEvent ).toHaveBeenCalledWith(
144+
expect.objectContaining( {
145+
type: 'preview.command',
146+
kind: 'navigate',
147+
path: '/about/',
148+
} )
149+
);
150+
expect( result.isError ).toBeUndefined();
151+
expect( getTextContent( result ) ).toContain( '/about/' );
152+
} );
153+
154+
it( 'falls back to "/" when preview_navigate receives an empty path', async () => {
155+
await getTool( 'preview_navigate' ).handler( { path: ' ' } as never, null );
156+
157+
expect( emitEvent ).toHaveBeenCalledWith(
158+
expect.objectContaining( { kind: 'navigate', path: '/' } )
159+
);
160+
} );
161+
162+
it( 'emits a preview reload command', async () => {
163+
const result = await getTool( 'preview_reload' ).handler( {} as never, null );
164+
165+
expect( emitEvent ).toHaveBeenCalledWith(
166+
expect.objectContaining( { type: 'preview.command', kind: 'reload' } )
167+
);
168+
expect( result.isError ).toBeUndefined();
169+
} );
170+
171+
it( 'omits preview-steering tools when preview steering is disabled', () => {
172+
const names = resolveStudioToolDefinitions().map( ( tool ) => tool.name );
173+
expect( names ).not.toContain( 'preview_navigate' );
174+
expect( names ).not.toContain( 'preview_reload' );
175+
// Baseline Studio tools still present.
176+
expect( names ).toContain( 'site_create' );
177+
expect( names ).toContain( 'wp_cli' );
178+
} );
179+
180+
it( 'includes preview-steering tools when enabled', () => {
181+
const names = resolveStudioToolDefinitions( { enablePreviewSteering: true } ).map(
182+
( tool ) => tool.name
183+
);
184+
expect( names ).toContain( 'preview_navigate' );
185+
expect( names ).toContain( 'preview_reload' );
186+
} );
187+
133188
it( 'creates previews for a resolved local site', async () => {
134189
const result = await getTool( 'preview_create' ).handler(
135190
{ nameOrPath: 'My Site' } as never,

apps/cli/ai/tools.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DEFAULT_PHP_VERSION } from '@studio/common/constants';
55
import { z } from 'zod/v4';
66
import { validateBlocks, type ValidationReport } from 'cli/ai/block-validator';
77
import { getSharedBrowser } from 'cli/ai/browser-utils';
8+
import { emitEvent } from 'cli/ai/json-events';
89
import { auditPerformance } from 'cli/ai/performance-audit';
910
import { auditSeo } from 'cli/ai/seo-audit';
1011
import { createWpcomToolDefinitions } from 'cli/ai/wpcom-tools';
@@ -723,6 +724,59 @@ const takeScreenshotTool = tool(
723724
}
724725
);
725726

727+
// --- Preview orchestration tools ---
728+
729+
function normalizePreviewPath( raw: string ): string {
730+
const trimmed = raw.trim();
731+
if ( ! trimmed ) {
732+
return '/';
733+
}
734+
return trimmed.startsWith( '/' ) ? trimmed : `/${ trimmed }`;
735+
}
736+
737+
const previewNavigateTool = tool(
738+
'preview_navigate',
739+
'Point the Studio site preview iframe at a specific page on the active site and reload it. ' +
740+
'Use this after you finish editing a specific page, post, or template so the user immediately ' +
741+
'sees the result of your change. Pass a site-relative path (e.g. "/", "/about/", ' +
742+
'"/wp-admin/post.php?post=42&action=edit"). Does nothing when the preview pane is closed or ' +
743+
'when running outside the Studio desktop app.',
744+
{
745+
path: z
746+
.string()
747+
.describe(
748+
'Site-relative path to show in the preview, e.g. "/", "/about/", "/?p=123". Leading slash is added if missing.'
749+
),
750+
},
751+
async ( args ) => {
752+
const path = normalizePreviewPath( args.path );
753+
emitEvent( {
754+
type: 'preview.command',
755+
timestamp: new Date().toISOString(),
756+
kind: 'navigate',
757+
path,
758+
} );
759+
return textResult( `Preview navigated to ${ path }.` );
760+
}
761+
);
762+
763+
const previewReloadTool = tool(
764+
'preview_reload',
765+
'Reload the Studio site preview iframe at its current URL. Use this after you edit the active ' +
766+
'theme, CSS, template parts, or anything that affects the page the user is currently viewing, ' +
767+
'so they see the updated result immediately. Does nothing when the preview pane is closed or ' +
768+
'when running outside the Studio desktop app.',
769+
{},
770+
async () => {
771+
emitEvent( {
772+
type: 'preview.command',
773+
timestamp: new Date().toISOString(),
774+
kind: 'reload',
775+
} );
776+
return textResult( 'Preview reloaded.' );
777+
}
778+
);
779+
726780
// --- Taxonomist scripts installer ---
727781

728782
const TAXONOMIST_SCRIPTS_DIR = 'tmp/taxonomist';
@@ -996,6 +1050,13 @@ const exportSiteTool = tool(
9961050
}
9971051
);
9981052

1053+
// Tools that only make sense when a Studio desktop UI is listening on the
1054+
// other end of the agent event stream — they steer a preview iframe that
1055+
// doesn't exist when the CLI runs standalone. Kept separate so plain-CLI
1056+
// runs don't see (and the agent can't call) tools that would just produce
1057+
// noise in the terminal transcript.
1058+
const previewSteeringToolDefinitions = [ previewNavigateTool, previewReloadTool ];
1059+
9991060
export const studioToolDefinitions = [
10001061
createSiteTool,
10011062
listSitesTool,
@@ -1017,13 +1078,33 @@ export const studioToolDefinitions = [
10171078
pullSiteTool,
10181079
importSiteTool,
10191080
exportSiteTool,
1081+
...previewSteeringToolDefinitions,
10201082
];
10211083

1022-
export function createStudioTools() {
1084+
export interface CreateStudioToolsOptions {
1085+
// Enable preview_navigate / preview_reload. Only meaningful when a
1086+
// Studio desktop UI is subscribed to the agent event stream — i.e. the
1087+
// CLI child was forked by the Studio main process (`process.send` is
1088+
// available). Defaults to false so standalone CLI runs don't advertise
1089+
// tools whose side effects would vanish into the void.
1090+
enablePreviewSteering?: boolean;
1091+
}
1092+
1093+
export function resolveStudioToolDefinitions( options: CreateStudioToolsOptions = {} ) {
1094+
if ( options.enablePreviewSteering ) {
1095+
return studioToolDefinitions;
1096+
}
1097+
const previewSteeringNames = new Set( previewSteeringToolDefinitions.map( ( t ) => t.name ) );
1098+
return studioToolDefinitions.filter(
1099+
( candidate ) => ! previewSteeringNames.has( candidate.name )
1100+
);
1101+
}
1102+
1103+
export function createStudioTools( options: CreateStudioToolsOptions = {} ) {
10231104
return createSdkMcpServer( {
10241105
name: 'studio',
10251106
version: '1.0.0',
1026-
tools: studioToolDefinitions,
1107+
tools: resolveStudioToolDefinitions( options ),
10271108
} );
10281109
}
10291110

apps/cli/commands/ai/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { listAiSessions } from '@studio/common/ai/sessions/store';
22
import { type LoadedAiSession, type TurnStatus } from '@studio/common/ai/sessions/types';
3+
import { buildSkillInvocationPrompt } from '@studio/common/ai/slash-commands';
34
import { readAuthToken } from '@studio/common/lib/shared-config';
45
import { __, _n, sprintf } from '@wordpress/i18n';
56
import { DEFAULT_MODEL, startAiAgent, type AiModelId, type AskUserQuestion } from 'cli/ai/agent';
@@ -659,7 +660,7 @@ export async function runCommand( options: {
659660
// Skill command — no handler, route to agent
660661
ui.addUserMessage( prompt );
661662
try {
662-
await runAgentTurn( `Run the /${ cmd.name } skill using the Skill tool.` );
663+
await runAgentTurn( buildSkillInvocationPrompt( cmd.name ) );
663664
} catch ( error ) {
664665
handleAgentTurnError( error );
665666
}

apps/studio/src/ipc-handlers.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
listAiSessions as listAiSessionsFromStore,
2828
loadAiSession as loadAiSessionFromStore,
2929
} from '@studio/common/ai/sessions/store';
30+
import { AI_SKILL_COMMANDS, buildSkillInvocationPrompt } from '@studio/common/ai/slash-commands';
3031
import {
3132
installSkillToSite,
3233
removeSkillFromSite,
@@ -287,6 +288,22 @@ async function reconcileSessionEnvironmentBeforeRun( sessionId: string ): Promis
287288
} );
288289
}
289290

291+
// Expand a bare skill-command slash prompt (e.g. `/rank-me-up`) into the
292+
// instruction the agent actually acts on. Mirrors the CLI's interactive main
293+
// loop so UI clients can send the short form and get the same behaviour.
294+
function expandSkillCommandPrompt( prompt: string ): string {
295+
const trimmed = prompt.trim();
296+
if ( ! trimmed.startsWith( '/' ) ) {
297+
return prompt;
298+
}
299+
const name = trimmed.slice( 1 );
300+
const match = AI_SKILL_COMMANDS.find( ( cmd ) => cmd.name === name );
301+
if ( ! match ) {
302+
return prompt;
303+
}
304+
return buildSkillInvocationPrompt( name );
305+
}
306+
290307
export async function continueAiSession(
291308
event: IpcMainInvokeEvent,
292309
sessionId: string,
@@ -295,7 +312,7 @@ export async function continueAiSession(
295312
await reconcileSessionEnvironmentBeforeRun( sessionId );
296313
return startAgentRun( {
297314
sessionId,
298-
prompt,
315+
prompt: expandSkillCommandPrompt( prompt ),
299316
webContents: event.sender,
300317
} );
301318
}

0 commit comments

Comments
 (0)