Skip to content

Commit 4810df1

Browse files
epeichersejas
andauthored
Bundle WordPress agent skills into Studio binary (#2741)
* Bundle WordPress agent skills into Studio binary Add support for bundling 5 curated WordPress skills from the WordPress/agent-skills repository into Studio's binary distribution. Skills are downloaded on-demand via `npm run download-agent-skills`, bundled as extra resources, and copied to appdata server-files at app startup following the existing pattern. - Add download script for fetching skills from GitHub - Add skills installation logic with symlink creation for .agents/ and .claude/ dirs - Add IPC handlers and UI row in AI settings modal for manual install/reinstall - Auto-install skills on site creation (desktop + CLI), behind ENABLE_AGENT_SUITE flag - Add 9 unit tests for skills module * Address PR review: shared skill constants, translations, deduplicate install logic - Move BUNDLED_SKILL_IDS and installSkillsToSite to @studio/common/lib/agent-skills so both Studio and CLI share a single source of truth - Add __() translations to skill display names and descriptions - Remove duplicated installAgentSkills from CLI create.ts, use shared function - Simplify skills.ts by delegating to shared installSkillsToSite - Add 7 tests for the shared agent-skills module * Address code review: shared skill IDs, symlink fix, Windows compat 1. Use BUNDLED_SKILL_IDS from shared module in download script (single source of truth) 3. Fix symlink early-return bug: create .claude symlink even when .agents copy exists 4. Add Windows symlink fallback: fall back to junction on EPERM * Revert formatting change in forge.config.ts to match trunk * Trigger E2E tests * Fix blank screen caused by Node.js imports leaking into renderer bundle skills-constants.ts re-exported from @studio/common/lib/agent-skills, which imports fs/promises and path. Since ai-settings-modal.tsx imports skills-constants.ts, this pulled Node.js modules into the renderer bundle, crashing it silently. Removed the unused re-export. * Download WordPress skills as a postinstall step --------- Co-authored-by: Antonio Sejas <antonio.sejas@automattic.com>
1 parent fca9426 commit 4810df1

File tree

14 files changed

+667
-5
lines changed

14 files changed

+667
-5
lines changed

apps/cli/commands/site/create.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
DEFAULT_WORDPRESS_VERSION,
1010
MINIMUM_WORDPRESS_VERSION,
1111
} from '@studio/common/constants';
12+
import { installSkillsToSite } from '@studio/common/lib/agent-skills';
1213
import { extractFormValuesFromBlueprint } from '@studio/common/lib/blueprint-settings';
1314
import {
1415
filterUnsupportedBlueprintFeatures,
@@ -62,7 +63,7 @@ import {
6263
import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client';
6364
import { generateSiteName, getDefaultSitePath } from 'cli/lib/generate-site-name';
6465
import { copyLanguagePackToSite } from 'cli/lib/language-packs';
65-
import { getServerFilesPath } from 'cli/lib/server-files';
66+
import { getAgentSkillsPath, getServerFilesPath } from 'cli/lib/server-files';
6667
import { getPreferredSiteLanguage } from 'cli/lib/site-language';
6768
import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils';
6869
import { writeSkillMd } from 'cli/lib/skill-md';
@@ -442,6 +443,11 @@ export async function runCommand(
442443
console.log( __( 'Run "studio site start" to start the site.' ) );
443444
}
444445

446+
// Install bundled WordPress agent skills
447+
if ( process.env.ENABLE_AGENT_SUITE === 'true' ) {
448+
await installSkillsToSite( sitePath, getAgentSkillsPath() );
449+
}
450+
445451
logger.reportKeyValuePair( 'id', siteDetails.id );
446452
logger.reportKeyValuePair( 'running', String( siteDetails.running ) );
447453
await emitSiteEvent( SITE_EVENTS.CREATED, { siteId: siteDetails.id } );

apps/cli/lib/server-files.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ export function getSqliteCommandPath(): string {
1919
export function getLanguagePacksPath(): string {
2020
return path.join( getServerFilesPath(), 'language-packs' );
2121
}
22+
23+
export function getAgentSkillsPath(): string {
24+
return path.join( getServerFilesPath(), 'agent-skills' );
25+
}

apps/studio/src/components/ai-settings-modal.tsx

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
type InstructionFileType,
1111
} from 'src/modules/agent-instructions/constants';
1212
import { type InstructionFileStatus } from 'src/modules/agent-instructions/lib/instructions';
13+
import {
14+
BUNDLED_SKILLS,
15+
type SkillStatus,
16+
} from 'src/modules/agent-instructions/lib/skills-constants';
1317

1418
interface AiSettingsModalProps {
1519
isOpen: boolean;
@@ -161,6 +165,110 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) {
161165
);
162166
}
163167

168+
function WordPressSkillsPanel( { siteId }: { siteId: string } ) {
169+
const { __ } = useI18n();
170+
const [ statuses, setStatuses ] = useState< SkillStatus[] >( [] );
171+
const [ error, setError ] = useState< string | null >( null );
172+
const [ installing, setInstalling ] = useState( false );
173+
174+
const refreshStatus = useCallback( async () => {
175+
try {
176+
const result = await getIpcApi().getWordPressSkillsStatus( siteId );
177+
setStatuses( result as SkillStatus[] );
178+
setError( null );
179+
} catch ( err ) {
180+
const errorMessage = err instanceof Error ? err.message : String( err );
181+
setError( errorMessage );
182+
}
183+
}, [ siteId ] );
184+
185+
useEffect( () => {
186+
void refreshStatus();
187+
const handleFocus = () => void refreshStatus();
188+
window.addEventListener( 'focus', handleFocus );
189+
return () => window.removeEventListener( 'focus', handleFocus );
190+
}, [ refreshStatus ] );
191+
192+
const handleInstall = useCallback(
193+
async ( overwrite: boolean = false ) => {
194+
setInstalling( true );
195+
setError( null );
196+
try {
197+
await getIpcApi().installWordPressSkills( siteId, { overwrite } );
198+
await refreshStatus();
199+
} catch ( err ) {
200+
const errorMessage = err instanceof Error ? err.message : String( err );
201+
setError( errorMessage );
202+
} finally {
203+
setInstalling( false );
204+
}
205+
},
206+
[ siteId, refreshStatus ]
207+
);
208+
209+
const allInstalled = statuses.length > 0 && statuses.every( ( s ) => s.installed );
210+
const installedCount = statuses.filter( ( s ) => s.installed ).length;
211+
212+
return (
213+
<div className="flex flex-col gap-4">
214+
<div className="flex items-center justify-between">
215+
<div>
216+
<h3 className="text-sm font-medium text-gray-900">{ __( 'WordPress skills' ) }</h3>
217+
<p className="text-xs text-gray-500 mt-0.5">
218+
{ __( 'WordPress development skills for AI agents' ) }
219+
</p>
220+
</div>
221+
</div>
222+
223+
{ error && (
224+
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded text-sm">
225+
{ error }
226+
</div>
227+
) }
228+
229+
<div className="border border-gray-200 rounded-md overflow-hidden">
230+
<div className="flex items-center justify-between px-3 py-2.5">
231+
<div className="flex-1 min-w-0 pr-3">
232+
<div className="flex items-center gap-2">
233+
<span className="text-sm font-medium text-gray-900">
234+
{ __( 'WordPress Skills' ) }
235+
</span>
236+
{ allInstalled && (
237+
<span className="inline-flex items-center gap-1 text-[11px] text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
238+
<Icon icon={ check } size={ 12 } />
239+
{ __( 'Installed' ) }
240+
</span>
241+
) }
242+
{ ! allInstalled && installedCount > 0 && (
243+
<span className="inline-flex items-center gap-1 text-[11px] text-orange-700 bg-orange-50 px-2 py-0.5 rounded-full">
244+
{ `${ installedCount }/${ BUNDLED_SKILLS.length }` }
245+
</span>
246+
) }
247+
</div>
248+
<div className="text-xs text-gray-500">
249+
{ __( 'Plugins, blocks, themes, REST API, and WP-CLI skills' ) }
250+
</div>
251+
</div>
252+
<div className="flex items-center gap-2 flex-shrink-0">
253+
<Button
254+
variant="secondary"
255+
onClick={ () => handleInstall( allInstalled ) }
256+
disabled={ installing }
257+
className="text-xs py-1 px-2"
258+
>
259+
{ installing
260+
? __( 'Installing...' )
261+
: allInstalled
262+
? __( 'Reinstall' )
263+
: __( 'Install' ) }
264+
</Button>
265+
</div>
266+
</div>
267+
</div>
268+
</div>
269+
);
270+
}
271+
164272
export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalProps ) {
165273
const { __ } = useI18n();
166274

@@ -176,8 +284,9 @@ export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalPro
176284
size="medium"
177285
className="min-h-[350px] app-no-drag-region"
178286
>
179-
<div className="px-2 pb-4 flex gap-4 flex-col">
287+
<div className="px-2 pb-4 flex gap-6 flex-col">
180288
<AgentInstructionsPanel siteId={ siteId } />
289+
<WordPressSkillsPanel siteId={ siteId } />
181290
</div>
182291
</Modal>
183292
);

