Skip to content
14 changes: 13 additions & 1 deletion apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp
- take_screenshot: Take a full-page screenshot of a URL (supports desktop and mobile viewports). Use this to visually check the site after building it.
- need_for_speed: Measure frontend performance metrics (TTFB, FCP, LCP, CLS, page weight, DOM size, JS/CSS/image/font asset breakdown) for a running site. Use this to identify performance bottlenecks and guide optimization.
- rank_me_up: Run an on-page SEO audit (title/meta tags, headings, image alt text, OpenGraph/Twitter cards, JSON-LD structured data, robots.txt and sitemap.xml availability) for a running site. Use this to identify on-page SEO issues and guide fixes.
- site_connected_remote_sites: List the WordPress.com sites already attached to a local site. Call this before site_push to decide how to ask the user which remote site to target.
- 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).
- site_pull: Pull a WordPress.com site to a local site. Requires authentication. Specify the remote site URL or ID and sync options.
- site_import: Import a backup file (.zip, .tar.gz, .sql, .wpress) into a local site.
Expand All @@ -198,7 +199,18 @@ One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp
- Always enqueue the theme's style.css on the frontend from functions.php.
- For theme and page content custom CSS, put the styles in the main style.css of the theme. No custom stylesheets.
- Scroll animations must use progressive enhancement: CSS defines elements in their **final visible state** by default (full opacity, final position). JavaScript on the frontend adds the initial hidden state (e.g. \`opacity: 0\`, \`transform\`) and scroll-triggered transitions. This ensures elements are fully visible in the block editor (which loads theme CSS but not custom JS).
- All animations and transitions must respect \`prefers-reduced-motion\`. Add a \`@media (prefers-reduced-motion: reduce)\` block that disables or simplifies animations (e.g. \`animation: none; transition: none; scroll-behavior: auto;\`).`;
- All animations and transitions must respect \`prefers-reduced-motion\`. Add a \`@media (prefers-reduced-motion: reduce)\` block that disables or simplifies animations (e.g. \`animation: none; transition: none; scroll-behavior: auto;\`).

## Push workflow

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.

I wonder if this is better in the push tool instead of the system prompt to keep the system prompt leaner.

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.

I didn't really evaluate how this is integrated into Studio Code – I focused almost exclusively on the data storage. The changes in this file come from the initial commit to this PR, b54464a.


When the user asks to push a site to WordPress.com, you MUST resolve the target remote site before calling \`site_push\`:

1. Call \`site_connected_remote_sites\` with the local site's name or path to get the list of already-attached WordPress.com sites.
2. Branch on how many remote sites are attached:
- **Exactly one attached site**: Use \`AskUserQuestion\` to confirm pushing to that site. Present two options labeled "Yes" and "No" with a description that includes the remote site's name and URL. Only call \`site_push\` if the user confirms.
- **Multiple attached sites**: Use \`AskUserQuestion\` with one question whose options are the attached sites (label = site name, description = URL). Then call \`site_push\` with the chosen site's ID or URL as \`remoteSite\`.
- **No attached sites**: Do NOT use \`AskUserQuestion\`. Ask an open-ended question in plain text for the URL or ID of the WordPress.com site to push to, then wait for the user's reply before calling \`site_push\`.
3. Never call \`site_push\` without explicit user confirmation of the target — even when only one site is attached.`;
}

