diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 4f60765799..b6d8cf9f7a 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -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'; @@ -81,6 +83,7 @@ import { type InstructionFileStatus, } from 'src/modules/agent-instructions/lib/instructions'; import { + BUNDLED_SKILLS, getSkillsStatus, installAllSkills, type SkillStatus, @@ -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 + ), + } ) ); +} + +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' ); diff --git a/apps/studio/src/modules/user-settings/components/skills-tab.tsx b/apps/studio/src/modules/user-settings/components/skills-tab.tsx new file mode 100644 index 0000000000..3e1115b4f6 --- /dev/null +++ b/apps/studio/src/modules/user-settings/components/skills-tab.tsx @@ -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 ( +
+

+ { __( 'Agents can decide to use skills to help them accomplish specialized tasks.' ) } +

+ + { error && ( +
+ { error } +
+ ) } + + { installedSkills.length > 0 && ( +
+
+ + { __( 'Installed' ) } + +
+ { installedSkills.map( ( skill ) => ( +
+
+
+ { skill.displayName } + + + { __( 'Installed' ) } + +
+
{ skill.description }
+
+ + { ( { onClose }: { onClose: () => void } ) => ( + + { + void handleRemoveSkill( skill.id ); + onClose(); + } } + > + { __( 'Remove' ) } + + + ) } + +
+ ) ) } +
+ ) } + + { availableSkills.length > 0 && ( +
+
+ + { __( 'Available' ) } + + +
+ { availableSkills.map( ( skill ) => { + const isInstallingThis = installingSkillId === skill.id; + return ( +
+
+
{ skill.displayName }
+
{ skill.description }
+
+
+ +
+
+ ); + } ) } +
+ ) } + + { statuses.length === 0 && ! error && ( +
{ __( 'Loading skills...' ) }
+ ) } +
+ ); +} diff --git a/apps/studio/src/modules/user-settings/components/user-settings.tsx b/apps/studio/src/modules/user-settings/components/user-settings.tsx index 10b2079517..835d260f9e 100644 --- a/apps/studio/src/modules/user-settings/components/user-settings.tsx +++ b/apps/studio/src/modules/user-settings/components/user-settings.tsx @@ -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'; @@ -125,7 +126,7 @@ export default function UserSettings() { { ( { name } ) => (
{ name === 'general' && } - { name === 'skills' &&

Skills Tab

} + { name === 'skills' && } { name === 'mcp' && } { name === 'account' && ( 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 ); diff --git a/tools/common/lib/agent-skills.ts b/tools/common/lib/agent-skills.ts index 2f47dd9d7a..796d87b396 100644 --- a/tools/common/lib/agent-skills.ts +++ b/tools/common/lib/agent-skills.ts @@ -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,