apps/studio/src/ipc-handlers.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
trustRootCA,
5050
} from 'src/lib/certificate-manager';
5151
import { simplifyErrorForDisplay } from 'src/lib/error-formatting';
52-
import { buildFeatureFlags } from 'src/lib/feature-flags';
52+
import { buildFeatureFlags, getFeatureFlagFromEnv } from 'src/lib/feature-flags';
5353
import { getImageData } from 'src/lib/get-image-data';
5454
import { exportBackup } from 'src/lib/import-export/export/export-manager';
5555
import { ExportOptions } from 'src/lib/import-export/export/types';
@@ -74,6 +74,11 @@ import {
7474
installInstructionFile,
7575
type InstructionFileStatus,
7676
} from 'src/modules/agent-instructions/lib/instructions';
77+
import {
78+
getSkillsStatus,
79+
installAllSkills,
80+
type SkillStatus,
81+
} from 'src/modules/agent-instructions/lib/skills';
7782
import { editSiteViaCli, EditSiteOptions } from 'src/modules/cli/lib/cli-site-editor';
7883
import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers';
7984
import { STABLE_BIN_DIR_PATH } from 'src/modules/cli/lib/windows-installation-manager';
@@ -164,6 +169,30 @@ export async function installAgentInstructions(
164169
);
165170
}
166171

