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 && ( +Skills Tab
} + { name === 'skills' &&