Skip to content
Merged
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
57 changes: 57 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import nodePath from 'path';
import * as Sentry from '@sentry/electron/main';
import {
installAiInstructionsToSite,
installSkillToSite,
removeSkillFromSite,
updateManagedInstructionFiles,
} from '@studio/common/lib/agent-skills';
import { validateBlueprintData } from '@studio/common/lib/blueprint-validation';
Expand Down Expand Up @@ -81,6 +83,7 @@ import {
type InstructionFileStatus,
} from 'src/modules/agent-instructions/lib/instructions';
import {
BUNDLED_SKILLS,
getSkillsStatus,
installAllSkills,
type SkillStatus,
Expand Down Expand Up @@ -196,6 +199,60 @@ export async function installWordPressSkills(
await installAllSkills( server.details.path, overwrite );
}

export async function getWordPressSkillsStatusAllSites(
_event: IpcMainInvokeEvent
): Promise< SkillStatus[] > {
const { sites } = await loadUserData();
if ( ! sites.length ) {
return BUNDLED_SKILLS.map( ( skill ) => ( { ...skill, installed: false } ) );
}
const allSiteStatuses = await Promise.all(
sites.map( ( site ) => getSkillsStatus( site.path ) )
);
return BUNDLED_SKILLS.map( ( skill ) => ( {
...skill,
installed: allSiteStatuses.every(
( siteStatuses ) => siteStatuses.find( ( s ) => s.id === skill.id )?.installed ?? false
),
} ) );
Comment on lines +212 to +217
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the follow-up STU-1451, Let's replace this logic and display user's selection saved in appdata.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds good to me, I will take a look at the follow up tomorrow morning 👀

}

export async function installWordPressSkillsToAllSites(
_event: IpcMainInvokeEvent,
options?: { skillId?: string; overwrite?: boolean }
): Promise< void > {
const { sites } = await loadUserData();
const overwrite = options?.overwrite ?? false;
const bundledPath = getAiInstructionsPath();
const tasks = sites.flatMap( ( site ) =>
options?.skillId
? [ installSkillToSite( site.path, bundledPath, options.skillId, overwrite ) ]
: BUNDLED_SKILLS.map( ( skill ) =>
installSkillToSite( site.path, bundledPath, skill.id, overwrite )
)
);
const results = await Promise.allSettled( tasks );
results.forEach( ( result ) => {
if ( result.status === 'rejected' ) {
console.error( '[skills] Failed to install skill:', result.reason );
}
} );
}

export async function removeWordPressSkillFromAllSites(
_event: IpcMainInvokeEvent,
skillId: string
): Promise< void > {
const { sites } = await loadUserData();
const tasks = sites.map( ( site ) => removeSkillFromSite( site.path, skillId ) );
const results = await Promise.allSettled( tasks );
results.forEach( ( result ) => {
if ( result.status === 'rejected' ) {
console.error( '[skills] Failed to remove skill:', result.reason );
}
} );
}

const DEBUG_LOG_MAX_LINES = 50;
const PM2_HOME = nodePath.join( os.homedir(), '.studio', 'pm2' );
const DEFAULT_ENCODED_PASSWORD = encodePassword( 'password' );
Expand Down
202 changes: 202 additions & 0 deletions apps/studio/src/modules/user-settings/components/skills-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
import { Icon, check, moreVertical } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Button from 'src/components/button';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { type SkillStatus } from 'src/modules/agent-instructions/lib/skills-constants';

export function SkillsTab() {
const { __ } = useI18n();
const [ statuses, setStatuses ] = useState< SkillStatus[] >( [] );
const [ error, setError ] = useState< string | null >( null );
const [ installingSkillId, setInstallingSkillId ] = useState< string | null >( null );
const [ installingAll, setInstallingAll ] = useState( false );

const refreshStatus = useCallback( async () => {
try {
const result = await getIpcApi().getWordPressSkillsStatusAllSites();
setStatuses( result as SkillStatus[] );
setError( null );
} catch ( err ) {
const errorMessage = err instanceof Error ? err.message : String( err );
setError( errorMessage );
}
}, [] );

useEffect( () => {
void refreshStatus();
const handleFocus = () => void refreshStatus();
window.addEventListener( 'focus', handleFocus );
return () => window.removeEventListener( 'focus', handleFocus );
}, [ refreshStatus ] );

const handleRemoveSkill = useCallback(
async ( skillId: string ) => {
setError( null );
try {
await getIpcApi().removeWordPressSkillFromAllSites( skillId );
await refreshStatus();
} catch ( err ) {
const errorMessage = err instanceof Error ? err.message : String( err );
setError( errorMessage );
}
},
[ refreshStatus ]
);

const handleInstallSkill = useCallback(
async ( skillId: string ) => {
setInstallingSkillId( skillId );
setError( null );
try {
await getIpcApi().installWordPressSkillsToAllSites( { skillId } );
await refreshStatus();
} catch ( err ) {
const errorMessage = err instanceof Error ? err.message : String( err );
setError( errorMessage );
} finally {
setInstallingSkillId( null );
}
},
[ refreshStatus ]
);

const wordPressSkills = useMemo(
() => statuses.filter( ( s ) => s.id !== 'studio-cli' ),
[ statuses ]
);
const installedSkills = useMemo(
() => wordPressSkills.filter( ( s ) => s.installed ),
[ wordPressSkills ]
);
const availableSkills = useMemo(
() => wordPressSkills.filter( ( s ) => ! s.installed ),
[ wordPressSkills ]
);

const handleInstallAll = useCallback( async () => {
setInstallingAll( true );
setError( null );
try {
for ( const skill of availableSkills ) {
await getIpcApi().installWordPressSkillsToAllSites( { skillId: skill.id } );
}
await refreshStatus();
} catch ( err ) {
const errorMessage = err instanceof Error ? err.message : String( err );
setError( errorMessage );
} finally {
setInstallingAll( false );
}
}, [ availableSkills, refreshStatus ] );

const isAnyInstalling = installingSkillId !== null || installingAll;

return (
<div className="flex flex-col gap-4 pb-2">
<p className="text-xs text-frame-text-secondary text-center">
{ __( 'Agents can decide to use skills to help them accomplish specialized tasks.' ) }
</p>

{ error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded text-sm">
{ error }
</div>
) }

{ installedSkills.length > 0 && (
<div className="border border-frame-border rounded-md overflow-hidden">
<div className="flex items-center px-3 py-2 bg-frame-surface border-b border-frame-border">
<span className="text-[11px] font-semibold uppercase tracking-wide text-frame-text-secondary">
{ __( 'Installed' ) }
</span>
</div>
{ installedSkills.map( ( skill ) => (
<div
key={ skill.id }
className="flex items-center justify-between px-3 py-2.5 border-b border-frame-border last:border-b-0"
>
<div className="flex-1 min-w-0 pr-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-frame-text">{ skill.displayName }</span>
<span className="inline-flex items-center gap-1 text-[11px] text-green-900 bg-green-50 dark:!text-green-300 dark:bg-green-950 px-2 py-0.5 rounded-full">
<Icon className="dark:fill-green-300" icon={ check } size={ 12 } />
{ __( 'Installed' ) }
</span>
</div>
<div className="text-xs text-frame-text-secondary">{ skill.description }</div>
</div>
<DropdownMenu
icon={ moreVertical }
label={ __( 'Skill actions' ) }
className="flex items-center"
popoverProps={ { position: 'bottom left', resize: true } }
>
{ ( { onClose }: { onClose: () => void } ) => (
<MenuGroup>
<MenuItem
isDestructive
onClick={ () => {
void handleRemoveSkill( skill.id );
onClose();
} }
>
{ __( 'Remove' ) }
</MenuItem>
</MenuGroup>
) }
</DropdownMenu>
</div>
) ) }
</div>
) }

{ availableSkills.length > 0 && (
<div className="border border-frame-border rounded-md overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-frame-surface border-b border-frame-border">
<span className="text-[11px] font-semibold uppercase tracking-wide text-frame-text-secondary">
{ __( 'Available' ) }
</span>
<Button
variant="secondary"
onClick={ handleInstallAll }
disabled={ isAnyInstalling }
className="text-xs py-1 px-2 [&.is-secondary]:bg-frame"
>
{ installingAll ? __( 'Installing...' ) : __( 'Install all' ) }
</Button>
</div>
{ availableSkills.map( ( skill ) => {
const isInstallingThis = installingSkillId === skill.id;
return (
<div
key={ skill.id }
className="flex items-center justify-between px-3 py-2.5 border-b border-frame-border last:border-b-0"
>
<div className="flex-1 min-w-0 pr-3">
<div className="text-sm font-medium text-frame-text">{ skill.displayName }</div>
<div className="text-xs text-frame-text-secondary">{ skill.description }</div>
</div>
<div className="flex-shrink-0">
<Button
variant="secondary"
onClick={ () => handleInstallSkill( skill.id ) }
disabled={ isAnyInstalling }
className="text-xs py-1 px-2"
>
{ isInstallingThis ? __( 'Installing...' ) : __( 'Install' ) }
</Button>
</div>
</div>
);
} ) }
</div>
) }

{ statuses.length === 0 && ! error && (
<div className="text-sm text-gray-500 text-center py-4">{ __( 'Loading skills...' ) }</div>
) }
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getIpcApi } from 'src/lib/get-ipc-api';
import { McpSettings } from 'src/modules/mcp/components/mcp-settings';
import { AccountTab } from 'src/modules/user-settings/components/account-tab';
import { PreferencesTab } from 'src/modules/user-settings/components/preferences-tab';
import { SkillsTab } from 'src/modules/user-settings/components/skills-tab';
import { UserSettingsTab } from 'src/modules/user-settings/user-settings-types';
import { useRootSelector } from 'src/stores';
import { snapshotSelectors } from 'src/stores/snapshot-slice';
Expand Down Expand Up @@ -125,7 +126,7 @@ export default function UserSettings() {
{ ( { name } ) => (
<div className="mt-6 px-8 pb-8 flex gap-4 flex-col">
{ name === 'general' && <PreferencesTab onClose={ resetLocalState } /> }
{ name === 'skills' && <p> Skills Tab </p> }
{ name === 'skills' && <SkillsTab /> }
{ name === 'mcp' && <McpSettings /> }
{ name === 'account' && (
<AccountTab
Expand Down
5 changes: 5 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ const api: IpcApi = {
getWordPressSkillsStatus: ( siteId ) => ipcRendererInvoke( 'getWordPressSkillsStatus', siteId ),
installWordPressSkills: ( siteId, options ) =>
ipcRendererInvoke( 'installWordPressSkills', siteId, options ),
getWordPressSkillsStatusAllSites: () => ipcRendererInvoke( 'getWordPressSkillsStatusAllSites' ),
installWordPressSkillsToAllSites: ( options ) =>
ipcRendererInvoke( 'installWordPressSkillsToAllSites', options ),
removeWordPressSkillFromAllSites: ( skillId ) =>
ipcRendererInvoke( 'removeWordPressSkillFromAllSites', skillId ),
};

contextBridge.exposeInMainWorld( 'ipcApi', api );
Expand Down
7 changes: 7 additions & 0 deletions tools/common/lib/agent-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ async function installInstructionFile(
await fs.copyFile( path.join( bundledPath, fileName ), dest );
}

export async function removeSkillFromSite( sitePath: string, skillId: string ): Promise< void > {
const agentsSkillPath = path.join( sitePath, '.agents', 'skills', skillId );
const claudeSkillPath = path.join( sitePath, '.claude', 'skills', skillId );
await fs.rm( agentsSkillPath, { recursive: true, force: true } );
await fs.rm( claudeSkillPath, { recursive: true, force: true } );
}

export async function installSkillToSite(
sitePath: string,
bundledPath: string,
Expand Down
Loading