172+
export async function getWordPressSkillsStatus(
173+
_event: IpcMainInvokeEvent,
174+
siteId: string
175+
): Promise< SkillStatus[] > {
176+
const server = SiteServer.get( siteId );
177+
if ( ! server ) {
178+
throw new Error( `Site not found: ${ siteId }` );
179+
}
180+
return getSkillsStatus( server.details.path );
181+
}
182+
183+
export async function installWordPressSkills(
184+
_event: IpcMainInvokeEvent,
185+
siteId: string,
186+
options?: { overwrite?: boolean }
187+
): Promise< void > {
188+
const server = SiteServer.get( siteId );
189+
if ( ! server ) {
190+
throw new Error( `Site not found: ${ siteId }` );
191+
}
192+
const overwrite = options?.overwrite ?? false;
193+
await installAllSkills( server.details.path, overwrite );
194+
}
195+
167196
const DEBUG_LOG_MAX_LINES = 50;
168197
const PM2_HOME = nodePath.join( os.homedir(), '.studio', 'pm2' );
169198
const DEFAULT_ENCODED_PASSWORD = encodePassword( 'password' );
@@ -337,6 +366,14 @@ export async function createSite(
337366
void loadThemeDetails( event, server.details.id );
338367
}
339368

369+
// Install agent instructions and WordPress skills into the new site
370+
if ( getFeatureFlagFromEnv( 'enableAgentSuite' ) ) {
371+
void installInstructionFile( path, 'agents', DEFAULT_AGENT_INSTRUCTIONS, false ).catch(
372+
() => {}
373+
);
374+
void installAllSkills( path, false ).catch( () => {} );
375+
}
376+
340377
return server.details;
341378
} catch ( error ) {
342379
// Skip WASM memory errors - they're user system issues, not bugs

apps/studio/src/lib/server-files-paths.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,10 @@ export function getWpCliPath(): string {
7171
export function getLanguagePacksPath(): string {
7272
return path.join( getBasePath(), 'language-packs' );
7373
}
74+
75+
/**
76+
* The path where bundled agent skills are stored.
77+
*/
78+
export function getAgentSkillsPath(): string {
79+
return path.join( getBasePath(), 'agent-skills' );
80+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { __ } from '@wordpress/i18n';
2+
3+
export interface SkillConfig {
4+
id: string;
5+
displayName: string;
6+
description: string;
7+
}
8+
9+
export interface SkillStatus extends SkillConfig {
10+
installed: boolean;
11+
}
12+
13+
export const BUNDLED_SKILLS: SkillConfig[] = [
14+
{
15+
id: 'wp-plugin-development',
16+
displayName: __( 'Plugin Development' ),
17+
description: __( 'Hooks, settings API, security, and packaging' ),
18+
},
19+
{
20+
id: 'wp-block-development',
21+
displayName: __( 'Block Development' ),
22+
description: __( 'Block.json, attributes, rendering, and deprecations' ),
23+
},
24+
{
25+
id: 'wp-block-themes',
26+
displayName: __( 'Block Themes' ),
27+
description: __( 'Theme.json, templates, patterns, and style variations' ),
28+
},
29+
{
30+
id: 'wp-rest-api',
31+
displayName: __( 'REST API' ),
32+
description: __( 'Routes, endpoints, schema, and authentication' ),
33+
},
34+
{
35+
id: 'wp-wpcli-and-ops',
36+
displayName: __( 'WP-CLI & Ops' ),
37+
description: __( 'CLI commands, automation, and search-replace' ),
38+
},
39+
];
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import fs from 'fs/promises';
2+
import nodePath from 'path';
3+
import { installSkillsToSite } from '@studio/common/lib/agent-skills';
4+
import { getAgentSkillsPath } from 'src/lib/server-files-paths';
5+
import { BUNDLED_SKILLS, type SkillStatus } from './skills-constants';
6+
7+
export { BUNDLED_SKILLS, type SkillConfig, type SkillStatus } from './skills-constants';
8+
9+
export function getBundledSkillsPath(): string {
10+
return getAgentSkillsPath();
11+
}
12+
13+
export async function getSkillsStatus( sitePath: string ): Promise< SkillStatus[] > {
14+
return Promise.all(
15+
BUNDLED_SKILLS.map( async ( skill ) => {
16+
const skillMdPath = nodePath.join( sitePath, '.agents', 'skills', skill.id, 'SKILL.md' );
17+
let installed = false;
18+
try {
19+
await fs.access( skillMdPath );
20+
installed = true;
21+
} catch {
22+
// Skill not installed or incomplete
23+
}
24+
return { ...skill, installed };
25+
} )
26+
);
27+
}
28+
29+
export async function installAllSkills(
30+
sitePath: string,
31+
overwrite: boolean = false
32+
): Promise< void > {
33+
await installSkillsToSite( sitePath, getBundledSkillsPath(), overwrite );
34+
}

0 commit comments

Comments
 (0)