Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/cli/ai/runtimes/anthropic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const anthropicRuntime: AgentRuntime = {
name: activeSite.name,
url: activeSite.url ?? '',
id: activeSite.wpcomSiteId!,
planFeaturesActive: activeSite.planFeaturesActive,
},
remoteSession,
}
Expand Down
1 change: 1 addition & 0 deletions apps/cli/ai/runtimes/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ async function getOrCreateAgent(
name: config.activeSite.name,
url: config.activeSite.url ?? '',
id: config.activeSite.wpcomSiteId,
planFeaturesActive: config.activeSite.planFeaturesActive,
},
}
: undefined
Expand Down
34 changes: 27 additions & 7 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ interface RemoteSiteContext {
name: string;
url: string;
id: number;
// Active feature slugs from `plan.features.active`, prefetched at agent
// start. When undefined, the prompt tells the agent to ask the user
// before making plan-feature claims.
planFeaturesActive?: string[];
}

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.`;
Expand All @@ -26,6 +30,8 @@ export function buildSystemPrompt( options?: BuildSystemPromptOptions ): string

${ REMOTE_CONTENT_GUIDELINES }

${ WPCOM_PLAN_CAPABILITIES }

${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }
`;
}
Expand All @@ -39,11 +45,18 @@ ${ LOCAL_DESIGN_GUIDELINES }${ remoteSessionAddendum }
}