const REMOTE_SESSION_GUIDANCE = `## Telegram remote session
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/ai/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { deleteSiteTool } from './delete-site';
import { exportSiteTool } from './export-site';
import { importSiteTool } from './import-site';
import { installTaxonomyScriptsTool } from './install-taxonomy-scripts';
import { listConnectedRemoteSitesTool } from './list-connected-remote-sites';
import { listPreviewsTool } from './list-previews';
import { listSitesTool } from './list-sites';
import { auditPerformanceTool } from './need-for-speed';
Expand Down Expand Up @@ -52,6 +53,7 @@ export const studioToolDefinitions = [
installTaxonomyScriptsTool,
auditPerformanceTool,
auditSeoTool,
listConnectedRemoteSitesTool,
pushSiteTool,
pullSiteTool,
importSiteTool,
Expand Down
36 changes: 36 additions & 0 deletions apps/cli/ai/tools/list-connected-remote-sites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { tool } from '@anthropic-ai/claude-agent-sdk';
import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites';
import { z } from 'zod/v4';
import { errorResult, resolveSite, textResult } from './utils';

export const listConnectedRemoteSitesTool = tool(
'site_connected_remote_sites',
'Lists the WordPress.com sites that are already connected/attached to a local Studio site. ' +
'Use this before calling site_push to determine how to ask the user which remote site to push to. ' +
'Returns an empty array when the user has no connections for that local site.',
{
nameOrPath: z.string().describe( 'The local site name or file system path' ),
},
async ( args ) => {
try {
const site = await resolveSite( args.nameOrPath );
const connected = await getConnectedWpcomSitesForLocalSite( site.id );
const summary = connected.map( ( s ) => ( {
id: s.id,
name: s.name,
url: s.url,
isStaging: s.isStaging,
syncSupport: s.syncSupport,
lastPushTimestamp: s.lastPushTimestamp,
lastPullTimestamp: s.lastPullTimestamp,
} ) );
return textResult( JSON.stringify( summary, null, 2 ) );
} catch ( error ) {
return errorResult(
`Failed to list connected remote sites: ${
error instanceof Error ? error.message : String( error )
}`
);
}
}
);
13 changes: 13 additions & 0 deletions apps/cli/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { confirm } from '@inquirer/prompts';
import {
addConnectedWpcomSite,
markConnectedWpcomSiteSynced,
} from '@studio/common/lib/connected-sites';
import { readAuthToken } from '@studio/common/lib/shared-config';
import {
SYNC_MAX_STALLED_ATTEMPTS,
Expand Down Expand Up @@ -189,6 +193,15 @@ export async function runCommand(
const siteUrl = getSiteUrl( site );
await fetch( siteUrl ).catch( () => {} );

// Remember this connection so future push/pull runs (and the Desktop UI)
// can surface it without re-selecting from the full site list.
try {
await addConnectedWpcomSite( site.id, { ...remoteSite, localSiteId: site.id } );
await markConnectedWpcomSiteSynced( site.id, remoteSite.id, 'pull' );
} catch ( error ) {
logger.reportError( new LoggerError( 'Failed to save connected site', error ), false );
}

logger.reportSuccess(
sprintf( __( 'Pulled from %s (%s)' ), remoteSite.name, remoteSite.url )
);
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/commands/push.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
addConnectedWpcomSite,
markConnectedWpcomSiteSynced,
} from '@studio/common/lib/connected-sites';
import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore';
import { readAuthToken } from '@studio/common/lib/shared-config';
import {
Expand Down Expand Up @@ -251,6 +255,15 @@ export async function runCommand(
);
}

// Remember this connection so future push/pull runs (and the Desktop UI)
// can surface it without re-selecting from the full site list.
try {
await addConnectedWpcomSite( site.id, { ...remoteSite, localSiteId: site.id } );
await markConnectedWpcomSiteSynced( site.id, remoteSite.id, 'push' );
} catch ( error ) {
logger.reportError( new LoggerError( 'Failed to save connected site', error ), false );
}

logger.reportSuccess(
sprintf( __( 'Successfully pushed to %s (%s)' ), remoteSite.name, remoteSite.url )
);
Expand Down
78 changes: 78 additions & 0 deletions apps/cli/migrations/05-migrate-connected-sites-to-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Copies `connectedWpcomSites` from the Desktop-owned `app.json` into the
* shared `shared.json` so the CLI can read and write it through the helpers
* in `tools/common/lib/connected-sites.ts`.
*
* Runs only when app.json carries the legacy field AND shared.json does not
* already have it — the CLI must never modify app.json. The Studio app runs
* its own migration to strip the field once it boots; until then the legacy
* copy in app.json is harmless because both the new Studio and the new CLI
* read connections from shared.json.
*/

import fs from 'node:fs';
import {
lockSharedConfig,
readSharedConfig,
saveSharedConfig,
unlockSharedConfig,
} from '@studio/common/lib/shared-config';
import { getAppConfigPath } from '@studio/common/lib/well-known-paths';
import { syncSiteSchema } from '@studio/common/types/sync';
import { readFile } from 'atomically';
import { z } from 'zod';
import type { Migration } from '@studio/common/lib/migration';

const appConnectedShapeSchema = z
.object( {
connectedWpcomSites: z.record( z.string(), z.array( syncSiteSchema ) ).optional(),
} )
.loose();

async function readAppConnectedSites(): Promise< Record<
string,
z.infer< typeof syncSiteSchema >[]
> | null > {
const appPath = getAppConfigPath();
if ( ! fs.existsSync( appPath ) ) {
return null;
}
try {
const raw = await readFile( appPath, { encoding: 'utf8' } );
const parsed = appConnectedShapeSchema.safeParse( JSON.parse( raw ) );
if ( ! parsed.success ) {
return null;
}
return parsed.data.connectedWpcomSites ?? null;
} catch {
return null;
}
}

export const migrateConnectedSitesToShared: Migration = {
async needsToRun() {
const appConnected = await readAppConnectedSites();
if ( ! appConnected || Object.keys( appConnected ).length === 0 ) {
return false;
}
const shared = await readSharedConfig().catch( () => null );
return ! shared?.connectedWpcomSites;
},

async run() {
const appConnected = await readAppConnectedSites();
if ( ! appConnected || Object.keys( appConnected ).length === 0 ) {
return;
}
try {
await lockSharedConfig();
const shared = await readSharedConfig();
if ( shared.connectedWpcomSites ) {
return;
}
await saveSharedConfig( { ...shared, connectedWpcomSites: appConnected } );
} finally {
await unlockSharedConfig();
}
},
};
2 changes: 2 additions & 0 deletions apps/cli/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { checkStudioCompatibilityForInitialMigration } from './00-check-studio-c
import { hideStudioDirWindows } from './01-hide-studio-dir-windows';
import { renameProcessManagerHome } from './03-rename-pm-home';
import { cleanupObsoleteServerFiles } from './04-cleanup-obsolete-server-files';
import { migrateConnectedSitesToShared } from './05-migrate-connected-sites-to-shared';
import type { Migration } from '@studio/common/lib/migration';

export const migrations: Migration[] = [
checkStudioCompatibilityForInitialMigration,
hideStudioDirWindows,
renameProcessManagerHome,
cleanupObsoleteServerFiles,
migrateConnectedSitesToShared,
];
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"build:npm": "vite build --config ./vite.config.npm.ts",
"install:bundle": "npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
"package": "npm run install:bundle && npm run build:prod",
"lint": "eslint .",
"watch": "vite build --config ./vite.config.dev.ts --watch",
"prepublishOnly": "npm run build:npm",
"postinstall": "node scripts/postinstall-npm.mjs",
Expand Down
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"make:macos-x64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && SKIP_DMG=true FILE_ARCHITECTURE=x64 electron-forge make . --arch=x64 --platform=darwin",
"make:macos-arm64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && SKIP_DMG=true FILE_ARCHITECTURE=arm64 electron-forge make . --arch=arm64 --platform=darwin",
"install:bundle": "npm install --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
"lint": "eslint src e2e",
"package": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && electron-forge package .",
"publish": "electron-forge publish .",
"start-wayland": "npm -w wp-studio run build && electron-forge start . -- --enable-features=UseOzonePlatform --ozone-platform=wayland",
Expand Down
19 changes: 4 additions & 15 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from '@studio/common/lib/agent-skills';
import { validateBlueprintData } from '@studio/common/lib/blueprint-validation';
import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error';
import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites';
import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore';
import { extractZip } from '@studio/common/lib/extract-zip';
import {
Expand All @@ -52,11 +53,7 @@ import { isMultisite } from '@studio/common/lib/is-multisite';
import { getAuthenticationUrl } from '@studio/common/lib/oauth';
import { decodePassword, encodePassword } from '@studio/common/lib/passwords';
import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name';
import {
getCurrentUserId,
readSharedConfig,
updateSharedConfig,
} from '@studio/common/lib/shared-config';
import { readSharedConfig, updateSharedConfig } from '@studio/common/lib/shared-config';
import { SYNC_IGNORE_DEFAULTS } from '@studio/common/lib/sync/constants';
import { shouldExcludeFromSync } from '@studio/common/lib/sync/exclude-from-sync';
import { shouldLimitDepth } from '@studio/common/lib/sync/tree-utils';
Expand Down Expand Up @@ -270,12 +267,7 @@ async function reconcileSessionEnvironmentBeforeRun( sessionId: string ): Promis
return;
}

const currentUserId = await getCurrentUserId();
const userData = currentUserId ? await loadUserData() : undefined;
const connected = userData?.connectedWpcomSites?.[ currentUserId! ] ?? [];
const connectedForOwner = connected.filter(
( site ) => site.localSiteId === ownerServer.details.id
);
const connectedForOwner = await getConnectedWpcomSitesForLocalSite( ownerServer.details.id );
const connectedIds = new Set( connectedForOwner.map( ( site ) => site.id ) );

const effective = deriveEffectiveEnvironment( summary, ( blogId ) => connectedIds.has( blogId ) );
Expand Down Expand Up @@ -376,10 +368,7 @@ export async function setSessionEnvironment(
const timestamp = new Date().toISOString();

if ( environment === 'live' ) {
const currentUserId = await getCurrentUserId();
const userData = currentUserId ? await loadUserData() : undefined;
const connected = userData?.connectedWpcomSites?.[ currentUserId! ] ?? [];
const candidates = connected.filter( ( s ) => s.localSiteId === ownerServer.details.id );
const candidates = await getConnectedWpcomSitesForLocalSite( ownerServer.details.id );
// Prefer the production (non-staging) site to match the UI's
// `pickLiveSite` behavior in the site dropdown.
const liveSite = candidates.find( ( s ) => ! s.isStaging ) ?? candidates[ 0 ];
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/src/migrations/02-migrate-to-split-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ function getOldAppdataPath(): string {
return path.join( os.homedir(), 'Library', 'Application Support', 'Studio', 'appdata-v1.json' );
}

// Pick only the authToken and locale fields from the shared config schema, because this is what we
// expected when this migration was implemented.
const sharedConfigExtractSchema = z.object( {
...sharedConfigSchema.omit( { version: true } ).shape,
...sharedConfigSchema.pick( { authToken: true, locale: true } ).shape,
} );

const cliSiteSchema = siteDetailsSchema.extend( {
Expand Down
84 changes: 84 additions & 0 deletions apps/studio/src/migrations/04-migrate-connected-sites-to-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Moves `connectedWpcomSites` from `app.json` into `shared.json` so both the
* Studio app and the Studio CLI can read and write it through the helpers in
* `tools/common/lib/connected-sites.ts`.
*
* Runs whenever app.json still carries the legacy `connectedWpcomSites`
* field. The copy into shared.json is skipped if shared.json already has the
* field (e.g. the CLI's own migration ran first), but the field is always
* stripped from app.json — once both halves of Studio agree that
* connections live in shared.json there's no reason to keep the stale copy
* around. Data loss is bounded: an older Studio that boots after this
* migration will simply re-add an empty `connectedWpcomSites` to app.json,
* which the next run of this migration will clean up.
*/

import fs from 'node:fs';
import {
lockSharedConfig,
readSharedConfig,
saveSharedConfig,
unlockSharedConfig,
} from '@studio/common/lib/shared-config';
import { getAppConfigPath } from '@studio/common/lib/well-known-paths';
import { syncSiteSchema } from '@studio/common/types/sync';
import { readFile, writeFile } from 'atomically';
import { z } from 'zod';
import { lockAppdata, unlockAppdata } from 'src/storage/user-data';
import type { Migration } from '@studio/common/lib/migration';

const appConnectedShapeSchema = z
.object( {
connectedWpcomSites: z.record( z.string(), z.array( syncSiteSchema ) ).optional(),
} )
.loose();

async function readAppConfigWithConnectedWpcomSites() {
const appPath = getAppConfigPath();
if ( ! fs.existsSync( appPath ) ) {
return null;
}
try {
const raw = await readFile( appPath, { encoding: 'utf8' } );
const parsed = JSON.parse( raw );
return appConnectedShapeSchema.parse( parsed );
} catch {
return null;
}
}

export const migrateConnectedSitesToShared: Migration = {
async needsToRun() {
const parsed = await readAppConfigWithConnectedWpcomSites();
return !! parsed?.connectedWpcomSites && Object.keys( parsed.connectedWpcomSites ).length > 0;
},
async run() {
const parsed = await readAppConfigWithConnectedWpcomSites();
if ( ! parsed ) {
return;
}

try {
await lockSharedConfig();
const shared = await readSharedConfig();
if ( ! shared.connectedWpcomSites ) {
await saveSharedConfig( {
...shared,
connectedWpcomSites: parsed.connectedWpcomSites,
} );
}
} finally {
await unlockSharedConfig();
}

try {
await lockAppdata();
const { connectedWpcomSites: _legacy, ...rest } = parsed;
await writeFile( getAppConfigPath(), JSON.stringify( rest, null, 2 ) + '\n', {
encoding: 'utf8',
} );
} finally {
await unlockAppdata();
}
},
};
Loading
Loading