function buildRemoteIntro( site: RemoteSiteContext ): string {
const featuresLine =
site.planFeaturesActive && site.planFeaturesActive.length > 0
? `IMPORTANT: Active plan features for this site (from \`plan.features.active\`, prefetched at session start): ${ site.planFeaturesActive.join(
', '
) }. Use this list — not tier names — when answering whether the site supports a feature.`
: `IMPORTANT: The active feature list for this site was not prefetched. When asked whether the site supports a specific feature, ask the user to confirm against https://wordpress.com/plans/ rather than guessing from the tier name.`;
return `${ AGENT_IDENTITY } You manage WordPress.com sites using the WordPress.com REST API.

IMPORTANT: The active site is a remote WordPress.com site: "${ site.name }" (ID: ${ site.id }) at ${ site.url }.
IMPORTANT: You MUST use the wpcom_request tool (prefixed with mcp__studio__) to manage this site. Do NOT use WP-CLI, file Write/Edit, Bash, or any local file operations — this site is hosted on WordPress.com and cannot be modified through the local filesystem.
IMPORTANT: Before doing ANY work, you MUST first check the site's plan by calling \`GET /\` (apiNamespace: \`""\`). The \`plan.product_slug\` field indicates the plan. If the site is on a free plan (e.g. \`free_plan\`), you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. Do not proceed with the design task.
IMPORTANT: Before doing ANY work, you MUST first call \`GET /\` (apiNamespace: \`""\`) to fetch the site. Use \`plan.is_free\` (or \`plan.product_slug === "free_plan"\`) to detect the free plan; on a free plan you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP.
${ featuresLine }

## Available Tools (prefixed with mcp__studio__)

Expand Down Expand Up @@ -95,7 +108,7 @@ Use \`per_page\` and \`page\` for pagination. Use \`status\` to filter by publis

## Workflow

1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info and check \`plan.product_slug\`. Stop and inform the user if they request features unavailable on their plan.
1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info. Use \`plan.is_free\` for the free-plan refusal above; for any other feature, consult the prefetched \`plan.features.active\` list at the top of this prompt rather than calling \`GET /\` again or guessing from the tier name.
2. **Understand the site**: Use \`GET /posts\` to list content, \`GET /themes?status=active\` to see the active theme.
3. **Make changes**: Use POST requests to create/update content, manage templates, switch themes.
4. **Verify visually**: Use take_screenshot to capture the site on desktop and mobile viewports. Check spacing, alignment, colors, contrast, and layout. Fix any issues.
Expand Down Expand Up @@ -221,16 +234,23 @@ const REMOTE_CONTENT_GUIDELINES = `## Block content guidelines
- No decorative HTML comments (e.g. \`<!-- Hero Section -->\`). Only block delimiter comments are allowed.
- No emojis anywhere in generated content.`;

const REMOTE_DESIGN_GUIDELINES = `## Design capabilities by plan
const WPCOM_PLAN_CAPABILITIES = `## WordPress.com plan capabilities

WordPress.com plan names, tiers, and what each one includes change frequently. Do NOT assert what a specific tier name (Personal, Premium, Business, Commerce, …) does or does not support from memory — those claims are routinely out-of-date and have caused incorrect support replies.

Instead, when you need to know whether a site supports a feature:
1. Consult the prefetched \`plan.features.active\` list at the top of this prompt — the authoritative per-site list of feature slugs the site currently has access to.
2. If the feature you care about isn't represented there (or the list wasn't prefetched), say so plainly and ask the user to confirm against their plan page (https://wordpress.com/plans/) rather than guessing.
3. The only plan-level rule you should treat as fixed is the free-plan refusal stated at the top of this prompt; everything else, defer to the prefetched feature list or the user.`;

const REMOTE_DESIGN_GUIDELINES = `## Design action rules

**Free plans** — content only, no design customization:
**Free plans** — refuse all design changes:
- CAN: Create/edit posts, pages, templates, template parts. Switch themes. Upload media.
- CANNOT: Any visual/design customization including custom CSS, inline styles, style attributes on blocks, global styles, custom JavaScript, animations, custom colors, custom fonts, custom layouts, or plugin management.
- ACTION: If the user requests ANY design change — even "small" ones like changing a color or font — you MUST refuse, explain it requires a paid plan, and STOP. Do not suggest inline styles, style attributes, or any other workaround. These will produce invalid blocks.

**Paid plans** (Personal, Premium, Business, eCommerce) — progressively more control:
- Custom CSS, global styles, plugin management, and advanced customization become available.
- Check the specific plan to determine exact capabilities.`;
**Paid plans** — capabilities vary by tier and change over time. Don't assume a specific tier supports or excludes a design feature; consult the prefetched \`plan.features.active\` list (see above) before making claims.`;

const LOCAL_CONTENT_GUIDELINES = `## Block content guidelines

Expand Down
47 changes: 47 additions & 0 deletions apps/cli/ai/tests/system-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { buildSystemPrompt } from 'cli/ai/system-prompt';

const REMOTE_SITE = { name: 'Example', url: 'https://example.wordpress.com', id: 123 };

describe( 'buildSystemPrompt — WordPress.com plan capabilities', () => {
it( 'inlines the prefetched plan.features.active list when provided', () => {
const prompt = buildSystemPrompt( {
remoteSite: { ...REMOTE_SITE, planFeaturesActive: [ 'custom-design', 'videopress' ] },
} );
expect( prompt ).toMatch( /Active plan features for this site/i );
expect( prompt ).toMatch( /custom-design, videopress/ );
expect( prompt ).toMatch( /Use this list — not tier names/i );
} );

it( 'falls back to "ask the user" guidance when the features list was not prefetched', () => {
const prompt = buildSystemPrompt( { remoteSite: REMOTE_SITE } );
expect( prompt ).toMatch( /active feature list for this site was not prefetched/i );
expect( prompt ).toMatch( /https:\/\/wordpress\.com\/plans\// );
} );

it( 'tells the remote agent not to assert capabilities by tier name', () => {
const prompt = buildSystemPrompt( { remoteSite: REMOTE_SITE } );
expect( prompt ).toMatch( /Do NOT assert what a specific tier name/i );
} );

it( 'omits WordPress.com plan guidance from the local prompt', () => {
// Local Studio sites have no plan concept, so the WP.com capabilities
// block is irrelevant and would only add noise.
const prompt = buildSystemPrompt();
expect( prompt ).not.toMatch( /plan\.features\.active/ );
expect( prompt ).not.toMatch( /WordPress\.com plan capabilities/i );
} );

for ( const [ label, build ] of [
[ 'local mode', () => buildSystemPrompt() ],
[ 'remote mode', () => buildSystemPrompt( { remoteSite: REMOTE_SITE } ) ],
] as const ) {
it( `does not resurrect the deleted "(Personal, Premium, Business, …)" tier table in ${ label }`, () => {
// The previous wording lumped plugin install into all paid plans
// (Personal, Premium, Business, eCommerce); Tess corrected that and
// asked us not to ship hardcoded per-tier claims.
const prompt = build();
expect( prompt ).not.toMatch( /Paid plans[^.]*\(Personal,\s*Premium,\s*Business/i );
} );
}
} );
4 changes: 4 additions & 0 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export interface SiteInfo {
remote?: boolean;
url?: string;
wpcomSiteId?: number;
// Cached `plan.features.active` for remote WP.com sites — fetched once
// when the agent first runs against the site so the system prompt can
// list the active feature slugs without bloating per-request tool output.
planFeaturesActive?: string[];
}

const DEFAULT_COLLAPSE_THRESHOLD_LINES = 5;
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/ai/wpcom-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { z } from 'zod/v4';
* `features` sub-field alone is 60K+ characters, which pushes the tool result past
* Claude Code's MCP output limit (~100k chars). The v1.1 API doesn't support
* sub-field filtering (e.g. `fields=plan.product_slug`), so we can't solve this
* via query params. The agent only needs a few plan properties to gate features
* since the system prompt hardcodes what each plan tier can do.
* via query params. The agent only needs a few plan properties to detect the free
* plan; the active-features list arrives separately via the system prompt
* (prefetched once at agent start — see getWpComSitePlanFeatures).
*
* This is NOT a pattern to follow for other endpoints. For general large responses,
* the system prompt instructs the agent to use `_fields` (wp/v2) or `fields` (v1.1)
Expand Down
18 changes: 18 additions & 0 deletions apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { replaySessionHistory } from 'cli/ai/sessions/replay';
import { AI_CHAT_SLASH_COMMANDS, type SlashCommandContext } from 'cli/ai/slash-commands';
import { AiChatUI } from 'cli/ai/ui';
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
import { getWpComSitePlanFeatures } from 'cli/lib/api';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { findSiteByFolder } from 'cli/lib/cli-config/sites';
import { isRemoteSessionEnabled } from 'cli/lib/feature-flags';
Expand Down Expand Up @@ -463,6 +464,23 @@ export async function runCommand( options: {
wpcomAccessToken = token?.accessToken;
}

// Prefetch the active feature list once per site so the agent can read
// it from the system prompt instead of paying for it on every `GET /`
// tool response (the v1.1 API can't filter `plan.features.available`
// out, and that field alone is 60K+ chars). Cached on `ui.activeSite`
// so subsequent turns reuse it without another roundtrip.
if (
site?.remote &&
site.wpcomSiteId &&
wpcomAccessToken &&
site.planFeaturesActive === undefined
) {
const features = await getWpComSitePlanFeatures( wpcomAccessToken, site.wpcomSiteId );
if ( features ) {
site.planFeaturesActive = features;
}
}

await persistSessionContext();

await persist( ( recorder ) =>
Expand Down
41 changes: 41 additions & 0 deletions apps/cli/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,47 @@ const wpComSitesResponseSchema = z.object( {
),
} );

const sitePlanFeaturesResponseSchema = z.object( {
plan: z
.object( {
features: z
.object( {
active: z.array( z.string() ).optional(),
} )
.optional(),
} )
.optional(),
} );

/**
* Fetch the active feature slugs for a single WordPress.com site (e.g.
* `["custom-design", "advanced-design", "videopress", …]`). The full plan
* object includes `features.available` which is 60K+ chars — way too much
* to ship in tool output — so we ask for `fields=plan` and immediately
* narrow to `plan.features.active`. Returns undefined if the site has no
* plan or the field is missing; never throws (a broken feature lookup
* shouldn't block the agent — it falls back to asking the user).
*/
export async function getWpComSitePlanFeatures(
token: string,
siteId: number
): Promise< string[] | undefined > {
const wpcom = wpcomFactory( token, wpcomXhrRequest );
try {
const raw = await wpcom.req.get(
{
apiNamespace: '',
path: `/sites/${ siteId }`,
},
{ fields: 'plan' }
);
const parsed = sitePlanFeaturesResponseSchema.safeParse( raw );
return parsed.success ? parsed.data.plan?.features?.active : undefined;
} catch {
return undefined;
}
}

export async function getWpComSites( token: string ): Promise< WpComSiteInfo[] > {
const wpcom = wpcomFactory( token, wpcomXhrRequest );
try {
Expand Down
Loading