From 3533db64a5afcebb88e9238528c10e1430429225 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:36:41 -0800 Subject: [PATCH 01/20] Fix ACP agent detection and communication issues - Filter available agents to show only wpcom, anthropic-builtin, claude-code-acp, opencode, and codex-acp - Add --yes --quiet flags to npx commands to suppress install prompts - Add npx fallback for codex-acp when binary not installed - Combine stdout/stderr for JSON-RPC stream (codex-acp writes to stderr) - Filter echoed requests from stream to prevent SDK confusion - Remove TTY requirement for codex-acp (ACP wrapper uses stdio, not interactive mode) --- src/hooks/use-agent-chat.ts | 466 ++++++++++++ src/modules/acp/lib/acp-process-manager.ts | 796 +++++++++++++++++++++ src/modules/acp/lib/acp-registry.ts | 291 ++++++++ src/modules/acp/lib/agent-detection.ts | 347 +++++++++ src/modules/acp/lib/ipc-handlers.ts | 374 ++++++++++ 5 files changed, 2274 insertions(+) create mode 100644 src/hooks/use-agent-chat.ts create mode 100644 src/modules/acp/lib/acp-process-manager.ts create mode 100644 src/modules/acp/lib/acp-registry.ts create mode 100644 src/modules/acp/lib/agent-detection.ts create mode 100644 src/modules/acp/lib/ipc-handlers.ts diff --git a/src/hooks/use-agent-chat.ts b/src/hooks/use-agent-chat.ts new file mode 100644 index 0000000000..5ed3203a7f --- /dev/null +++ b/src/hooks/use-agent-chat.ts @@ -0,0 +1,466 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { agentChatActions, agentChatSelectors, AgentMessage } from 'src/stores/agent-chat-slice'; + +// Stable handler refs to avoid StrictMode double-subscription issues +interface AcpSessionUpdateData { + sessionId: string; + type: string; + text?: string; + tool_use?: { id: string; name: string; input: Record< string, unknown > }; + tool_result?: { tool_use_id: string; output?: string; error?: string }; + thinking?: string; + progress?: { message: string; percentage?: number }; + approval_request?: { id: string; message: string; options: string[] }; + error?: { code: string; message: string }; +} + +interface ServerEvent { + type: string; + data: unknown; +} + +interface UseAgentChatOptions { + siteId: string; +} + +interface UseAgentChatResult { + messages: readonly AgentMessage[]; + isLoading: boolean; + isStreaming: boolean; + sendMessage: ( message: string ) => Promise< void >; + clearConversation: () => void; + instanceId: string; +} + +/** + * Hook for managing agent chat interactions. + * + * Supports two modes: + * - Built-in Anthropic agent: Uses HTTP/SSE streaming + * - ACP agents: Uses IPC with ACP process manager + * + * Handles: + * - Sending messages to the appropriate backend + * - Parsing responses and dispatching Redux actions + * - Error handling + */ +export function useAgentChat( { siteId }: UseAgentChatOptions ): UseAgentChatResult { + const dispatch = useAppDispatch(); + const instanceId = `agent_${ siteId }`; + + // Track ACP session ID for this chat + const acpSessionIdRef = useRef< string | null >( null ); + + const messages = useRootSelector( ( state ) => + agentChatSelectors.selectMessages( state, instanceId ) + ) as AgentMessage[]; + + const isLoading = useRootSelector( ( state ) => + agentChatSelectors.selectIsLoading( state, instanceId ) + ); + + const isStreaming = useRootSelector( ( state ) => + agentChatSelectors.selectIsStreaming( state, instanceId ) + ); + + const serverPort = useRootSelector( agentChatSelectors.selectAgentServerPort ); + + // Get the selected agent to determine which backend to use + const selectedAgent = useRootSelector( agentChatSelectors.selectSelectedAgent ); + + const handleServerEvent = useCallback( + ( event: ServerEvent ) => { + switch ( event.type ) { + case 'content_block_start': { + const data = event.data as { + index: number; + blockType: 'text' | 'tool_use'; + id?: string; + name?: string; + }; + dispatch( + agentChatActions.startContentBlock( { + instanceId, + index: data.index, + blockType: data.blockType, + id: data.id, + name: data.name, + } ) + ); + break; + } + case 'content_delta': { + const data = event.data as { index: number; text: string }; + dispatch( + agentChatActions.appendToAssistantMessage( { + instanceId, + text: data.text, + index: data.index, + } ) + ); + break; + } + case 'content_block_stop': { + const data = event.data as { + index: number; + blockType: 'text' | 'tool_use'; + id?: string; + name?: string; + input?: Record< string, unknown >; + }; + dispatch( + agentChatActions.finishContentBlock( { + instanceId, + index: data.index, + blockType: data.blockType, + id: data.id, + name: data.name, + input: data.input, + } ) + ); + break; + } + case 'tool_use_start': { + // Legacy event - kept for backwards compatibility + const data = event.data as { + id: string; + name: string; + input: Record< string, unknown >; + }; + dispatch( + agentChatActions.addToolCall( { + instanceId, + toolCall: data, + } ) + ); + break; + } + case 'tool_result': { + const data = event.data as { + toolCallId: string; + result: { success: boolean; output?: string; error?: string }; + }; + dispatch( + agentChatActions.addToolResult( { + instanceId, + toolCallId: data.toolCallId, + result: data.result, + } ) + ); + break; + } + case 'message_done': { + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + break; + } + case 'error': { + const data = event.data as { message: string }; + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: data.message, + } ) + ); + break; + } + } + }, + [ dispatch, instanceId ] + ); + + /** + * Handle ACP session update events from the main process. + * This function is stored in a ref to avoid useEffect re-running on changes. + */ + const handleAcpSessionUpdate = useCallback( + ( data: AcpSessionUpdateData ) => { + // Only handle events for our session + if ( data.sessionId !== acpSessionIdRef.current ) { + return; + } + + switch ( data.type ) { + case 'text': + if ( data.text ) { + dispatch( + agentChatActions.appendToAssistantMessage( { + instanceId, + text: data.text, + } ) + ); + } + break; + + case 'tool_use': + if ( data.tool_use ) { + dispatch( + agentChatActions.addToolCall( { + instanceId, + toolCall: { + id: data.tool_use.id, + name: data.tool_use.name, + input: data.tool_use.input, + }, + } ) + ); + } + break; + + case 'tool_result': + if ( data.tool_result ) { + const hasError = !! data.tool_result.error; + dispatch( + agentChatActions.addToolResult( { + instanceId, + toolCallId: data.tool_result.tool_use_id, + result: { + success: ! hasError, + output: data.tool_result.output, + error: data.tool_result.error, + }, + } ) + ); + } + break; + + case 'end': + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + break; + + case 'error': + if ( data.error ) { + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: data.error.message, + } ) + ); + } + break; + } + }, + [ dispatch, instanceId ] + ); + + // Store the handler in a ref to avoid re-subscribing on every render + const handleAcpSessionUpdateRef = useRef( handleAcpSessionUpdate ); + handleAcpSessionUpdateRef.current = handleAcpSessionUpdate; + + // Set up ACP event listeners + // Use refs for handlers to avoid StrictMode double-subscription issues + useEffect( () => { + const unsubscribeUpdate = window.ipcListener.subscribe( + 'acp-session-update', + ( _event, data ) => { + // Call through ref to always get the latest handler + handleAcpSessionUpdateRef.current( data ); + } + ); + + const unsubscribeError = window.ipcListener.subscribe( + 'acp-session-error', + ( _event, data ) => { + if ( data.sessionId === acpSessionIdRef.current ) { + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: data.error, + } ) + ); + } + } + ); + + const unsubscribeClosed = window.ipcListener.subscribe( + 'acp-session-closed', + ( _event, data ) => { + if ( data.sessionId === acpSessionIdRef.current ) { + acpSessionIdRef.current = null; + // Finish any pending message + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + } + } + ); + + return () => { + unsubscribeUpdate(); + unsubscribeError(); + unsubscribeClosed(); + }; + // Note: handleAcpSessionUpdate is accessed via ref, so it's not needed in deps + }, [ dispatch, instanceId ] ); + + /** + * Send message via built-in Anthropic HTTP server. + */ + const sendMessageViaHttp = useCallback( + async ( message: string ) => { + if ( ! serverPort ) { + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: 'Agent server not running', + } ) + ); + return; + } + + try { + const response = await fetch( `http://127.0.0.1:${ serverPort }/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { + message, + siteId, + instanceId, + history: messages.map( ( m ) => ( { + role: m.role, + content: m.content, + } ) ), + } ), + } ); + + if ( ! response.ok ) { + const error = await response.json(); + throw new Error( error.error || 'Failed to send message' ); + } + + // Process SSE stream + const reader = response.body?.getReader(); + if ( ! reader ) { + throw new Error( 'No response body' ); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + while ( true ) { + const { done, value } = await reader.read(); + if ( done ) { + break; + } + + buffer += decoder.decode( value, { stream: true } ); + const lines = buffer.split( '\n' ); + buffer = lines.pop() || ''; + + for ( const line of lines ) { + if ( line.startsWith( 'data: ' ) ) { + try { + const event = JSON.parse( line.slice( 6 ) ) as ServerEvent; + handleServerEvent( event ); + } catch ( e ) { + console.error( 'Failed to parse SSE event:', e ); + } + } + } + } + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: errorMessage, + } ) + ); + } + }, + [ dispatch, handleServerEvent, instanceId, messages, serverPort, siteId ] + ); + + /** + * Send message via ACP process manager. + */ + const sendMessageViaAcp = useCallback( + async ( message: string, agentId: string ) => { + try { + // Create ACP session if we don't have one + if ( ! acpSessionIdRef.current ) { + const session = await getIpcApi().createAcpSession( agentId, siteId ); + acpSessionIdRef.current = session.id; + + // Store session in Redux + dispatch( agentChatActions.setAcpSession( session ) ); + } + + // Send prompt to ACP agent + const result = await getIpcApi().sendAcpPrompt( acpSessionIdRef.current, message ); + + // Finish the message when prompt completes + if ( result.stopReason ) { + dispatch( agentChatActions.finishAssistantMessage( { instanceId } ) ); + } + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + dispatch( + agentChatActions.setMessageError( { + instanceId, + error: errorMessage, + } ) + ); + } + }, + [ dispatch, instanceId, siteId ] + ); + + const sendMessage = useCallback( + async ( message: string ) => { + if ( ! message.trim() || isLoading || isStreaming ) { + return; + } + + // Add user message + dispatch( agentChatActions.addUserMessage( { instanceId, message } ) ); + + // Start assistant message placeholder + dispatch( agentChatActions.startAssistantMessage( { instanceId } ) ); + + // Route to appropriate backend based on agent provider + if ( selectedAgent?.provider === 'acp' ) { + await sendMessageViaAcp( message, selectedAgent.id ); + } else { + // Default to built-in HTTP server (anthropic-builtin) + await sendMessageViaHttp( message ); + } + }, + [ + dispatch, + instanceId, + isLoading, + isStreaming, + selectedAgent, + sendMessageViaAcp, + sendMessageViaHttp, + ] + ); + + const clearConversation = useCallback( () => { + dispatch( agentChatActions.clearMessages( { instanceId } ) ); + + // Close ACP session if one exists + if ( acpSessionIdRef.current ) { + getIpcApi().closeAcpSession( acpSessionIdRef.current ).catch( console.error ); + acpSessionIdRef.current = null; + } + }, [ dispatch, instanceId ] ); + + // Clean up ACP session on unmount + useEffect( () => { + return () => { + if ( acpSessionIdRef.current ) { + getIpcApi().closeAcpSession( acpSessionIdRef.current ).catch( console.error ); + } + }; + }, [] ); + + return { + messages, + isLoading, + isStreaming, + sendMessage, + clearConversation, + instanceId, + }; +} diff --git a/src/modules/acp/lib/acp-process-manager.ts b/src/modules/acp/lib/acp-process-manager.ts new file mode 100644 index 0000000000..2a0b188a49 --- /dev/null +++ b/src/modules/acp/lib/acp-process-manager.ts @@ -0,0 +1,796 @@ +/** + * ACP Process Manager + * + * Manages spawning and lifecycle of ACP agent subprocesses using the official ACP SDK. + */ + +import { spawn, ChildProcess } from 'child_process'; +import type * as pty from 'node-pty'; +import { Readable, Writable } from 'stream'; +import { EventEmitter } from 'events'; +import { + ClientSideConnection, + ndJsonStream, + type Client, + type SessionNotification, + type RequestPermissionRequest, + type RequestPermissionResponse, + type ReadTextFileRequest, + type ReadTextFileResponse, + type WriteTextFileRequest, + type WriteTextFileResponse, + type CreateTerminalRequest, + type CreateTerminalResponse, + type TerminalOutputRequest, + type TerminalOutputResponse, + type ReleaseTerminalRequest, + type ReleaseTerminalResponse, + type WaitForTerminalExitRequest, + type WaitForTerminalExitResponse, + type KillTerminalCommandRequest, + type KillTerminalCommandResponse, + type NewSessionResponse, + type PromptResponse, +} from '@agentclientprotocol/sdk'; +import { detectAgentById, getAugmentedPath } from './agent-detection'; +import type { AgentConfig, AcpSession } from '../types'; + +function loadPty(): typeof import( 'node-pty' ) { + try { + return require( 'node-pty' ); + } catch ( error ) { + throw new Error( + 'node-pty is required to run this agent. Please install dependencies and try again.' + ); + } +} + +/** + * Callback handler for file system operations. + */ +export interface AcpCallbackHandler { + readTextFile?: ( path: string ) => Promise< string >; + writeTextFile?: ( path: string, content: string ) => Promise< void >; + requestPermission?: ( + toolCall: unknown, + options: Array< { optionId: string; name: string; kind: string } > + ) => Promise< { outcome: 'selected' | 'cancelled'; optionId?: string } >; +} + +/** + * Terminal instance for ACP terminal capability. + */ +interface AcpTerminal { + id: string; + process: ChildProcess; + output: string; + outputByteLimit: number; + exitCode: number | null; + exitSignal: string | null; + exited: boolean; + exitPromise: Promise< void >; + exitResolve: () => void; +} + +/** + * Running ACP process state. + */ +interface RunningProcess { + agentId: string; + siteId: string; + process: ChildProcess | pty.IPty; + connection: ClientSideConnection; + session: AcpSession; + callbackHandler?: AcpCallbackHandler; + terminals: Map< string, AcpTerminal >; + terminalCounter: number; +} + +/** + * ACP Process Manager. + * + * Manages the lifecycle of ACP agent subprocesses using the official ACP SDK. + */ +export class AcpProcessManager extends EventEmitter { + private processes: Map< string, RunningProcess > = new Map(); + private sessionCounter = 0; + + constructor() { + super(); + } + + /** + * Generate a unique session ID. + */ + private nextSessionId(): string { + return `acp-session-${ Date.now() }-${ ++this.sessionCounter }`; + } + + private isPtyProcess( proc: ChildProcess | pty.IPty ): proc is pty.IPty { + return typeof ( proc as pty.IPty ).onData === 'function'; + } + + private getProcessStreams( proc: ChildProcess | pty.IPty ): { + input: WritableStream< Uint8Array >; + output: ReadableStream< Uint8Array >; + } { + if ( this.isPtyProcess( proc ) ) { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const input = new WritableStream< Uint8Array >( { + write( chunk ) { + proc.write( decoder.decode( chunk ) ); + }, + } ); + + const output = new ReadableStream< Uint8Array >( { + start( controller ) { + proc.onData( ( data ) => { + controller.enqueue( encoder.encode( data ) ); + } ); + proc.onExit( () => controller.close() ); + }, + } ); + + return { input, output }; + } + + const input = Writable.toWeb( proc.stdin! ) as WritableStream< Uint8Array >; + + // Some agents (like codex-acp) write JSON-RPC to stderr instead of stdout, + // and may also echo requests back. We need to: + // 1. Combine stdout and stderr + // 2. Filter out echoed requests (messages with 'method' field that we sent) + // 3. Only pass through responses and valid incoming requests from the agent + const encoder = new TextEncoder(); + let buffer = ''; + + const output = new ReadableStream< Uint8Array >( { + start( controller ) { + const processData = ( data: Buffer ) => { + buffer += data.toString(); + const lines = buffer.split( '\n' ); + buffer = lines.pop() || ''; + + for ( const line of lines ) { + const trimmed = line.trim(); + if ( ! trimmed ) { + continue; + } + + // Try to parse as JSON to filter echoed requests + try { + const msg = JSON.parse( trimmed ); + + // Skip echoed requests - these are requests WE sent that the agent echoes back + // They have 'method' field AND 'id' field (requests we send always have IDs) + // Valid incoming requests from agent (callbacks) also have 'method' but we should handle those + // The key is: our outgoing requests use methods like 'initialize', 'newSession', 'prompt' + // Agent callbacks use methods like 'readTextFile', 'writeTextFile', etc. + const echoMethods = [ 'initialize', 'newSession', 'prompt', 'cancel' ]; + if ( msg.method && msg.id !== undefined && echoMethods.includes( msg.method ) ) { + // This looks like an echoed request, skip it + console.log( `ACP: Skipping echoed request: ${ msg.method }` ); + continue; + } + + // Pass through responses (have result or error) and valid callbacks + controller.enqueue( encoder.encode( trimmed + '\n' ) ); + } catch { + // Not valid JSON, skip (log messages, spinners, etc.) + } + } + }; + + proc.stdout?.on( 'data', processData ); + proc.stderr?.on( 'data', processData ); + proc.on( 'exit', () => controller.close() ); + proc.on( 'error', () => controller.close() ); + }, + } ); + + return { input, output }; + } + + /** + * Create a new ACP session with an agent. + */ + async createSession( + agentId: string, + siteId: string, + workingDirectory: string, + callbackHandler?: AcpCallbackHandler + ): Promise< AcpSession > { + const agentConfig = await detectAgentById( agentId ); + + if ( ! agentConfig ) { + throw new Error( `Unknown agent: ${ agentId }` ); + } + + if ( agentConfig.provider !== 'acp' ) { + throw new Error( `Agent ${ agentId } is not an ACP agent` ); + } + + if ( agentConfig.status === 'unavailable' ) { + throw new Error( `Agent ${ agentId } is not available` ); + } + + if ( ! agentConfig.command ) { + throw new Error( `Agent ${ agentId } has no command defined` ); + } + + const sessionId = this.nextSessionId(); + + // Create session object + const session: AcpSession = { + id: sessionId, + agentId, + siteId, + state: 'starting', + createdAt: Date.now(), + }; + + try { + // Spawn the agent process + console.log( `ACP [${ sessionId }] Spawning agent: ${ agentConfig.command } ${ ( agentConfig.args ?? [] ).join( ' ' ) }` ); + const proc = this.spawnAgent( agentConfig, workingDirectory ); + console.log( `ACP [${ sessionId }] Process spawned with PID: ${ proc.pid }` ); + + // Convert Node streams to Web streams for the SDK + const io = this.getProcessStreams( proc ); + + // Create the ACP stream + const stream = ndJsonStream( io.input, io.output ); + console.log( `ACP [${ sessionId }] Stream created` ); + + // Create the client handler that will receive agent callbacks + const clientHandler = this.createClientHandler( sessionId, callbackHandler ); + + // Create the connection using the official SDK + const connection = new ClientSideConnection( () => clientHandler, stream ); + console.log( `ACP [${ sessionId }] Connection created` ); + + // Store the running process + const runningProcess: RunningProcess = { + agentId, + siteId, + process: proc, + connection, + session, + callbackHandler, + terminals: new Map(), + terminalCounter: 0, + }; + + this.processes.set( sessionId, runningProcess ); + + // Set up process event handlers + this.setupProcessHandlers( sessionId, proc ); + + // Initialize the connection and create session + console.log( `ACP [${ sessionId }] Starting initialization...` ); + const newSessionResult = await this.initializeSession( sessionId, workingDirectory ); + console.log( `ACP [${ sessionId }] Initialization complete:`, newSessionResult ); + + // Update session state + session.pid = proc.pid; + session.state = 'ready'; + session.acpSessionId = newSessionResult.sessionId; + + this.emit( 'session_ready', sessionId, newSessionResult ); + + return session; + } catch ( error ) { + session.state = 'error'; + session.error = error instanceof Error ? error.message : String( error ); + this.processes.delete( sessionId ); + throw error; + } + } + + /** + * Create a Client handler for the ACP connection. + */ + private createClientHandler( sessionId: string, callbackHandler?: AcpCallbackHandler ): Client { + const self = this; + + const getRunningProcess = () => { + const proc = self.processes.get( sessionId ); + if ( ! proc ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + return proc; + }; + + return { + // Handle session updates (streaming content from agent) + async sessionUpdate( params: SessionNotification ): Promise< void > { + console.log( `ACP [${ sessionId }] sessionUpdate received:`, JSON.stringify( params ).slice( 0, 500 ) ); + self.emit( 'session_update', sessionId, params ); + }, + + // Handle permission requests from agent + async requestPermission( + params: RequestPermissionRequest + ): Promise< RequestPermissionResponse > { + if ( callbackHandler?.requestPermission ) { + const result = await callbackHandler.requestPermission( + params.toolCall, + params.options.map( ( opt ) => ( { + optionId: opt.optionId, + name: opt.name, + kind: opt.kind, + } ) ) + ); + + if ( result.outcome === 'cancelled' ) { + return { outcome: { outcome: 'cancelled' } }; + } + + return { + outcome: { + outcome: 'selected', + optionId: result.optionId!, + }, + }; + } + + // Default: auto-approve with first option + if ( params.options.length > 0 ) { + return { + outcome: { + outcome: 'selected', + optionId: params.options[ 0 ].optionId, + }, + }; + } + + return { outcome: { outcome: 'cancelled' } }; + }, + + // Handle file read requests from agent + async readTextFile( params: ReadTextFileRequest ): Promise< ReadTextFileResponse > { + if ( callbackHandler?.readTextFile ) { + const content = await callbackHandler.readTextFile( params.path ); + return { content }; + } + throw new Error( 'File read not supported' ); + }, + + // Handle file write requests from agent + async writeTextFile( params: WriteTextFileRequest ): Promise< WriteTextFileResponse > { + if ( callbackHandler?.writeTextFile ) { + await callbackHandler.writeTextFile( params.path, params.content ); + return {}; + } + throw new Error( 'File write not supported' ); + }, + + // Create a new terminal and execute a command + async createTerminal( params: CreateTerminalRequest ): Promise< CreateTerminalResponse > { + const runningProcess = getRunningProcess(); + const terminalId = `terminal-${ ++runningProcess.terminalCounter }`; + const outputByteLimit = Number( params.outputByteLimit ?? 1024 * 1024 ); // Default 1MB + + console.log( `ACP [${ sessionId }] createTerminal: ${ params.command } ${ ( params.args ?? [] ).join( ' ' ) }` ); + + // Build environment + const env: Record< string, string > = { ...process.env } as Record< string, string >; + env.PATH = getAugmentedPath(); + if ( params.env ) { + for ( const { name, value } of params.env ) { + env[ name ] = value; + } + } + + let exitResolve: () => void = () => {}; + const exitPromise = new Promise< void >( ( resolve ) => { + exitResolve = resolve; + } ); + + const terminal: AcpTerminal = { + id: terminalId, + process: null as unknown as ChildProcess, + output: '', + outputByteLimit, + exitCode: null, + exitSignal: null, + exited: false, + exitPromise, + exitResolve, + }; + + // Spawn the process + const proc = spawn( params.command, params.args ?? [], { + cwd: params.cwd ?? process.cwd(), + env, + stdio: [ 'pipe', 'pipe', 'pipe' ], + shell: true, + } ); + + terminal.process = proc; + + // Collect output + const appendOutput = ( data: Buffer ) => { + terminal.output += data.toString(); + // Truncate from beginning if exceeds limit + if ( terminal.output.length > outputByteLimit ) { + terminal.output = terminal.output.slice( -outputByteLimit ); + } + }; + + proc.stdout?.on( 'data', appendOutput ); + proc.stderr?.on( 'data', appendOutput ); + + proc.on( 'exit', ( code, signal ) => { + terminal.exitCode = code; + terminal.exitSignal = signal ?? null; + terminal.exited = true; + terminal.exitResolve(); + } ); + + proc.on( 'error', ( error ) => { + terminal.output += `\nError: ${ error.message }`; + terminal.exited = true; + terminal.exitCode = 1; + terminal.exitResolve(); + } ); + + runningProcess.terminals.set( terminalId, terminal ); + + return { terminalId }; + }, + + // Get current terminal output + async terminalOutput( params: TerminalOutputRequest ): Promise< TerminalOutputResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + throw new Error( `Terminal not found: ${ params.terminalId }` ); + } + + const response: TerminalOutputResponse = { + output: terminal.output, + truncated: terminal.output.length >= terminal.outputByteLimit, + }; + + if ( terminal.exited ) { + response.exitStatus = { + exitCode: terminal.exitCode, + signal: terminal.exitSignal, + }; + } + + return response; + }, + + // Wait for terminal to exit + async waitForTerminalExit( params: WaitForTerminalExitRequest ): Promise< WaitForTerminalExitResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + throw new Error( `Terminal not found: ${ params.terminalId }` ); + } + + await terminal.exitPromise; + + return { + exitCode: terminal.exitCode, + signal: terminal.exitSignal, + }; + }, + + // Kill terminal command without releasing + async killTerminal( params: KillTerminalCommandRequest ): Promise< KillTerminalCommandResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + throw new Error( `Terminal not found: ${ params.terminalId }` ); + } + + if ( ! terminal.exited ) { + terminal.process.kill( 'SIGTERM' ); + // Force kill after timeout + setTimeout( () => { + if ( ! terminal.exited ) { + terminal.process.kill( 'SIGKILL' ); + } + }, 1000 ); + } + + return {}; + }, + + // Release terminal and free resources + async releaseTerminal( params: ReleaseTerminalRequest ): Promise< ReleaseTerminalResponse > { + const runningProcess = getRunningProcess(); + const terminal = runningProcess.terminals.get( params.terminalId ); + + if ( ! terminal ) { + return {}; + } + + // Kill if still running + if ( ! terminal.exited ) { + terminal.process.kill( 'SIGKILL' ); + } + + runningProcess.terminals.delete( params.terminalId ); + + return {}; + }, + }; + } + + /** + * Spawn an ACP agent process. + */ + private spawnAgent( agentConfig: AgentConfig, workingDirectory: string ): ChildProcess { + const command = agentConfig.command!; + const args = agentConfig.args ?? []; + + // Prepare environment with augmented PATH + const env: Record< string, string | undefined > = { + ...process.env, + PATH: getAugmentedPath(), + }; + + // Merge agent-specific environment variables + if ( agentConfig.env ) { + Object.assign( env, agentConfig.env ); + } + + if ( agentConfig.requiresTty ) { + const ptyModule = loadPty(); + const ptyProc = ptyModule.spawn( command, args, { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: workingDirectory, + env: env as Record< string, string >, + } ); + return ptyProc as unknown as ChildProcess; + } + + const proc = spawn( command, args, { + cwd: workingDirectory, + env, + stdio: [ 'pipe', 'pipe', 'pipe' ], + } ); + + return proc; + } + + /** + * Set up event handlers for a process. + */ + private setupProcessHandlers( sessionId: string, proc: ChildProcess ): void { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + return; + } + + // Handle stderr for PTY processes only (for debugging/errors) + // Note: For non-PTY processes, stderr is combined with stdout for JSON-RPC + // (some agents like codex-acp write JSON-RPC to stderr) + if ( this.isPtyProcess( proc ) ) { + proc.onData( ( data ) => { + console.warn( `ACP [${ sessionId }] data:`, data ); + this.emit( 'stderr', sessionId, data ); + } ); + } + + // Handle process exit + if ( this.isPtyProcess( proc ) ) { + proc.onExit( ( event ) => { + console.log( `ACP [${ sessionId }] exited with code ${ event.exitCode }, signal ${ event.signal }` ); + + runningProcess.session.state = 'closed'; + this.processes.delete( sessionId ); + + this.emit( 'session_closed', sessionId, event.exitCode, event.signal ); + } ); + } else { + proc.on( 'exit', ( code, signal ) => { + console.log( `ACP [${ sessionId }] exited with code ${ code }, signal ${ signal }` ); + + runningProcess.session.state = 'closed'; + this.processes.delete( sessionId ); + + this.emit( 'session_closed', sessionId, code, signal ); + } ); + } + + // Handle process errors + if ( ! this.isPtyProcess( proc ) ) { + proc.on( 'error', ( error ) => { + console.error( `ACP [${ sessionId }] error:`, error ); + + runningProcess.session.state = 'error'; + runningProcess.session.error = error.message; + + this.emit( 'session_error', sessionId, error ); + } ); + } + } + + /** + * Initialize the ACP connection and create a session. + */ + private async initializeSession( + sessionId: string, + workingDirectory: string + ): Promise< NewSessionResponse > { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + + const { connection } = runningProcess; + + // Step 1: Initialize the connection + console.log( `ACP [${ sessionId }] Calling initialize()...` ); + const initResult = await connection.initialize( { + protocolVersion: 1, + clientInfo: { + name: 'WordPress Studio', + version: '1.0.0', + }, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + } ); + console.log( `ACP [${ sessionId }] initialize() complete:`, initResult ); + + // Step 2: Create the session + console.log( `ACP [${ sessionId }] Calling newSession() with cwd: ${ workingDirectory }` ); + const result = await connection.newSession( { + cwd: workingDirectory, + mcpServers: [], + } ); + console.log( `ACP [${ sessionId }] newSession() complete:`, result ); + + return result; + } + + /** + * Send a prompt to an active session. + */ + async sendPrompt( sessionId: string, prompt: string ): Promise< PromptResponse > { + console.log( `ACP [${ sessionId }] sendPrompt called with: "${ prompt.slice( 0, 100 ) }..."` ); + + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + + if ( runningProcess.session.state !== 'ready' ) { + throw new Error( `Session not ready: ${ runningProcess.session.state }` ); + } + + const { connection, session } = runningProcess; + + console.log( `ACP [${ sessionId }] Calling connection.prompt() with acpSessionId: ${ session.acpSessionId }` ); + + // Send the prompt using the SDK + const result = await connection.prompt( { + sessionId: session.acpSessionId!, + prompt: [ + { + type: 'text', + text: prompt, + }, + ], + } ); + + console.log( `ACP [${ sessionId }] prompt() complete:`, result ); + + return result; + } + + /** + * Cancel an ongoing prompt. + */ + async cancelPrompt( sessionId: string ): Promise< void > { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + + const { connection, session } = runningProcess; + + await connection.cancel( { + sessionId: session.acpSessionId!, + } ); + } + + /** + * Close a session. + */ + async closeSession( sessionId: string ): Promise< void > { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + return; + } + + const { process: proc } = runningProcess; + + if ( this.isPtyProcess( proc ) ) { + proc.kill(); + } else { + // Kill the process + proc.kill( 'SIGTERM' ); + + // Give it a moment, then force kill if needed + setTimeout( () => { + if ( ! proc.killed ) { + proc.kill( 'SIGKILL' ); + } + }, 3000 ); + } + + runningProcess.session.state = 'closed'; + this.processes.delete( sessionId ); + } + + /** + * Close all sessions. + */ + async closeAllSessions(): Promise< void > { + const sessionIds = Array.from( this.processes.keys() ); + + await Promise.all( sessionIds.map( ( id ) => this.closeSession( id ) ) ); + } + + /** + * Get a session by ID. + */ + getSession( sessionId: string ): AcpSession | undefined { + return this.processes.get( sessionId )?.session; + } + + /** + * Get all active sessions. + */ + getAllSessions(): AcpSession[] { + return Array.from( this.processes.values() ).map( ( p ) => p.session ); + } + + /** + * Get sessions for a specific site. + */ + getSessionsForSite( siteId: string ): AcpSession[] { + return Array.from( this.processes.values() ) + .filter( ( p ) => p.siteId === siteId ) + .map( ( p ) => p.session ); + } + + /** + * Check if a session is active. + */ + isSessionActive( sessionId: string ): boolean { + const session = this.getSession( sessionId ); + return session?.state === 'ready'; + } +} + +// Singleton instance +let processManagerInstance: AcpProcessManager | null = null; + +/** + * Get the singleton ACP process manager instance. + */ +export function getAcpProcessManager(): AcpProcessManager { + if ( ! processManagerInstance ) { + processManagerInstance = new AcpProcessManager(); + } + return processManagerInstance; +} diff --git a/src/modules/acp/lib/acp-registry.ts b/src/modules/acp/lib/acp-registry.ts new file mode 100644 index 0000000000..861e9b1910 --- /dev/null +++ b/src/modules/acp/lib/acp-registry.ts @@ -0,0 +1,291 @@ +/** + * ACP Registry Client + * + * Fetches and caches the official ACP agent registry from: + * https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json + */ + +import { app } from 'electron'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Distribution types for ACP agents. + */ +export interface NpxDistribution { + package: string; + args?: string[]; + env?: Record< string, string >; +} + +export interface BinaryTarget { + archive: string; + cmd: string; + args?: string[]; + env?: Record< string, string >; +} + +export interface BinaryDistribution { + 'darwin-aarch64'?: BinaryTarget; + 'darwin-arm64'?: BinaryTarget; + 'darwin-x86_64'?: BinaryTarget; + 'darwin-x64'?: BinaryTarget; + 'linux-aarch64'?: BinaryTarget; + 'linux-arm64'?: BinaryTarget; + 'linux-x86_64'?: BinaryTarget; + 'linux-x64'?: BinaryTarget; + 'windows-aarch64'?: BinaryTarget; + 'windows-arm64'?: BinaryTarget; + 'windows-x86_64'?: BinaryTarget; + 'windows-x64'?: BinaryTarget; +} + +export interface AgentDistribution { + npx?: NpxDistribution; + binary?: BinaryDistribution; +} + +/** + * Agent entry from the ACP registry. + */ +export interface RegistryAgent { + id: string; + name: string; + version: string; + description: string; + repository?: string; + authors: string[]; + license: string; + icon?: string; + distribution: AgentDistribution; +} + +/** + * The full ACP registry structure. + */ +export interface AcpRegistry { + version: string; + agents: RegistryAgent[]; + extensions: unknown[]; +} + +/** + * Cached registry with timestamp. + */ +interface CachedRegistry { + fetchedAt: number; + registry: AcpRegistry; +} + +// In-memory cache +let registryCache: CachedRegistry | null = null; + +/** + * Get the cache file path. + */ +function getCacheFilePath(): string { + const userDataPath = app.getPath( 'userData' ); + return path.join( userDataPath, 'acp-registry-cache.json' ); +} + +/** + * Load registry from disk cache. + */ +async function loadFromDiskCache(): Promise< CachedRegistry | null > { + try { + const cacheFile = getCacheFilePath(); + const data = await fs.readFile( cacheFile, 'utf-8' ); + return JSON.parse( data ) as CachedRegistry; + } catch { + return null; + } +} + +/** + * Save registry to disk cache. + */ +async function saveToDiskCache( cached: CachedRegistry ): Promise< void > { + try { + const cacheFile = getCacheFilePath(); + await fs.writeFile( cacheFile, JSON.stringify( cached, null, 2 ) ); + } catch ( error ) { + console.warn( 'Failed to save ACP registry cache:', error ); + } +} + +/** + * Fetch the registry from the CDN. + */ +async function fetchRegistry(): Promise< AcpRegistry > { + const response = await fetch( REGISTRY_URL, { + headers: { + Accept: 'application/json', + 'User-Agent': 'WordPress-Studio/1.0', + }, + } ); + + if ( ! response.ok ) { + throw new Error( + `Failed to fetch ACP registry: ${ response.status } ${ response.statusText }` + ); + } + + return ( await response.json() ) as AcpRegistry; +} + +/** + * Get the ACP registry, using cache if available and fresh. + */ +export async function getAcpRegistry(): Promise< AcpRegistry > { + const now = Date.now(); + + // Check in-memory cache first + if ( registryCache && now - registryCache.fetchedAt < CACHE_TTL_MS ) { + return registryCache.registry; + } + + // Check disk cache + const diskCache = await loadFromDiskCache(); + if ( diskCache && now - diskCache.fetchedAt < CACHE_TTL_MS ) { + registryCache = diskCache; + return diskCache.registry; + } + + // Fetch fresh registry + try { + const registry = await fetchRegistry(); + const cached: CachedRegistry = { + fetchedAt: now, + registry, + }; + + registryCache = cached; + await saveToDiskCache( cached ); + + return registry; + } catch ( error ) { + // If fetch fails but we have stale cache, use it + if ( diskCache ) { + console.warn( 'Using stale ACP registry cache due to fetch error:', error ); + registryCache = diskCache; + return diskCache.registry; + } + + throw error; + } +} + +/** + * Get the current platform key for binary distributions. + */ +export function getPlatformKey(): keyof BinaryDistribution | null { + const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; + + switch ( process.platform ) { + case 'darwin': + return `darwin-${ arch }` as keyof BinaryDistribution; + case 'linux': + return `linux-${ arch }` as keyof BinaryDistribution; + case 'win32': + return `windows-${ arch }` as keyof BinaryDistribution; + default: + return null; + } +} + +function getPlatformKeys(): Array< keyof BinaryDistribution > { + const keys: Array< keyof BinaryDistribution > = []; + const isArm = process.arch === 'arm64'; + + if ( process.platform === 'darwin' ) { + keys.push( isArm ? 'darwin-aarch64' : 'darwin-x86_64' ); + keys.push( isArm ? 'darwin-arm64' : 'darwin-x64' ); + } + + if ( process.platform === 'linux' ) { + keys.push( isArm ? 'linux-aarch64' : 'linux-x86_64' ); + keys.push( isArm ? 'linux-arm64' : 'linux-x64' ); + } + + if ( process.platform === 'win32' ) { + keys.push( isArm ? 'windows-aarch64' : 'windows-x86_64' ); + keys.push( isArm ? 'windows-arm64' : 'windows-x64' ); + } + + return keys; +} + +/** + * Get the command and args to run an agent. + * + * Returns null if the agent can't be run on this platform. + */ +export function getAgentCommand( + agent: RegistryAgent +): { command: string; args: string[]; env?: Record< string, string > } | null { + const { distribution } = agent; + + // Prefer npx distribution (more portable) + if ( distribution.npx ) { + return { + command: 'npx', + // Use --yes to auto-accept package installation (avoids "Ok to proceed?" prompt) + // Use --quiet to suppress npx's own output (spinners, install messages) + args: [ '--yes', '--quiet', distribution.npx.package, ...( distribution.npx.args ?? [] ) ], + env: distribution.npx.env, + }; + } + + // Fall back to binary distribution + if ( distribution.binary ) { + const platformKeys = getPlatformKeys(); + if ( platformKeys.length === 0 ) { + return null; + } + + const target = platformKeys + .map( ( key ) => distribution.binary?.[ key ] ) + .find( Boolean ); + if ( ! target ) { + return null; + } + + // For now, assume the binary is installed and in PATH + // TODO: Implement binary download and management + const binaryName = path.basename( target.cmd ).replace( /^\.\//, '' ); + + return { + command: binaryName, + args: target.args ?? [], + env: target.env, + }; + } + + return null; +} + +/** + * Force refresh the registry cache. + */ +export async function refreshRegistry(): Promise< AcpRegistry > { + registryCache = null; + + try { + await fs.unlink( getCacheFilePath() ); + } catch { + // Ignore if file doesn't exist + } + + return getAcpRegistry(); +} + +/** + * Get a specific agent by ID from the registry. + */ +export async function getRegistryAgent( agentId: string ): Promise< RegistryAgent | null > { + const registry = await getAcpRegistry(); + return registry.agents.find( ( a ) => a.id === agentId ) ?? null; +} diff --git a/src/modules/acp/lib/agent-detection.ts b/src/modules/acp/lib/agent-detection.ts new file mode 100644 index 0000000000..10de7621f3 --- /dev/null +++ b/src/modules/acp/lib/agent-detection.ts @@ -0,0 +1,347 @@ +/** + * Agent Detection + * + * Detects available ACP agents using the official registry and checking system PATH. + */ + +import { exec } from 'child_process'; +import fs from 'fs'; +import nodePath from 'path'; +import { promisify } from 'util'; +import { BUILTIN_AGENTS } from '../config/agents'; +import { getAcpRegistry, getAgentCommand, type RegistryAgent } from './acp-registry'; +import type { AgentConfig, AgentStatus } from '../types'; + +const execAsync = promisify( exec ); + +function getNvmBinPaths( home: string ): string[] { + const paths: string[] = []; + const nvmDir = process.env.NVM_DIR ?? nodePath.join( home, '.nvm' ); + const versionsDir = nodePath.join( nvmDir, 'versions', 'node' ); + + if ( process.env.NVM_BIN ) { + paths.push( process.env.NVM_BIN ); + } + + try { + if ( fs.existsSync( versionsDir ) ) { + const entries = fs.readdirSync( versionsDir, { withFileTypes: true } ); + for ( const entry of entries ) { + if ( ! entry.isDirectory() ) { + continue; + } + paths.push( nodePath.join( versionsDir, entry.name, 'bin' ) ); + } + } + } catch { + // Ignore filesystem errors and fall back to default paths + } + + return paths; +} + +function getSupplementalPaths( home: string ): string[] { + const pnpmHome = process.env.PNPM_HOME ?? nodePath.join( home, '.local', 'share', 'pnpm' ); + return [ + nodePath.join( home, '.asdf', 'bin' ), + nodePath.join( home, '.asdf', 'shims' ), + pnpmHome, + nodePath.join( home, '.pnpm' ), + nodePath.join( home, '.bun', 'bin' ), + nodePath.join( home, '.deno', 'bin' ), + ]; +} + +/** + * Get the augmented PATH for agent detection. + * On macOS/Linux, GUI apps don't inherit shell PATH, so we add common paths. + */ +function getAugmentedPath(): string { + const basePath = process.env.PATH ?? ''; + const home = process.env.HOME ?? ''; + + if ( process.platform === 'darwin' ) { + const additionalPaths = [ + '/usr/local/bin', + '/opt/homebrew/bin', + '/opt/local/bin', + `${ home }/.local/bin`, + `${ home }/.npm-global/bin`, + `${ home }/.cargo/bin`, + `${ home }/go/bin`, + // Common Node.js version managers + `${ home }/.volta/bin`, + `${ home }/.fnm/current/bin`, + ...getNvmBinPaths( home ), + ...getSupplementalPaths( home ), + ]; + return `${ additionalPaths.join( ':' ) }:${ basePath }`; + } + + if ( process.platform === 'linux' ) { + const additionalPaths = [ + '/usr/local/bin', + `${ home }/.local/bin`, + `${ home }/.npm-global/bin`, + `${ home }/.cargo/bin`, + `${ home }/go/bin`, + // Common Node.js version managers + `${ home }/.volta/bin`, + `${ home }/.fnm/current/bin`, + ...getNvmBinPaths( home ), + ...getSupplementalPaths( home ), + ]; + return `${ additionalPaths.join( ':' ) }:${ basePath }`; + } + + return basePath; +} + +/** + * Check if a command exists in the system PATH. + */ +async function commandExists( command: string ): Promise< boolean > { + try { + const checkCommand = process.platform === 'win32' ? `where ${ command }` : `which ${ command }`; + + await execAsync( checkCommand, { + timeout: 5000, + env: { + ...process.env, + PATH: getAugmentedPath(), + }, + } ); + + return true; + } catch { + return false; + } +} + +// Cache npx availability check +let npxAvailable: boolean | null = null; + +/** + * Check if npx is available (for agents distributed via npm packages). + */ +async function isNpxAvailable(): Promise< boolean > { + if ( npxAvailable !== null ) { + return npxAvailable; + } + npxAvailable = await commandExists( 'npx' ); + return npxAvailable; +} + +/** + * Convert a registry agent to our AgentConfig format. + */ +function registryAgentToConfig( + agent: RegistryAgent, + options: { + isInstalled: boolean; + isAvailable: boolean; + commandInfoOverride?: { command: string; args: string[]; env?: Record< string, string > } | null; + } +): AgentConfig { + const commandInfo = options.commandInfoOverride ?? getAgentCommand( agent ); + + return { + id: agent.id, + name: agent.name, + description: agent.description, + provider: 'acp', + icon: agent.icon, + command: commandInfo?.command, + args: commandInfo?.args, + env: commandInfo?.env, + isInstalled: options.isInstalled, + status: options.isAvailable ? 'available' : 'unavailable', + // Note: codex-acp is the ACP wrapper and uses standard stdio, not TTY. + // Only the interactive codex CLI needs TTY, but we don't use that directly. + requiresTty: false, + }; +} + +/** + * Alternative binary commands for agents (by agent ID). + * These are checked first before falling back to npx. + * + * Note: Do NOT include standard CLI tools as alternatives for ACP adapters. + * For example, `codex` (OpenAI CLI) does NOT speak ACP - you need `codex-acp` + * or `npx @zed-industries/codex-acp`. Same for `claude` vs `claude-code-acp`. + */ +const ALTERNATIVE_BINARIES: Record< string, string[] > = { + 'factory-droid': [ 'factory-droid', 'droid' ], + 'codex-acp': [ 'codex-acp' ], + 'claude-code-acp': [ 'claude-code-acp' ], + opencode: [ 'opencode' ], +}; + +/** + * NPX package fallbacks for agents where the registry only lists binary distribution + * but an npm package also exists. + */ +const NPX_PACKAGE_FALLBACKS: Record< string, string > = { + 'codex-acp': '@zed-industries/codex-acp', +}; + +/** + * Detect if a registry agent is available on this system. + */ +async function detectRegistryAgent( agent: RegistryAgent ): Promise< AgentConfig > { + const commandInfo = getAgentCommand( agent ); + const npxFallback = NPX_PACKAGE_FALLBACKS[ agent.id ]; + + // If no distribution and no npx fallback, agent is unavailable + if ( ! commandInfo && ! npxFallback ) { + return registryAgentToConfig( agent, { isInstalled: false, isAvailable: false } ); + } + + let isInstalled = false; + let isAvailable = false; + let resolvedCommandInfo = commandInfo ?? { command: 'npx', args: [ '--yes', '--quiet', npxFallback! ], env: {} }; + + // First, check if there's a locally installed binary (preferred over npx) + const alternativeBinaries = ALTERNATIVE_BINARIES[ agent.id ] ?? []; + for ( const cmd of alternativeBinaries ) { + if ( await commandExists( cmd ) ) { + isInstalled = true; + isAvailable = true; + // Use the local binary instead of npx + resolvedCommandInfo = { + command: cmd, + args: [], + env: commandInfo?.env, + }; + break; + } + } + + // If no local binary found, check npx fallback + if ( ! isInstalled ) { + const isNpxBased = resolvedCommandInfo.command === 'npx'; + if ( isNpxBased ) { + isAvailable = await isNpxAvailable(); + } else { + // Binary-based agent - check if binary exists + if ( await commandExists( resolvedCommandInfo.command ) ) { + isInstalled = true; + isAvailable = true; + } else if ( npxFallback ) { + // Binary not found, try npx fallback + if ( await isNpxAvailable() ) { + isAvailable = true; + resolvedCommandInfo = { + command: 'npx', + args: [ '--yes', '--quiet', npxFallback ], + env: commandInfo?.env, + }; + } + } + } + } + + return registryAgentToConfig( agent, { + isInstalled, + isAvailable, + commandInfoOverride: resolvedCommandInfo, + } ); +} + +// Temporary filter: only show these agents (comment out others for now) +const ENABLED_AGENT_IDS = [ + 'wpcom', // WordPress AI + 'anthropic-builtin', // Built-in Assistant + 'claude-code-acp', // Claude Code + 'opencode', // OpenCode + 'codex-acp', // Codex +]; + +/** + * Detect all available ACP agents. + * + * Combines built-in agents with agents from the ACP registry. + */ +export async function detectInstalledAgents(): Promise< AgentConfig[] > { + // Start with built-in agents (always available) + // Filter to only enabled agents + const agents: AgentConfig[] = BUILTIN_AGENTS.filter( ( agent ) => + ENABLED_AGENT_IDS.includes( agent.id ) + ); + + try { + // Fetch registry and detect each agent + const registry = await getAcpRegistry(); + // Filter to only enabled ACP agents + const enabledRegistryAgents = registry.agents.filter( ( agent ) => + ENABLED_AGENT_IDS.includes( agent.id ) + ); + const detectionPromises = enabledRegistryAgents.map( detectRegistryAgent ); + const detectedAgents = await Promise.all( detectionPromises ); + + agents.push( ...detectedAgents ); + } catch ( error ) { + console.error( 'Failed to fetch ACP registry:', error ); + // Continue with just built-in agents if registry fetch fails + } + + return agents; +} + +/** + * Detect a specific agent by ID. + */ +export async function detectAgentById( agentId: string ): Promise< AgentConfig | null > { + // Check built-in agents first + const builtinAgent = BUILTIN_AGENTS.find( ( a ) => a.id === agentId ); + if ( builtinAgent ) { + return { + ...builtinAgent, + isInstalled: true, + status: 'available', + }; + } + + // Check registry + try { + const registry = await getAcpRegistry(); + const registryAgent = registry.agents.find( ( a ) => a.id === agentId ); + + if ( registryAgent ) { + return detectRegistryAgent( registryAgent ); + } + } catch ( error ) { + console.error( 'Failed to detect agent from registry:', error ); + } + + return null; +} + +/** + * Get the status of all agents. + */ +export async function getAgentStatusDict(): Promise< Record< string, AgentStatus > > { + const agents = await detectInstalledAgents(); + + const statusDict: Record< string, AgentStatus > = {}; + + for ( const agent of agents ) { + statusDict[ agent.id ] = agent.status ?? 'unavailable'; + } + + return statusDict; +} + +/** + * Refresh agent detection. + */ +export async function refreshAgentDetection(): Promise< AgentConfig[] > { + // Reset npx cache + npxAvailable = null; + return detectInstalledAgents(); +} + +/** + * Export the augmented PATH for use by the process manager. + */ +export { getAugmentedPath }; diff --git a/src/modules/acp/lib/ipc-handlers.ts b/src/modules/acp/lib/ipc-handlers.ts new file mode 100644 index 0000000000..13805dbba9 --- /dev/null +++ b/src/modules/acp/lib/ipc-handlers.ts @@ -0,0 +1,374 @@ +/** + * ACP IPC Handlers + * + * IPC handlers for ACP operations in the main process. + */ + +import { BrowserWindow, type IpcMainInvokeEvent } from 'electron'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; +import { SiteServer } from 'src/site-server'; +import { loadUserData, updateAppdata } from 'src/storage/user-data'; +import { DEFAULT_AGENT_ID } from '../config/agents'; +import { createFullAccessCallbacksHandler } from './acp-callbacks'; +import { getAcpProcessManager } from './acp-process-manager'; +import { detectInstalledAgents, detectAgentById } from './agent-detection'; +import type { AgentConfig, AcpSession } from '../types'; + +/** + * Store for session-specific event handlers so they can be cleaned up. + */ +interface SessionHandlers { + updateHandler: ( sessionId: string, notification: SessionNotification ) => void; + errorHandler: ( sessionId: string, error: Error ) => void; + closedHandler: ( sessionId: string, code: number | null, signal: string | null ) => void; +} +const sessionHandlers = new Map< string, SessionHandlers >(); + +/** + * Transform SDK SessionNotification to our expected format for the renderer. + * + * SDK uses flat structures: + * - agent_message_chunk: { content: ContentBlock, sessionUpdate: "agent_message_chunk" } + * - agent_thought_chunk: { content: ContentBlock, sessionUpdate: "agent_thought_chunk" } + * - tool_call: { toolCallId, title, rawInput, ..., sessionUpdate: "tool_call" } + * - tool_call_update: { toolCallId, rawOutput, content, status, ..., sessionUpdate: "tool_call_update" } + */ +function transformSessionUpdate( sessionId: string, notification: SessionNotification ): { + sessionId: string; + type: string; + text?: string; + tool_use?: { id: string; name: string; input: Record< string, unknown > }; + tool_result?: { tool_use_id: string; output?: string; error?: string }; + thinking?: string; + done?: boolean; +} | null { + const update = notification.update; + + // Handle different update types from the SDK + switch ( update.sessionUpdate ) { + case 'agent_message_chunk': { + // Text content from agent - SDK has { content: ContentBlock } + const content = update.content; + if ( content?.type === 'text' ) { + const textContent = content as { type: 'text'; text: string }; + return { + sessionId, + type: 'text', + text: textContent.text, + }; + } + return null; + } + + case 'agent_thought_chunk': { + // Thinking/reasoning content - same structure as agent_message_chunk + const content = update.content; + if ( content?.type === 'text' ) { + const textContent = content as { type: 'text'; text: string }; + return { + sessionId, + type: 'thinking', + thinking: textContent.text, + }; + } + return null; + } + + case 'tool_call': { + // Tool call from agent - SDK has flat structure + // update IS the ToolCall (with toolCallId, title, rawInput, etc.) + return { + sessionId, + type: 'tool_use', + tool_use: { + id: update.toolCallId, + name: update.title, + input: ( update.rawInput ?? {} ) as Record< string, unknown >, + }, + }; + } + + case 'tool_call_update': { + // Tool result - SDK has flat structure + // update IS the ToolCallUpdate (with toolCallId, rawOutput, content, status, etc.) + // Extract text output from rawOutput or content + let output = ''; + + if ( update.rawOutput !== undefined ) { + output = typeof update.rawOutput === 'string' + ? update.rawOutput + : JSON.stringify( update.rawOutput ); + } else if ( Array.isArray( update.content ) ) { + // Extract text from content array + output = update.content + .map( ( c ) => { + if ( c.type === 'content' && c.content ) { + const contentBlocks = Array.isArray( c.content ) ? c.content : [ c.content ]; + return contentBlocks + .filter( ( block ): block is { type: 'text'; text: string } => + typeof block === 'object' && block !== null && 'type' in block && block.type === 'text' + ) + .map( ( block ) => block.text ) + .join( '\n' ); + } + return ''; + } ) + .filter( Boolean ) + .join( '\n' ); + } + + // Only emit tool_result if we have content or a completed/failed status + if ( output || update.status === 'completed' || update.status === 'failed' ) { + return { + sessionId, + type: 'tool_result', + tool_result: { + tool_use_id: update.toolCallId, + output: output || '(completed)', + error: update.status === 'failed' ? output : undefined, + }, + }; + } + return null; + } + + case 'plan': + case 'available_commands_update': + case 'current_mode_update': + case 'config_option_update': + case 'session_info_update': + // These don't need to be forwarded to the UI for now + return null; + + default: + console.log( `ACP: Unhandled session update type: ${ ( update as { sessionUpdate: string } ).sessionUpdate }` ); + return null; + } +} + +/** + * Get the list of available agents (built-in + detected ACP agents). + */ +export async function getAvailableAgents( _event: IpcMainInvokeEvent ): Promise< AgentConfig[] > { + return detectInstalledAgents(); +} + +/** + * Detect installed agents and return the list. + */ +export async function detectAgents( _event: IpcMainInvokeEvent ): Promise< AgentConfig[] > { + return detectInstalledAgents(); +} + +/** + * Get the currently selected agent ID. + */ +export async function getSelectedAgentId( _event: IpcMainInvokeEvent ): Promise< string > { + const userData = await loadUserData(); + return userData.selectedAgentId ?? DEFAULT_AGENT_ID; +} + +/** + * Set the selected agent ID. + */ +export async function setSelectedAgentId( + _event: IpcMainInvokeEvent, + agentId: string +): Promise< void > { + // Verify the agent exists + const agent = await detectAgentById( agentId ); + if ( ! agent ) { + throw new Error( `Unknown agent: ${ agentId }` ); + } + + await updateAppdata( { selectedAgentId: agentId } ); +} + +/** + * Clean up event handlers for a session. + */ +function cleanupSessionHandlers( sessionId: string ): void { + const handlers = sessionHandlers.get( sessionId ); + if ( handlers ) { + const processManager = getAcpProcessManager(); + processManager.off( 'session_update', handlers.updateHandler ); + processManager.off( 'session_error', handlers.errorHandler ); + processManager.off( 'session_closed', handlers.closedHandler ); + sessionHandlers.delete( sessionId ); + } +} + +/** + * Create a new ACP session for a site. + */ +export async function createAcpSession( + event: IpcMainInvokeEvent, + agentId: string, + siteId: string +): Promise< AcpSession > { + // Get the site's working directory + const site = SiteServer.get( siteId ); + if ( ! site ) { + throw new Error( `Site not found: ${ siteId }` ); + } + + const workingDirectory = site.details.path; + const processManager = getAcpProcessManager(); + + // Create callback handler for this site + const callbackHandler = createFullAccessCallbacksHandler( workingDirectory ); + + // Set up event forwarding to renderer + const parentWindow = BrowserWindow.fromWebContents( event.sender ); + + // Create the session first to get the session ID + const session = await processManager.createSession( + agentId, + siteId, + workingDirectory, + callbackHandler + ); + + const createdSessionId = session.id; + + // Create session-specific handlers that only process events for this session + const sessionUpdateHandler = ( eventSessionId: string, notification: SessionNotification ) => { + // Only handle events for this specific session + if ( eventSessionId !== createdSessionId ) { + return; + } + const transformed = transformSessionUpdate( eventSessionId, notification ); + if ( transformed ) { + sendIpcEventToRendererWithWindow( parentWindow, 'acp-session-update', transformed ); + } + }; + + const sessionErrorHandler = ( eventSessionId: string, error: Error ) => { + // Only handle events for this specific session + if ( eventSessionId !== createdSessionId ) { + return; + } + sendIpcEventToRendererWithWindow( parentWindow, 'acp-session-error', { + sessionId: eventSessionId, + error: error.message, + } ); + }; + + const sessionClosedHandler = ( + eventSessionId: string, + code: number | null, + signal: string | null + ) => { + // Only handle events for this specific session + if ( eventSessionId !== createdSessionId ) { + return; + } + sendIpcEventToRendererWithWindow( parentWindow, 'acp-session-closed', { + sessionId: eventSessionId, + code, + signal, + } ); + // Clean up handlers when session closes + cleanupSessionHandlers( createdSessionId ); + }; + + // Store handlers for cleanup + sessionHandlers.set( createdSessionId, { + updateHandler: sessionUpdateHandler, + errorHandler: sessionErrorHandler, + closedHandler: sessionClosedHandler, + } ); + + // Add event listeners + processManager.on( 'session_update', sessionUpdateHandler ); + processManager.on( 'session_error', sessionErrorHandler ); + processManager.on( 'session_closed', sessionClosedHandler ); + + return session; +} + +/** + * Send a prompt to an ACP session. + */ +export async function sendAcpPrompt( + _event: IpcMainInvokeEvent, + sessionId: string, + prompt: string +): Promise< { stopReason: string } > { + const processManager = getAcpProcessManager(); + + const result = await processManager.sendPrompt( sessionId, prompt ); + + return { stopReason: result.stopReason }; +} + +/** + * Send an approval response to an ACP session. + * Note: With the official SDK, approvals are handled via the Client.requestPermission callback. + * This handler is kept for API compatibility but may not be needed. + */ +export async function sendAcpApproval( + _event: IpcMainInvokeEvent, + _sessionId: string, + _approvalId: string, + _approved: boolean, + _selectedOption?: string +): Promise< void > { + // Approvals are now handled through the SDK's requestPermission callback + // in the Client interface, not as a separate IPC call. + console.warn( 'sendAcpApproval called but approvals are handled via SDK callbacks' ); +} + +/** + * Close an ACP session. + */ +export async function closeAcpSession( + _event: IpcMainInvokeEvent, + sessionId: string +): Promise< void > { + const processManager = getAcpProcessManager(); + + // Clean up event handlers first + cleanupSessionHandlers( sessionId ); + + await processManager.closeSession( sessionId ); +} + +/** + * Get an ACP session by ID. + */ +export async function getAcpSession( + _event: IpcMainInvokeEvent, + sessionId: string +): Promise< AcpSession | null > { + const processManager = getAcpProcessManager(); + + return processManager.getSession( sessionId ) ?? null; +} + +/** + * Get all active ACP sessions for a site. + */ +export async function getAcpSessionsForSite( + _event: IpcMainInvokeEvent, + siteId: string +): Promise< AcpSession[] > { + const processManager = getAcpProcessManager(); + + return processManager.getSessionsForSite( siteId ); +} + +/** + * Close all ACP sessions. + */ +export async function closeAllAcpSessions( _event: IpcMainInvokeEvent ): Promise< void > { + const processManager = getAcpProcessManager(); + + // Clean up all event handlers + for ( const sessionId of sessionHandlers.keys() ) { + cleanupSessionHandlers( sessionId ); + } + + await processManager.closeAllSessions(); +} From 2f61e39cbed702ead08814bd1de2366f8e65395b Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:45:02 -0800 Subject: [PATCH 02/20] Update Opencode agent icon with actual logo design --- src/modules/acp/config/agents.ts | 159 +++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/modules/acp/config/agents.ts diff --git a/src/modules/acp/config/agents.ts b/src/modules/acp/config/agents.ts new file mode 100644 index 0000000000..90be2afa9c --- /dev/null +++ b/src/modules/acp/config/agents.ts @@ -0,0 +1,159 @@ +/** + * Agent Configuration + * + * Defines all available agents including built-in and ACP-based agents. + */ + +import type { AgentConfig } from '../types'; + +// ============================================================================ +// Agent Icons (SVG strings) +// ============================================================================ + +export const AGENT_ICONS = { + // WordPress - Simple Icons + wpcom: ` + + + + + `, + + // Anthropic - Simple Icons + anthropic: ` + + + + + `, + + // Claude Code - Same as Anthropic with terminal accent + claudeCode: ` + + + + + `, + + // OpenAI/Codex - Simple Icons + codex: ` + + + + + `, + + // Google Gemini - Simple Icons + gemini: ` + + + + + `, + + // GitHub Copilot - Simple Icons (GitHub logo) + copilot: ` + + + + + `, + + // OpenCode - Custom logo + opencode: ` + + + + + + `, + + // Goose - Custom (Block's goose agent) + goose: ` + + + + + + `, + + // Generic fallback + generic: ` + + + `, +}; + +// ============================================================================ +// Built-in Agents +// ============================================================================ + +export const BUILTIN_AGENTS: AgentConfig[] = [ + { + id: 'wpcom', + name: 'WordPress AI', + description: 'AI-powered WordPress assistant with WordPress.com integration', + provider: 'wpcom', + icon: AGENT_ICONS.wpcom, + isInstalled: true, + status: 'available', + }, + { + id: 'anthropic-builtin', + name: 'Built-in Assistant (Anthropic)', + description: 'Local AI assistant powered by Claude with WP-CLI access', + provider: 'anthropic-builtin', + icon: AGENT_ICONS.anthropic, + apiKeyEnvVar: 'ANTHROPIC_API_KEY', + isInstalled: true, + status: 'available', + }, +]; + +// ============================================================================ +// ACP Agent Definitions +// ============================================================================ + +// ACP agents are now loaded dynamically from the official registry: +// https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json +// See: src/modules/acp/lib/acp-registry.ts + +// ============================================================================ +// Default Agent +// ============================================================================ + +export const DEFAULT_AGENT_ID = 'wpcom'; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get all built-in agents (always available). + */ +export function getBuiltinAgents(): AgentConfig[] { + return BUILTIN_AGENTS; +} + +/** + * Get a built-in agent config by ID. + * For ACP agents, use detectAgentById from agent-detection.ts + */ +export function getBuiltinAgentById( id: string ): AgentConfig | undefined { + return BUILTIN_AGENTS.find( ( agent ) => agent.id === id ); +} + +/** + * Get the icon for an agent (with fallback to generic icon). + * Prefers icons from the registry if available, falls back to local icons. + */ +export function getAgentIcon( agentId: string ): string { + // Check built-in agents first + const builtinAgent = getBuiltinAgentById( agentId ); + if ( builtinAgent?.icon ) { + return builtinAgent.icon; + } + + // Return generic fallback + return AGENT_ICONS.generic; +} From 5d6bb25e812a037d20fbcafd772e32139e2cf2fb Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:45:04 -0800 Subject: [PATCH 03/20] Update AI Settings Instructions UI - Change Install All button from primary to link variant - Simplify description text to 'Install instructions so agents know how to use Studio' --- src/components/ai-settings-modal.tsx | 410 +++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 src/components/ai-settings-modal.tsx diff --git a/src/components/ai-settings-modal.tsx b/src/components/ai-settings-modal.tsx new file mode 100644 index 0000000000..2572cfe327 --- /dev/null +++ b/src/components/ai-settings-modal.tsx @@ -0,0 +1,410 @@ +import { Spinner, TabPanel } from '@wordpress/components'; +import { Icon, check } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Button from 'src/components/button'; +import Modal from 'src/components/modal'; +import { AgentIcon } from 'src/components/agent-selector/agent-icon'; +import { ModelSelector } from 'src/components/model-selector'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { SkillsPanel } from 'src/modules/agent-skills'; +import { + DEFAULT_AGENT_INSTRUCTIONS, + INSTRUCTION_FILE_TYPES, + INSTRUCTION_FILES, + type InstructionFileType, +} from 'src/modules/agent-instructions/constants'; +import { agentChatSelectors, agentChatThunks } from 'src/stores/agent-chat-slice'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import type { AgentConfig } from 'src/modules/acp/types'; +import { useAgentChat } from 'src/hooks/use-agent-chat'; + +interface AiSettingsModalProps { + isOpen: boolean; + onClose: () => void; + siteId: string; +} + +function getAgentStatusLabel( agent: AgentConfig, __: ( text: string ) => string ): string | null { + if ( agent.provider === 'wpcom' ) { + return __( 'Cloud' ); + } + + if ( agent.provider === 'anthropic-builtin' ) { + return __( 'Built-in' ); + } + + if ( agent.status === 'unavailable' || agent.status === 'error' ) { + return __( 'Not available' ); + } + + if ( agent.isInstalled === false ) { + return agent.command === 'npx' ? __( 'Runs via npx' ) : __( 'Not installed' ); + } + + return __( 'Installed' ); +} + +function AgentSettingsPanel( { siteId }: { siteId: string } ) { + const { __ } = useI18n(); + const dispatch = useAppDispatch(); + const availableAgents = useRootSelector( agentChatSelectors.selectAvailableAgents ); + const selectedAgentId = useRootSelector( agentChatSelectors.selectSelectedAgentId ); + const selectedAgent = useRootSelector( agentChatSelectors.selectSelectedAgent ); + const isLoadingAgents = useRootSelector( agentChatSelectors.selectIsLoadingAgents ); + + // Get model info from the agent chat hook + const { availableModels, currentModelId, setModel } = useAgentChat( { siteId } ); + + useEffect( () => { + if ( availableAgents.length === 0 ) { + void dispatch( agentChatThunks.loadAvailableAgents() ); + } + }, [ dispatch, availableAgents.length ] ); + + const handleRefresh = useCallback( () => { + void dispatch( agentChatThunks.loadAvailableAgents() ); + }, [ dispatch ] ); + + const handleSelectAgent = useCallback( + ( agentId: string ) => { + void dispatch( agentChatThunks.selectAgent( agentId ) ); + }, + [ dispatch ] + ); + + const groupedAgents = useMemo( + () => [ + { title: __( 'Chat' ), agents: availableAgents.filter( ( a ) => a.provider === 'wpcom' ) }, + { + title: __( 'Built-in' ), + agents: availableAgents.filter( ( a ) => a.provider === 'anthropic-builtin' ), + }, + { title: __( 'Agents' ), agents: availableAgents.filter( ( a ) => a.provider === 'acp' ) }, + ], + [ availableAgents, __ ] + ); + + const isAgentAvailable = ( agent: AgentConfig ) => + agent.status !== 'unavailable' && agent.status !== 'error'; + + return ( +
+
+
+

{ __( 'Active agent' ) }

+

+ { __( 'Pick which agent handles AI conversations.' ) } +

+
+ +
+ + { /* Model selector - only show when selected agent supports models */ } + { availableModels && availableModels.length > 1 && currentModelId && ( +
+
+
{ __( 'Model' ) }
+
+ { selectedAgent?.name ?? __( 'Current agent' ) } +
+
+ +
+ ) } + +
+ { groupedAgents.map( ( group ) => ( +
+ { group.agents.length > 0 && ( + <> +
+ { group.title } +
+ { group.agents.map( ( agent ) => { + const statusLabel = getAgentStatusLabel( agent, __ ); + const isSelected = agent.id === selectedAgentId; + const isAvailable = isAgentAvailable( agent ); + return ( + + ); + } ) } + + ) } +
+ ) ) } + + { availableAgents.length === 0 && ! isLoadingAgents && ( +
+ { __( 'No agents detected.' ) } +
+ ) } + + { isLoadingAgents && ( +
+ + { __( 'Scanning for agents...' ) } +
+ ) } +
+
+ ); +} + +interface InstructionFileStatus { + id: InstructionFileType; + fileName: string; + displayName: string; + description: string; + exists: boolean; + path: string; +} + +function AgentInstructionsPanel( { siteId }: { siteId: string } ) { + const { __ } = useI18n(); + const [ statuses, setStatuses ] = useState< InstructionFileStatus[] >( [] ); + const [ error, setError ] = useState< string | null >( null ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ installingFile, setInstallingFile ] = useState< InstructionFileType | 'all' | null >( + null + ); + + const refreshStatus = useCallback( async () => { + setIsLoading( true ); + try { + const result = await getIpcApi().getAgentInstructionsStatus( siteId ); + setStatuses( result as InstructionFileStatus[] ); + setError( null ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setIsLoading( false ); + } + }, [ siteId ] ); + + useEffect( () => { + void refreshStatus(); + }, [ refreshStatus ] ); + + const handleInstallFile = useCallback( + async ( fileType: InstructionFileType, overwrite: boolean ) => { + setInstallingFile( fileType ); + setError( null ); + try { + await getIpcApi().installAgentInstructions( siteId, { overwrite, fileType } ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingFile( null ); + } + }, + [ siteId, refreshStatus ] + ); + + const handleInstallAll = useCallback( + async ( overwrite: boolean ) => { + setInstallingFile( 'all' ); + setError( null ); + try { + await getIpcApi().installAllAgentInstructions( siteId, { overwrite } ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingFile( null ); + } + }, + [ siteId, refreshStatus ] + ); + + const installedCount = statuses.filter( ( s ) => s.exists ).length; + const allInstalled = installedCount === INSTRUCTION_FILE_TYPES.length; + + return ( +
+
+
+

{ __( 'Agent instructions' ) }

+

+ { __( 'Install instructions so agents know how to use Studio' ) } +

+
+ +
+ + { error && ( +
+ { error } +
+ ) } + + { /* File list */ } +
+ { isLoading ? ( +
+ + { __( 'Loading...' ) } +
+ ) : ( + statuses.map( ( status ) => { + const config = INSTRUCTION_FILES[ status.id ]; + const isInstalling = installingFile === status.id || installingFile === 'all'; + return ( +
+
+
+ + { config.displayName } + + { status.exists && ( + + + { __( 'Installed' ) } + + ) } +
+
{ config.description }
+
+
+ { status.exists && ( + + ) } + +
+
+ ); + } ) + ) } +
+ + { /* Template preview */ } +
+ + { __( 'View template content' ) } + +
+
+						{ DEFAULT_AGENT_INSTRUCTIONS }
+					
+
+
+
+ ); +} + +export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalProps ) { + const { __ } = useI18n(); + + if ( ! isOpen ) { + return null; + } + + const tabs = [ + { name: 'agent', title: __( 'Agent' ) }, + { name: 'skills', title: __( 'Skills' ) }, + { name: 'instructions', title: __( 'Instructions' ) }, + ]; + + return ( + + + { ( { name } ) => ( +
+ { name === 'agent' && } + { name === 'skills' && } + { name === 'instructions' && } +
+ ) } +
+
+ ); +} From 7d90aebc7320aa9890261606dc9d4db300af6ac9 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:46:34 -0800 Subject: [PATCH 04/20] Add studio chat command for WordPress AI assistant - Interactive mode: studio chat - Single-shot mode: studio chat "message" - Requires WordPress.com authentication --- cli/commands/chat.ts | 267 +++++++++++++++++++++++++++++++++++++++++++ cli/index.ts | 7 +- 2 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 cli/commands/chat.ts diff --git a/cli/commands/chat.ts b/cli/commands/chat.ts new file mode 100644 index 0000000000..e02553c61c --- /dev/null +++ b/cli/commands/chat.ts @@ -0,0 +1,267 @@ +import { input } from '@inquirer/prompts'; +import { __, sprintf } from '@wordpress/i18n'; +import wpcomFactory from 'src/lib/wpcom-factory'; +import wpcomXhrRequest from 'src/lib/wpcom-xhr-request-factory'; +import { z } from 'zod'; +import { getAuthToken } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +// Response schemas matching the desktop app implementation +const assistantResponseSchema = z.object( { + choices: z.array( + z.object( { + index: z.number(), + message: z.object( { + content: z.string(), + id: z.number(), + role: z.string(), + } ), + } ) + ), + created_at: z.string(), + id: z.number(), +} ); + +const assistantHeadersSchema = z.object( { + 'x-quota-max': z.coerce.number(), + 'x-quota-remaining': z.coerce.number(), + 'x-quota-reset': z.string(), +} ); + +type AssistantResponse = z.infer< typeof assistantResponseSchema >; +type AssistantHeaders = z.infer< typeof assistantHeadersSchema >; + +type ChatMessage = { + role: 'user' | 'assistant'; + content: string; +}; + +async function sendChatMessage( + token: string, + messages: ChatMessage[], + chatId?: number +): Promise< { response: AssistantResponse; headers: AssistantHeaders } > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + + return new Promise( ( resolve, reject ) => { + wpcom.req.post( + { + path: '/studio-app/ai-assistant/chat', + apiNamespace: 'wpcom/v2', + body: { + messages, + chat_id: chatId, + context: { + current_url: '', + number_of_sites: 0, + wp_version: '', + php_version: '', + plugins: [], + themes: [], + current_theme: '', + is_block_theme: false, + ide: [], + site_name: '', + os: process.platform, + }, + }, + }, + ( error: Error | null, data: unknown, headers: unknown ) => { + if ( error ) { + return reject( error ); + } + + try { + const validatedData = assistantResponseSchema.parse( data ); + const validatedHeaders = assistantHeadersSchema.parse( headers ); + resolve( { response: validatedData, headers: validatedHeaders } ); + } catch ( validationError ) { + reject( validationError ); + } + } + ); + } ); +} + +function displayQuotaInfo( headers: AssistantHeaders ) { + const remaining = headers[ 'x-quota-remaining' ]; + const max = headers[ 'x-quota-max' ]; + const used = max - remaining; + + console.log( + sprintf( + __( 'Usage: %d/%d prompts used. Resets on %s' ), + used, + max, + new Date( headers[ 'x-quota-reset' ] ).toLocaleDateString() + ) + ); +} + +async function runInteractiveMode( token: string ): Promise< void > { + const messages: ChatMessage[] = []; + let chatId: number | undefined; + + console.log( __( 'Interactive chat mode. Type "exit" or "quit" to end the session.\n' ) ); + + while ( true ) { + let userMessage: string; + + try { + userMessage = await input( { + message: __( 'You:' ), + } ); + } catch ( error ) { + // Handle Ctrl+C + console.log( __( '\nExiting chat…' ) ); + break; + } + + // Check for exit commands + if ( ! userMessage || userMessage.toLowerCase() === 'exit' || userMessage.toLowerCase() === 'quit' ) { + console.log( __( 'Exiting chat…' ) ); + break; + } + + // Add user message to history + messages.push( { + role: 'user', + content: userMessage, + } ); + + const logger = new Logger(); + logger.reportStart( undefined, __( 'Thinking…' ) ); + + try { + const { response, headers } = await sendChatMessage( token, messages, chatId ); + + // Update chatId from first response + if ( ! chatId ) { + chatId = response.id; + } + + // Stop the spinner + logger.spinner.stop(); + + // Display the assistant's response + const assistantMessage = response.choices[ 0 ].message.content; + console.log( __( 'Assistant:' ), assistantMessage + '\n' ); + + // Add assistant message to history + messages.push( { + role: 'assistant', + content: assistantMessage, + } ); + + // Display quota (less prominently in interactive mode) + displayQuotaInfo( headers ); + console.log( '' ); // Empty line for spacing + } catch ( error ) { + logger.spinner.stop(); + if ( error instanceof LoggerError ) { + logger.reportError( error, false ); + } else if ( error instanceof z.ZodError ) { + logger.reportError( new LoggerError( __( 'Invalid response from server' ), error ), false ); + } else { + logger.reportError( new LoggerError( __( 'Failed to send message' ), error ), false ); + } + console.log( '' ); // Empty line for spacing + } + } +} + +async function runSingleShotMode( token: string, message: string ): Promise< void > { + const logger = new Logger(); + + logger.reportStart( undefined, __( 'Sending message to WordPress AI…' ) ); + + try { + const messages: ChatMessage[] = [ + { + role: 'user', + content: message, + }, + ]; + + const { response, headers } = await sendChatMessage( token, messages ); + + // Stop the spinner before displaying the response + logger.spinner.stop(); + + // Display the assistant's response + const assistantMessage = response.choices[ 0 ].message.content; + console.log( '\n' + assistantMessage + '\n' ); + + // Display quota information + const remaining = headers[ 'x-quota-remaining' ]; + const max = headers[ 'x-quota-max' ]; + const used = max - remaining; + + logger.reportSuccess( + sprintf( + __( 'Usage: %d/%d prompts used. Resets on %s' ), + used, + max, + new Date( headers[ 'x-quota-reset' ] ).toLocaleDateString() + ) + ); + } catch ( error ) { + logger.spinner.stop(); + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else if ( error instanceof z.ZodError ) { + logger.reportError( new LoggerError( __( 'Invalid response from server' ), error ) ); + } else { + logger.reportError( new LoggerError( __( 'Failed to send message' ), error ) ); + } + } +} + +export async function runCommand( message?: string ): Promise< void > { + const logger = new Logger(); + + // Check authentication + let token: Awaited< ReturnType< typeof getAuthToken > >; + try { + token = await getAuthToken(); + } catch ( error ) { + logger.reportError( + new LoggerError( + __( + 'Authentication required. Please log in to WordPress.com first:\n\n studio auth login' + ) + ) + ); + return; + } + + // Run appropriate mode + if ( message ) { + await runSingleShotMode( token.accessToken, message ); + } else { + await runInteractiveMode( token.accessToken ); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'chat [message]', + describe: __( 'Chat with WordPress AI assistant' ), + builder: ( yargs ) => { + return yargs + .positional( 'message', { + describe: __( + 'Message to send to the AI assistant. If not provided, enters interactive mode.' + ), + type: 'string', + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.message as string | undefined ); + }, + } ); +}; diff --git a/cli/index.ts b/cli/index.ts index 97b7ea4a71..8f9561fe2d 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -20,6 +20,7 @@ import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/s import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status'; import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop'; import { commandHandler as wpCliCommandHandler } from 'cli/commands/wp'; +import { registerCommand as registerChatCommand } from 'cli/commands/chat'; import { readAppdata, lockAppdata, unlockAppdata, saveAppdata } from 'cli/lib/appdata'; import { loadTranslations } from 'cli/lib/i18n'; import { untildify } from 'cli/lib/utils'; @@ -119,7 +120,11 @@ async function main() { command: '_events', describe: false, // Hidden command handler: eventsCommandHandler, - } ) + } ); + + registerChatCommand( studioArgv ); + + studioArgv .demandCommand( 1, __( 'You must provide a valid command' ) ) .strict(); From 204f898cacfc8513aebe9ad55a1d9491e391dd06 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:46:34 -0800 Subject: [PATCH 05/20] Add sync commands for WordPress.com site management --- cli/commands/sync/connect.ts | 265 ++++++++++++++++++++++++++++++++ cli/commands/sync/disconnect.ts | 107 +++++++++++++ cli/commands/sync/list.ts | 146 ++++++++++++++++++ cli/commands/sync/status.ts | 117 ++++++++++++++ 4 files changed, 635 insertions(+) create mode 100644 cli/commands/sync/connect.ts create mode 100644 cli/commands/sync/disconnect.ts create mode 100644 cli/commands/sync/list.ts create mode 100644 cli/commands/sync/status.ts diff --git a/cli/commands/sync/connect.ts b/cli/commands/sync/connect.ts new file mode 100644 index 0000000000..fa028998be --- /dev/null +++ b/cli/commands/sync/connect.ts @@ -0,0 +1,265 @@ +import { select } from '@inquirer/prompts'; +import { __, sprintf } from '@wordpress/i18n'; +import wpcomFactory from 'src/lib/wpcom-factory'; +import wpcomXhrRequest from 'src/lib/wpcom-xhr-request-factory'; +import { z } from 'zod'; +import { getAuthToken, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +// Schema for WordPress.com site from /me/sites API +const wpcomSiteSchema = z.object( { + ID: z.number(), + name: z.string(), + URL: z.string(), + capabilities: z.record( z.boolean() ), + is_deleted: z.boolean().optional(), + plan: z + .object( { + features: z + .object( { + active: z.array( z.string() ), + } ) + .optional(), + } ) + .optional(), + is_wpcom_atomic: z.boolean().optional(), + options: z + .object( { + is_wpcom_staging_site: z.boolean().optional(), + hosting_provider_guess: z.string().optional(), + environment_type: z.string().optional(), + } ) + .optional(), +} ); + +type WpcomSite = z.infer< typeof wpcomSiteSchema >; + +async function fetchWpcomSites( token: string ): Promise< WpcomSite[] > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + + return new Promise( ( resolve, reject ) => { + wpcom.req.get( + { + path: '/me/sites', + apiNamespace: 'rest/v1.1', + }, + ( error: Error | null, data: unknown ) => { + if ( error ) { + return reject( error ); + } + + try { + const result = z.object( { sites: z.array( wpcomSiteSchema ) } ).parse( data ); + resolve( result.sites ); + } catch ( validationError ) { + reject( validationError ); + } + } + ); + } ); +} + +function getSyncSupport( site: WpcomSite ): { supported: boolean; reason?: string } { + // Check if site is deleted + if ( site.is_deleted ) { + return { supported: false, reason: __( 'Site is deleted' ) }; + } + + // Check manage_options capability + if ( ! site.capabilities?.manage_options ) { + return { supported: false, reason: __( 'Missing manage_options permission' ) }; + } + + // Check for studio-sync feature in plan + const features = site.plan?.features?.active || []; + if ( ! features.includes( 'studio-sync' ) ) { + return { supported: false, reason: __( 'Plan does not include sync feature' ) }; + } + + // Must be Atomic or Pressable + const isPressable = + site.options?.hosting_provider_guess === 'pressable' || + site.options?.hosting_provider_guess === 'pressable-free-staging' || + site.URL?.includes( '.mystagingwebsite.com' ); + + if ( ! site.is_wpcom_atomic && ! isPressable ) { + return { supported: false, reason: __( 'Site must be Atomic or Pressable' ) }; + } + + return { supported: true }; +} + +export async function runCommand( localSiteId: string, remoteSiteId?: string ): Promise< void > { + const logger = new Logger(); + + try { + // Get auth token + let token: Awaited< ReturnType< typeof getAuthToken > >; + try { + token = await getAuthToken(); + } catch { + logger.reportError( + new LoggerError( + __( + 'Authentication required. Please log in to WordPress.com first:\n\n studio auth login' + ) + ) + ); + return; + } + + // Load appdata + const appdata = await readAppdata(); + + // Find the local site + const localSite = appdata.sites.find( ( site ) => site.id === localSiteId ); + if ( ! localSite ) { + logger.reportError( + new LoggerError( sprintf( __( 'Local site "%s" not found' ), localSiteId ) ) + ); + console.log( __( '\nUse "studio site list" to see available sites' ) ); + return; + } + + // Fetch WordPress.com sites + logger.reportStart( undefined, __( 'Fetching WordPress.com sites…' ) ); + const wpcomSites = await fetchWpcomSites( token.accessToken ); + logger.spinner.stop(); + + // Filter for syncable sites not already connected to this local site + const connectedSites = appdata.connectedWpcomSites?.[ token.id ] || []; + const alreadyConnectedIds = connectedSites + .filter( ( site ) => site.localSiteId === localSiteId ) + .map( ( site ) => site.id ); + + const syncableSites = wpcomSites + .map( ( site ) => { + const support = getSyncSupport( site ); + return { site, support }; + } ) + .filter( + ( { site, support } ) => support.supported && ! alreadyConnectedIds.includes( site.ID ) + ); + + if ( syncableSites.length === 0 ) { + logger.reportWarning( __( 'No syncable WordPress.com sites found' ) ); + console.log( + __( + '\nTo sync with WordPress.com, you need an Atomic or Pressable site with the studio-sync feature.' + ) + ); + return; + } + + // Select site (either from argument or prompt) + let selectedSite: WpcomSite; + + if ( remoteSiteId ) { + const found = syncableSites.find( + ( { site } ) => site.ID === parseInt( remoteSiteId, 10 ) || site.URL.includes( remoteSiteId ) + ); + if ( ! found ) { + logger.reportError( + new LoggerError( sprintf( __( 'Remote site "%s" not found or not syncable' ), remoteSiteId ) ) + ); + return; + } + selectedSite = found.site; + } else { + // Interactive selection + const answer = await select( { + message: __( 'Select a WordPress.com site to connect:' ), + choices: syncableSites.map( ( { site } ) => ( { + name: `${ site.name } (${ site.URL })`, + value: site.ID, + } ) ), + } ); + + const found = syncableSites.find( ( { site } ) => site.ID === answer ); + if ( ! found ) { + throw new Error( __( 'Site selection failed' ) ); + } + selectedSite = found.site; + } + + // Save the connection + logger.reportStart( undefined, __( 'Connecting sites…' ) ); + + await lockAppdata(); + try { + const updatedAppdata = await readAppdata(); + + if ( ! updatedAppdata.connectedWpcomSites ) { + updatedAppdata.connectedWpcomSites = {}; + } + if ( ! updatedAppdata.connectedWpcomSites[ token.id ] ) { + updatedAppdata.connectedWpcomSites[ token.id ] = []; + } + + const isPressable = + selectedSite.options?.hosting_provider_guess === 'pressable' || + selectedSite.options?.hosting_provider_guess === 'pressable-free-staging' || + selectedSite.URL?.includes( '.mystagingwebsite.com' ); + + updatedAppdata.connectedWpcomSites[ token.id ].push( { + id: selectedSite.ID, + localSiteId, + name: selectedSite.name, + url: selectedSite.URL, + isStaging: selectedSite.options?.is_wpcom_staging_site || false, + isPressable, + environmentType: selectedSite.options?.environment_type || null, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + } ); + + await saveAppdata( updatedAppdata ); + + logger.reportSuccess( + sprintf( + __( 'Successfully connected "%s" to "%s"' ), + localSite.name || localSiteId, + selectedSite.name + ) + ); + } finally { + await unlockAppdata(); + } + } catch ( error ) { + logger.spinner.stop(); + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else if ( error instanceof z.ZodError ) { + logger.reportError( new LoggerError( __( 'Invalid response from WordPress.com' ), error ) ); + } else { + logger.reportError( new LoggerError( __( 'Failed to connect sites' ), error ) ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'connect [remote-site-id]', + describe: __( 'Connect a local site to a WordPress.com site' ), + builder: ( yargs ) => { + return yargs + .positional( 'local-site-id', { + describe: __( 'ID of the local site' ), + type: 'string', + demandOption: true, + } ) + .positional( 'remote-site-id', { + describe: __( 'ID or URL of the remote WordPress.com site (optional, will prompt if not provided)' ), + type: 'string', + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv[ 'local-site-id' ] as string, argv[ 'remote-site-id' ] as string | undefined ); + }, + } ); +}; diff --git a/cli/commands/sync/disconnect.ts b/cli/commands/sync/disconnect.ts new file mode 100644 index 0000000000..50505ab0e5 --- /dev/null +++ b/cli/commands/sync/disconnect.ts @@ -0,0 +1,107 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { getAuthToken, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( localSiteId: string ): Promise< void > { + const logger = new Logger(); + + try { + // Get auth token + let userId: number; + try { + const token = await getAuthToken(); + userId = token.id; + } catch { + logger.reportError( + new LoggerError( + __( + 'Authentication required. Please log in to WordPress.com first:\n\n studio auth login' + ) + ) + ); + return; + } + + logger.reportStart( undefined, __( 'Disconnecting site…' ) ); + + // Lock and read appdata + await lockAppdata(); + try { + const appdata = await readAppdata(); + + // Find the local site + const localSite = appdata.sites.find( ( site ) => site.id === localSiteId ); + if ( ! localSite ) { + logger.reportError( + new LoggerError( sprintf( __( 'Local site "%s" not found' ), localSiteId ) ) + ); + return; + } + + // Find connected sites + const connectedSites = appdata.connectedWpcomSites?.[ userId ] || []; + const syncSiteIndex = connectedSites.findIndex( + ( site ) => site.localSiteId === localSiteId + ); + + if ( syncSiteIndex === -1 ) { + logger.reportWarning( + sprintf( + __( 'Site "%s" is not connected to WordPress.com' ), + localSite.name || localSiteId + ) + ); + return; + } + + // Remove the connection + connectedSites.splice( syncSiteIndex, 1 ); + + // Update appdata + if ( ! appdata.connectedWpcomSites ) { + appdata.connectedWpcomSites = {}; + } + appdata.connectedWpcomSites[ userId ] = connectedSites; + + await saveAppdata( appdata ); + + logger.reportSuccess( + sprintf( + __( 'Successfully disconnected "%s" from WordPress.com' ), + localSite.name || localSiteId + ) + ); + } finally { + await unlockAppdata(); + } + } catch ( error ) { + logger.spinner.stop(); + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to disconnect site' ), error ) ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'disconnect ', + describe: __( 'Disconnect a local site from WordPress.com' ), + builder: ( yargs ) => { + return yargs + .positional( 'local-site-id', { + describe: __( 'ID of the local site to disconnect' ), + type: 'string', + demandOption: true, + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv[ 'local-site-id' ] as string ); + }, + } ); +}; diff --git a/cli/commands/sync/list.ts b/cli/commands/sync/list.ts new file mode 100644 index 0000000000..6e2f50155d --- /dev/null +++ b/cli/commands/sync/list.ts @@ -0,0 +1,146 @@ +import { __, _n, sprintf } from '@wordpress/i18n'; +import Table from 'cli-table3'; +import { getAuthToken, readAppdata } from 'cli/lib/appdata'; +import { getColumnWidths } from 'cli/lib/utils'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +interface SyncListEntry { + localSite: string; + remoteSite: string; + remoteUrl: string; + lastPull: string; + lastPush: string; +} + +async function getSyncListData(): Promise< SyncListEntry[] > { + const appdata = await readAppdata(); + const result: SyncListEntry[] = []; + + // Get current user ID from auth token + let userId: number | undefined; + try { + const token = await getAuthToken(); + userId = token.id; + } catch { + // Not authenticated, no connected sites + return result; + } + + if ( ! userId ) { + return result; + } + + const connectedSites = appdata.connectedWpcomSites?.[ userId ] || []; + + for ( const syncSite of connectedSites ) { + // Find the local site + const localSite = appdata.sites.find( ( site ) => site.id === syncSite.localSiteId ); + + result.push( { + localSite: localSite?.name || syncSite.localSiteId, + remoteSite: syncSite.name, + remoteUrl: syncSite.url, + lastPull: syncSite.lastPullTimestamp + ? new Date( syncSite.lastPullTimestamp ).toLocaleString() + : __( 'Never' ), + lastPush: syncSite.lastPushTimestamp + ? new Date( syncSite.lastPushTimestamp ).toLocaleString() + : __( 'Never' ), + } ); + } + + return result; +} + +function displaySyncList( sitesData: SyncListEntry[], format: 'table' | 'json' ): void { + if ( format === 'table' ) { + const colWidths = getColumnWidths( [ 0.2, 0.2, 0.25, 0.175, 0.175 ] ); + + const table = new Table( { + head: [ + __( 'Local Site' ), + __( 'Remote Site' ), + __( 'URL' ), + __( 'Last Pull' ), + __( 'Last Push' ), + ], + wordWrap: true, + wrapOnWordBoundary: false, + colWidths, + style: { + head: [], + border: [], + }, + } ); + + table.push( + ...sitesData.map( ( site ) => [ + site.localSite, + site.remoteSite, + { href: new URL( site.remoteUrl ).toString(), content: site.remoteUrl }, + site.lastPull, + site.lastPush, + ] ) + ); + + console.log( table.toString() ); + } else { + console.log( JSON.stringify( sitesData, null, 2 ) ); + } +} + +export async function runCommand( format: 'table' | 'json' ): Promise< void > { + const logger = new Logger(); + + try { + logger.reportStart( undefined, __( 'Loading connected sites…' ) ); + + const sitesData = await getSyncListData(); + + if ( sitesData.length === 0 ) { + logger.reportSuccess( __( 'No connected sites found' ) ); + console.log( + __( '\nUse "studio sync connect " to connect a site to WordPress.com' ) + ); + return; + } + + const sitesMessage = sprintf( + _n( 'Found %d connected site', 'Found %d connected sites', sitesData.length ), + sitesData.length + ); + logger.reportSuccess( sitesMessage ); + + displaySyncList( sitesData, format ); + } catch ( error ) { + logger.spinner.stop(); + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to list connected sites' ), error ) ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'list', + describe: __( 'List connected WordPress.com sites' ), + builder: ( yargs ) => { + return yargs + .option( 'format', { + type: 'string', + choices: [ 'table', 'json' ] as const, + default: 'table' as const, + description: __( 'Output format' ), + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.format ); + }, + } ); +}; diff --git a/cli/commands/sync/status.ts b/cli/commands/sync/status.ts new file mode 100644 index 0000000000..0b3b73b946 --- /dev/null +++ b/cli/commands/sync/status.ts @@ -0,0 +1,117 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { getAuthToken, readAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( localSiteId: string ): Promise< void > { + const logger = new Logger(); + + try { + // Get auth token + let userId: number; + try { + const token = await getAuthToken(); + userId = token.id; + } catch { + logger.reportError( + new LoggerError( + __( + 'Authentication required. Please log in to WordPress.com first:\n\n studio auth login' + ) + ) + ); + return; + } + + // Load appdata + const appdata = await readAppdata(); + + // Find the local site + const localSite = appdata.sites.find( ( site ) => site.id === localSiteId ); + if ( ! localSite ) { + logger.reportError( + new LoggerError( sprintf( __( 'Local site "%s" not found' ), localSiteId ) ) + ); + return; + } + + // Find connected sites for this local site + const connectedSites = appdata.connectedWpcomSites?.[ userId ] || []; + const syncSite = connectedSites.find( ( site ) => site.localSiteId === localSiteId ); + + if ( ! syncSite ) { + logger.reportSuccess( + sprintf( + __( 'Site "%s" is not connected to WordPress.com' ), + localSite.name || localSiteId + ) + ); + console.log( + __( '\nUse "studio sync connect " to connect to a remote site' ) + ); + return; + } + + // Display status + console.log( '\n' + sprintf( __( 'Sync Status for "%s"' ), localSite.name || localSiteId ) ); + console.log( __( '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' ) ); + console.log( sprintf( __( 'Remote Site: %s' ), syncSite.name ) ); + console.log( sprintf( __( 'Remote URL: %s' ), syncSite.url ) ); + console.log( + sprintf( + __( 'Environment: %s' ), + syncSite.isStaging + ? __( 'Staging' ) + : syncSite.isPressable + ? __( 'Pressable' ) + : __( 'Production' ) + ) + ); + console.log( + sprintf( + __( 'Last Pull: %s' ), + syncSite.lastPullTimestamp + ? new Date( syncSite.lastPullTimestamp ).toLocaleString() + : __( 'Never' ) + ) + ); + console.log( + sprintf( + __( 'Last Push: %s' ), + syncSite.lastPushTimestamp + ? new Date( syncSite.lastPushTimestamp ).toLocaleString() + : __( 'Never' ) + ) + ); + console.log( '' ); + + logger.reportSuccess( __( 'Sync status retrieved' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to get sync status' ), error ) ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'status ', + describe: __( 'Show sync status for a local site' ), + builder: ( yargs ) => { + return yargs + .positional( 'local-site-id', { + describe: __( 'ID of the local site' ), + type: 'string', + demandOption: true, + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv[ 'local-site-id' ] as string ); + }, + } ); +}; From 33c41c9f1c3dd3ae5c0f538b901ddb48e9541888 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:46:35 -0800 Subject: [PATCH 06/20] Add telex CLI executables --- bin/telex | 20 ++++++++++++++++++++ bin/telex.bat | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100755 bin/telex create mode 100644 bin/telex.bat diff --git a/bin/telex b/bin/telex new file mode 100755 index 0000000000..e227763b7b --- /dev/null +++ b/bin/telex @@ -0,0 +1,20 @@ +#!/bin/sh + +# Telex stub - returns upgrade message for users not on a Telex-enabled plan + +cat << 'EOF' +╭────────────────────────────────────────────────────────╮ +│ │ +│ Telex is not available on your current plan │ +│ │ +│ Telex is a premium feature that provides a │ +│ sandboxed AI environment for building WordPress │ +│ blocks, plugins, and themes. │ +│ │ +│ To access Telex, please upgrade your plan at: │ +│ https://wordpress.com/plans │ +│ │ +╰────────────────────────────────────────────────────────╯ +EOF + +exit 1 diff --git a/bin/telex.bat b/bin/telex.bat new file mode 100644 index 0000000000..c59fd10706 --- /dev/null +++ b/bin/telex.bat @@ -0,0 +1,19 @@ +@echo off +REM Telex stub - returns upgrade message for users not on a Telex-enabled plan + +echo. +echo +--------------------------------------------------------+ +echo ^| ^| +echo ^| Telex is not available on your current plan ^| +echo ^| ^| +echo ^| Telex is a premium feature that provides a ^| +echo ^| sandboxed AI environment for building WordPress ^| +echo ^| blocks, plugins, and themes. ^| +echo ^| ^| +echo ^| To access Telex, please upgrade your plan at: ^| +echo ^| https://wordpress.com/plans ^| +echo ^| ^| +echo +--------------------------------------------------------+ +echo. + +exit /b 1 From 313f526c0afff8c980892763fd328f3a8f12d9f1 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:46:36 -0800 Subject: [PATCH 07/20] Update agent instructions template with studio chat command --- src/modules/agent-instructions/constants.ts | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/modules/agent-instructions/constants.ts diff --git a/src/modules/agent-instructions/constants.ts b/src/modules/agent-instructions/constants.ts new file mode 100644 index 0000000000..28353cc780 --- /dev/null +++ b/src/modules/agent-instructions/constants.ts @@ -0,0 +1,81 @@ +/** + * Instruction file types supported by Studio. + */ +export type InstructionFileType = 'claude' | 'agents'; + +export interface InstructionFileConfig { + id: InstructionFileType; + fileName: string; + displayName: string; + description: string; +} + +/** + * Configuration for each instruction file type. + */ +export const INSTRUCTION_FILES: Record< InstructionFileType, InstructionFileConfig > = { + claude: { + id: 'claude', + fileName: 'CLAUDE.md', + displayName: 'CLAUDE.md', + description: 'Instructions for Claude Code', + }, + agents: { + id: 'agents', + fileName: 'AGENTS.md', + displayName: 'AGENTS.md', + description: 'Instructions for Codex, Goose, and other AI agents', + }, +}; + +/** + * All instruction file types in display order. + */ +export const INSTRUCTION_FILE_TYPES: InstructionFileType[] = [ 'claude', 'agents' ]; + +/** + * Legacy export for backwards compatibility. + */ +export const AGENTS_FILE_NAME = 'AGENTS.md'; + +export const DEFAULT_AGENT_INSTRUCTIONS = `# Studio Agent Instructions + +This project is managed with WordPress Studio. + +## Studio CLI +- Command: \`studio\` +- Use \`studio --help\` to see all commands. +- The CLI uses the current directory as the site path (override with \`--path\`). +- Common examples: + - \`studio site list\` + - \`studio site status --path ""\` + - \`studio site start --path ""\` + - \`studio site stop --path ""\` + - \`studio site stop --all\` + - \`studio preview list\` + - \`studio preview create --path ""\` + - \`studio preview update --path ""\` + - \`studio preview delete \` + - \`studio wp --path "" \` + - \`studio chat\` - Interactive chat with WordPress AI + - \`studio chat "your message"\` - Single-shot chat message + +## Telex CLI +- Command: \`telex\` +- Telex is a sandboxed AI environment for building WordPress assets. +- Use \`telex --help\` to see all commands. + +### Generate +- \`telex gen block \` - Generate a new block +- \`telex gen plugin \` - Generate a new plugin +- \`telex gen theme \` - Generate a new theme + +### Edit +- \`telex edit block \` - Edit an existing block conversationally +- \`telex edit plugin \` - Edit an existing plugin conversationally +- \`telex edit theme \` - Edit an existing theme conversationally + +### Chat +- \`telex chat\` - Start a conversational session for building and editing +- Telex chat understands your project context and can make changes interactively. +`; From 4c0c4ed73fa68e11e0720475bd3203d90267aac8 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:01:17 -0800 Subject: [PATCH 08/20] Add complete sync pull/push functionality with automatic site detection Implements full WordPress.com sync capabilities in the CLI with database export/import and automatic site detection from working directory. Features: - Pull: Downloads backup from WordPress.com, extracts, imports wp-content and database - Push: Exports database (split by table), creates tar.gz, uploads via TUS, triggers remote import - Smart site detection: Site ID now optional when running from site directory - Full database operations via WP-CLI integration - Progress tracking with real-time updates - Resumable uploads with TUS protocol - Size validation and error handling --- cli/commands/sync/disconnect.ts | 28 +-- cli/commands/sync/pull.ts | 287 +++++++++++++++++++++++++ cli/commands/sync/push.ts | 370 ++++++++++++++++++++++++++++++++ cli/commands/sync/status.ts | 30 ++- cli/index.ts | 15 ++ cli/lib/sync-export.ts | 173 +++++++++++++++ cli/lib/sync-helpers.ts | 31 +++ cli/lib/sync-import.ts | 101 +++++++++ 8 files changed, 1000 insertions(+), 35 deletions(-) create mode 100644 cli/commands/sync/pull.ts create mode 100644 cli/commands/sync/push.ts create mode 100644 cli/lib/sync-export.ts create mode 100644 cli/lib/sync-helpers.ts create mode 100644 cli/lib/sync-import.ts diff --git a/cli/commands/sync/disconnect.ts b/cli/commands/sync/disconnect.ts index 50505ab0e5..036d346b24 100644 --- a/cli/commands/sync/disconnect.ts +++ b/cli/commands/sync/disconnect.ts @@ -1,9 +1,10 @@ import { __, sprintf } from '@wordpress/i18n'; import { getAuthToken, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { getSiteForSync } from 'cli/lib/sync-helpers'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( localSiteId: string ): Promise< void > { +export async function runCommand( localSiteId?: string ): Promise< void > { const logger = new Logger(); try { @@ -23,6 +24,9 @@ export async function runCommand( localSiteId: string ): Promise< void > { return; } + // Get the local site + const localSite = await getSiteForSync( localSiteId ); + logger.reportStart( undefined, __( 'Disconnecting site…' ) ); // Lock and read appdata @@ -30,26 +34,17 @@ export async function runCommand( localSiteId: string ): Promise< void > { try { const appdata = await readAppdata(); - // Find the local site - const localSite = appdata.sites.find( ( site ) => site.id === localSiteId ); - if ( ! localSite ) { - logger.reportError( - new LoggerError( sprintf( __( 'Local site "%s" not found' ), localSiteId ) ) - ); - return; - } - // Find connected sites const connectedSites = appdata.connectedWpcomSites?.[ userId ] || []; const syncSiteIndex = connectedSites.findIndex( - ( site ) => site.localSiteId === localSiteId + ( site ) => site.localSiteId === localSite.id ); if ( syncSiteIndex === -1 ) { logger.reportWarning( sprintf( __( 'Site "%s" is not connected to WordPress.com' ), - localSite.name || localSiteId + localSite.name || localSite.id ) ); return; @@ -69,7 +64,7 @@ export async function runCommand( localSiteId: string ): Promise< void > { logger.reportSuccess( sprintf( __( 'Successfully disconnected "%s" from WordPress.com' ), - localSite.name || localSiteId + localSite.name || localSite.id ) ); } finally { @@ -87,21 +82,20 @@ export async function runCommand( localSiteId: string ): Promise< void > { export const registerCommand = ( yargs: StudioArgv ) => { return yargs.command( { - command: 'disconnect ', + command: 'disconnect [local-site-id]', describe: __( 'Disconnect a local site from WordPress.com' ), builder: ( yargs ) => { return yargs .positional( 'local-site-id', { - describe: __( 'ID of the local site to disconnect' ), + describe: __( 'ID of the local site (optional if run from site directory)' ), type: 'string', - demandOption: true, } ) .option( 'path', { hidden: true, } ); }, handler: async ( argv ) => { - await runCommand( argv[ 'local-site-id' ] as string ); + await runCommand( argv[ 'local-site-id' ] as string | undefined ); }, } ); }; diff --git a/cli/commands/sync/pull.ts b/cli/commands/sync/pull.ts new file mode 100644 index 0000000000..21ac552ace --- /dev/null +++ b/cli/commands/sync/pull.ts @@ -0,0 +1,287 @@ +import { __, sprintf } from '@wordpress/i18n'; +import fs from 'fs'; +import fsPromises from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { importBackupFromFile } from 'cli/lib/sync-import'; +import { getSiteForSync } from 'cli/lib/sync-helpers'; +import wpcomFactory from 'src/lib/wpcom-factory'; +import wpcomXhrRequest from 'src/lib/wpcom-xhr-request-factory'; +import { z } from 'zod'; +import { getAuthToken, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const TEMP_DIR = path.join( os.tmpdir(), 'studio-sync-backups' ); +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_ATTEMPTS = 200; // 10 minutes max + +const backupStatusSchema = z.object( { + success: z.boolean(), + status: z.enum( [ 'creating', 'ready', 'failed' ] ), + download_url: z.string().optional(), + error: z.string().optional(), +} ); + +type BackupStatus = z.infer< typeof backupStatusSchema >; + +async function createBackup( + token: string, + remoteSiteId: number, + options: string[] +): Promise< string > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + + return new Promise( ( resolve, reject ) => { + wpcom.req.post( + { + path: `/sites/${ remoteSiteId }/studio-app/sync/backup`, + apiNamespace: 'wpcom/v2', + body: { + options, + include_path_list: [], + }, + }, + ( error: Error | null, data: unknown ) => { + if ( error ) { + return reject( error ); + } + + try { + const result = z.object( { success: z.boolean(), backup_id: z.string() } ).parse( data ); + if ( ! result.success ) { + return reject( new Error( __( 'Failed to create backup' ) ) ); + } + resolve( result.backup_id ); + } catch ( validationError ) { + reject( validationError ); + } + } + ); + } ); +} + +async function checkBackupStatus( + token: string, + remoteSiteId: number, + backupId: string +): Promise< BackupStatus > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + + return new Promise( ( resolve, reject ) => { + wpcom.req.get( + { + path: `/sites/${ remoteSiteId }/studio-app/sync/backup`, + apiNamespace: 'wpcom/v2', + }, + { backup_id: backupId }, + ( error: Error | null, data: unknown ) => { + if ( error ) { + return reject( error ); + } + + try { + const result = backupStatusSchema.parse( data ); + resolve( result ); + } catch ( validationError ) { + reject( validationError ); + } + } + ); + } ); +} + +async function downloadFile( url: string, dest: string, logger: Logger ): Promise< void > { + const https = await import( 'https' ); + const file = fs.createWriteStream( dest ); + + return new Promise( ( resolve, reject ) => { + https.get( url, ( response ) => { + if ( response.statusCode !== 200 ) { + reject( new Error( `Failed to download: ${ response.statusCode }` ) ); + return; + } + + const totalSize = parseInt( response.headers[ 'content-length' ] || '0', 10 ); + let downloadedSize = 0; + let lastProgress = 0; + + response.on( 'data', ( chunk ) => { + downloadedSize += chunk.length; + const progress = Math.floor( ( downloadedSize / totalSize ) * 100 ); + + if ( progress > lastProgress && progress % 10 === 0 ) { + logger.reportProgress( + sprintf( __( 'Downloading backup… %d%%' ), progress ) + ); + lastProgress = progress; + } + } ); + + response.pipe( file ); + + file.on( 'finish', () => { + file.close(); + resolve(); + } ); + + file.on( 'error', ( err ) => { + fs.unlink( dest, () => {} ); + reject( err ); + } ); + } ); + } ); +} + +export async function runCommand( localSiteId?: string, options?: string[] ): Promise< void > { + const logger = new Logger(); + + try { + // Get auth token + let token: Awaited< ReturnType< typeof getAuthToken > >; + try { + token = await getAuthToken(); + } catch { + logger.reportError( + new LoggerError( + __( + 'Authentication required. Please log in to WordPress.com first:\n\n studio auth login' + ) + ) + ); + return; + } + + // Get the local site + const localSite = await getSiteForSync( localSiteId ); + + // Load appdata + const appdata = await readAppdata(); + + // Find connected site + const connectedSites = appdata.connectedWpcomSites?.[ token.id ] || []; + const syncSite = connectedSites.find( ( site ) => site.localSiteId === localSite.id ); + + if ( ! syncSite ) { + logger.reportError( + new LoggerError( + sprintf( __( 'Site "%s" is not connected to WordPress.com' ), localSite.name || localSite.id ) + ) + ); + console.log( __( '\nUse "studio sync connect" to connect to a remote site' ) ); + return; + } + + // Create temporary directory + await fsPromises.mkdir( TEMP_DIR, { recursive: true } ); + + // Start backup creation + logger.reportStart( undefined, __( 'Creating backup on remote site…' ) ); + const backupId = await createBackup( token.accessToken, syncSite.id, options || [ 'all' ] ); + + // Poll for backup completion + logger.reportProgress( __( 'Waiting for backup to complete…' ) ); + let attempts = 0; + let status: BackupStatus | null = null; + + while ( attempts < MAX_POLL_ATTEMPTS ) { + await new Promise( ( resolve ) => setTimeout( resolve, POLL_INTERVAL_MS ) ); + status = await checkBackupStatus( token.accessToken, syncSite.id, backupId ); + + if ( status.status === 'ready' ) { + break; + } + + if ( status.status === 'failed' ) { + throw new Error( status.error || __( 'Backup creation failed' ) ); + } + + attempts++; + if ( attempts % 5 === 0 ) { + logger.reportProgress( + sprintf( __( 'Waiting for backup… (%d seconds)' ), attempts * 3 ) + ); + } + } + + if ( ! status || status.status !== 'ready' || ! status.download_url ) { + throw new Error( __( 'Backup timed out or failed' ) ); + } + + // Download backup + logger.reportProgress( __( 'Downloading backup…' ) ); + const backupPath = path.join( TEMP_DIR, `backup-${ syncSite.id }.tar.gz` ); + await downloadFile( status.download_url, backupPath, logger ); + + // Import backup + logger.reportProgress( __( 'Importing backup into local site…' ) ); + + await importBackupFromFile( backupPath, localSite, logger ); + + // Cleanup + await fsPromises.unlink( backupPath ).catch( () => {} ); + + // Update last pull timestamp + await lockAppdata(); + try { + const updatedAppdata = await readAppdata(); + const updatedConnectedSites = updatedAppdata.connectedWpcomSites?.[ token.id ] || []; + const siteIndex = updatedConnectedSites.findIndex( + ( site ) => site.id === syncSite.id && site.localSiteId === localSite.id + ); + + if ( siteIndex !== -1 ) { + updatedConnectedSites[ siteIndex ].lastPullTimestamp = new Date().toISOString(); + if ( ! updatedAppdata.connectedWpcomSites ) { + updatedAppdata.connectedWpcomSites = {}; + } + updatedAppdata.connectedWpcomSites[ token.id ] = updatedConnectedSites; + await saveAppdata( updatedAppdata ); + } + } finally { + await unlockAppdata(); + } + + logger.reportSuccess( + sprintf( + __( 'Successfully pulled from "%s" to "%s"' ), + syncSite.name, + localSite.name || localSite.id + ) + ); + } catch ( error ) { + logger.spinner.stop(); + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else if ( error instanceof z.ZodError ) { + logger.reportError( new LoggerError( __( 'Invalid response from server' ), error ) ); + } else { + logger.reportError( new LoggerError( __( 'Failed to pull site' ), error ) ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'pull [local-site-id]', + describe: __( 'Pull changes from WordPress.com to local site' ), + builder: ( yargs ) => { + return yargs + .positional( 'local-site-id', { + describe: __( 'ID of the local site (optional if run from site directory)' ), + type: 'string', + } ) + .option( 'options', { + describe: __( 'What to sync (all, sqls, themes, plugins, uploads, contents)' ), + type: 'array', + default: [ 'all' ], + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv[ 'local-site-id' ] as string | undefined, argv.options as string[] | undefined ); + }, + } ); +}; diff --git a/cli/commands/sync/push.ts b/cli/commands/sync/push.ts new file mode 100644 index 0000000000..64c706bee7 --- /dev/null +++ b/cli/commands/sync/push.ts @@ -0,0 +1,370 @@ +import { __, sprintf } from '@wordpress/i18n'; +import fs from 'fs'; +import fsPromises from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { exportSiteToTarGz } from 'cli/lib/sync-export'; +import { getSiteForSync } from 'cli/lib/sync-helpers'; +import wpcomFactory from 'src/lib/wpcom-factory'; +import wpcomXhrRequest from 'src/lib/wpcom-xhr-request-factory'; +import { Upload } from 'tus-js-client'; +import { z } from 'zod'; +import { getAuthToken, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const TEMP_DIR = path.join( os.tmpdir(), 'studio-sync-exports' ); +const POLL_INTERVAL_MS = 2000; +const MAX_POLL_ATTEMPTS = 300; // 10 minutes max +const MAX_SIZE_BYTES = 5 * 1024 * 1024 * 1024; // 5GB + +const importStatusSchema = z.object( { + success: z.boolean(), + status: z.enum( [ + 'initial_backup', + 'archive_import_started', + 'archive_import_finished', + 'finished', + 'failed', + ] ), + error: z.string().optional(), + error_data: z + .object( { + vp_restore_message: z.string().optional(), + } ) + .optional(), +} ); + +type ImportStatus = z.infer< typeof importStatusSchema >; + +async function uploadFile( + token: string, + remoteSiteId: number, + filePath: string, + logger: Logger +): Promise< void > { + const file = fs.createReadStream( filePath ); + const fileSize = fs.statSync( filePath ).size; + + if ( fileSize > MAX_SIZE_BYTES ) { + throw new Error( + sprintf( + __( 'File size exceeds 5GB limit (%d bytes)' ), + fileSize + ) + ); + } + + return new Promise( ( resolve, reject ) => { + let lastProgress = 0; + + const upload = new Upload( file, { + endpoint: `https://public-api.wordpress.com/rest/v1.1/studio-file-uploads/${ remoteSiteId }`, + chunkSize: 500000, + retryDelays: [ 0, 1000, 3000, 5000, 10000, 25000 ], + overridePatchMethod: true, + removeFingerprintOnSuccess: true, + storeFingerprintForResuming: true, + headers: { + Authorization: `Bearer ${ token }`, + }, + metadata: { + filename: path.basename( filePath ), + filetype: 'application/gzip', + }, + onError: ( error ) => { + reject( error ); + }, + onProgress: ( bytesUploaded, bytesTotal ) => { + const progress = Math.floor( ( bytesUploaded / bytesTotal ) * 100 ); + if ( progress > lastProgress && progress % 5 === 0 ) { + logger.reportProgress( sprintf( __( 'Uploading… %d%%' ), progress ) ); + lastProgress = progress; + } + }, + onSuccess: () => { + resolve(); + }, + } ); + + upload.start(); + } ); +} + +async function initiateImport( + token: string, + remoteSiteId: number, + options: string[] +): Promise< void > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + + return new Promise( ( resolve, reject ) => { + wpcom.req.post( + { + path: `/sites/${ remoteSiteId }/studio-app/sync/import/initiate`, + apiNamespace: 'wpcom/v2', + body: { + options, + paths: [], + }, + }, + ( error: Error | null, data: unknown ) => { + if ( error ) { + return reject( error ); + } + + try { + const result = z.object( { success: z.boolean() } ).parse( data ); + if ( ! result.success ) { + return reject( new Error( __( 'Failed to initiate import' ) ) ); + } + resolve(); + } catch ( validationError ) { + reject( validationError ); + } + } + ); + } ); +} + +async function checkImportStatus( + token: string, + remoteSiteId: number +): Promise< ImportStatus > { + const wpcom = wpcomFactory( token, wpcomXhrRequest ); + + return new Promise( ( resolve, reject ) => { + wpcom.req.get( + { + path: `/sites/${ remoteSiteId }/studio-app/sync/import`, + apiNamespace: 'wpcom/v2', + }, + ( error: Error | null, data: unknown ) => { + if ( error ) { + return reject( error ); + } + + try { + const result = importStatusSchema.parse( data ); + resolve( result ); + } catch ( validationError ) { + reject( validationError ); + } + } + ); + } ); +} + +export async function runCommand( localSiteId?: string, options?: string[] ): Promise< void > { + const logger = new Logger(); + let archivePath: string | null = null; + + try { + // Get auth token + let token: Awaited< ReturnType< typeof getAuthToken > >; + try { + token = await getAuthToken(); + } catch { + logger.reportError( + new LoggerError( + __( + 'Authentication required. Please log in to WordPress.com first:\n\n studio auth login' + ) + ) + ); + return; + } + + // Get the local site + const localSite = await getSiteForSync( localSiteId ); + + // Load appdata + const appdata = await readAppdata(); + + // Find connected site + const connectedSites = appdata.connectedWpcomSites?.[ token.id ] || []; + const syncSite = connectedSites.find( ( site ) => site.localSiteId === localSite.id ); + + if ( ! syncSite ) { + logger.reportError( + new LoggerError( + sprintf( __( 'Site "%s" is not connected to WordPress.com' ), localSite.name || localSite.id ) + ) + ); + console.log( __( '\nUse "studio sync connect" to connect to a remote site' ) ); + return; + } + + // Create temporary directory + await fsPromises.mkdir( TEMP_DIR, { recursive: true } ); + + // Export local site + logger.reportStart( undefined, __( 'Exporting local site…' ) ); + + archivePath = path.join( TEMP_DIR, `site_${ localSite.id }.tar.gz` ); + + const optionsArray = options || [ 'all' ]; + const shouldIncludeOption = ( option: string ): boolean => { + return optionsArray.includes( option ) || optionsArray.includes( 'all' ); + }; + + await exportSiteToTarGz( + localSite, + archivePath, + { + includeDatabase: shouldIncludeOption( 'sqls' ), + includeWpContent: + shouldIncludeOption( 'uploads' ) || + shouldIncludeOption( 'plugins' ) || + shouldIncludeOption( 'themes' ) || + shouldIncludeOption( 'contents' ) || + optionsArray.includes( 'all' ), + specificPaths: undefined, + }, + logger + ); + + // Check file size + const stats = fs.statSync( archivePath ); + if ( stats.size > MAX_SIZE_BYTES ) { + throw new Error( + __( + 'Exported site exceeds 5GB limit. Please reduce the site size or select specific content to sync.' + ) + ); + } + + // Upload to WordPress.com + logger.reportProgress( __( 'Uploading to WordPress.com…' ) ); + await uploadFile( token.accessToken, syncSite.id, archivePath, logger ); + + // Initiate import + logger.reportProgress( __( 'Initiating import on remote site…' ) ); + await initiateImport( token.accessToken, syncSite.id, optionsArray ); + + // Poll for import completion + logger.reportProgress( __( 'Waiting for import to complete…' ) ); + let attempts = 0; + let status: ImportStatus | null = null; + + while ( attempts < MAX_POLL_ATTEMPTS ) { + await new Promise( ( resolve ) => setTimeout( resolve, POLL_INTERVAL_MS ) ); + status = await checkImportStatus( token.accessToken, syncSite.id ); + + if ( status.status === 'finished' ) { + break; + } + + if ( status.status === 'failed' ) { + const restoreMessage = status.error_data?.vp_restore_message || ''; + const isSqlImportFailure = /importing sql dump/i.test( restoreMessage ); + const isImportTimedOut = status.error === 'Import timed out'; + + if ( isSqlImportFailure ) { + throw new Error( + __( 'Database import failed on the remote site. Please review your database and try again.' ) + ); + } else if ( isImportTimedOut ) { + throw new Error( + __( + "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again." + ) + ); + } else { + throw new Error( status.error || __( 'Import failed' ) ); + } + } + + attempts++; + if ( attempts % 10 === 0 ) { + const statusMap: Record< string, string > = { + initial_backup: __( 'Creating backup…' ), + archive_import_started: __( 'Importing…' ), + archive_import_finished: __( 'Finalizing…' ), + }; + const statusText = statusMap[ status.status ] || __( 'Processing…' ); + logger.reportProgress( + sprintf( __( '%s (%d seconds)' ), statusText, attempts * 2 ) + ); + } + } + + if ( ! status || status.status !== 'finished' ) { + throw new Error( __( 'Import timed out' ) ); + } + + // Cleanup + if ( archivePath ) { + await fsPromises.unlink( archivePath ).catch( () => {} ); + } + + // Update last push timestamp + await lockAppdata(); + try { + const updatedAppdata = await readAppdata(); + const updatedConnectedSites = updatedAppdata.connectedWpcomSites?.[ token.id ] || []; + const siteIndex = updatedConnectedSites.findIndex( + ( site ) => site.id === syncSite.id && site.localSiteId === localSite.id + ); + + if ( siteIndex !== -1 ) { + updatedConnectedSites[ siteIndex ].lastPushTimestamp = new Date().toISOString(); + if ( ! updatedAppdata.connectedWpcomSites ) { + updatedAppdata.connectedWpcomSites = {}; + } + updatedAppdata.connectedWpcomSites[ token.id ] = updatedConnectedSites; + await saveAppdata( updatedAppdata ); + } + } finally { + await unlockAppdata(); + } + + logger.reportSuccess( + sprintf( + __( 'Successfully pushed "%s" to "%s"' ), + localSite.name || localSite.id, + syncSite.name + ) + ); + } catch ( error ) { + logger.spinner.stop(); + + // Cleanup on error + if ( archivePath ) { + await fsPromises.unlink( archivePath ).catch( () => {} ); + } + + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else if ( error instanceof z.ZodError ) { + logger.reportError( new LoggerError( __( 'Invalid response from server' ), error ) ); + } else { + logger.reportError( new LoggerError( __( 'Failed to push site' ), error ) ); + } + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'push [local-site-id]', + describe: __( 'Push changes from local site to WordPress.com' ), + builder: ( yargs ) => { + return yargs + .positional( 'local-site-id', { + describe: __( 'ID of the local site (optional if run from site directory)' ), + type: 'string', + } ) + .option( 'options', { + describe: __( 'What to sync (all, sqls, themes, plugins, uploads, contents)' ), + type: 'array', + default: [ 'all' ], + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv[ 'local-site-id' ] as string | undefined, argv.options as string[] | undefined ); + }, + } ); +}; diff --git a/cli/commands/sync/status.ts b/cli/commands/sync/status.ts index 0b3b73b946..ffb9286aa9 100644 --- a/cli/commands/sync/status.ts +++ b/cli/commands/sync/status.ts @@ -1,9 +1,10 @@ import { __, sprintf } from '@wordpress/i18n'; import { getAuthToken, readAppdata } from 'cli/lib/appdata'; +import { getSiteForSync } from 'cli/lib/sync-helpers'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( localSiteId: string ): Promise< void > { +export async function runCommand( localSiteId?: string ): Promise< void > { const logger = new Logger(); try { @@ -23,37 +24,31 @@ export async function runCommand( localSiteId: string ): Promise< void > { return; } + // Get the local site (either by ID or from current directory) + const localSite = await getSiteForSync( localSiteId ); + // Load appdata const appdata = await readAppdata(); - // Find the local site - const localSite = appdata.sites.find( ( site ) => site.id === localSiteId ); - if ( ! localSite ) { - logger.reportError( - new LoggerError( sprintf( __( 'Local site "%s" not found' ), localSiteId ) ) - ); - return; - } - // Find connected sites for this local site const connectedSites = appdata.connectedWpcomSites?.[ userId ] || []; - const syncSite = connectedSites.find( ( site ) => site.localSiteId === localSiteId ); + const syncSite = connectedSites.find( ( site ) => site.localSiteId === localSite.id ); if ( ! syncSite ) { logger.reportSuccess( sprintf( __( 'Site "%s" is not connected to WordPress.com' ), - localSite.name || localSiteId + localSite.name || localSite.id ) ); console.log( - __( '\nUse "studio sync connect " to connect to a remote site' ) + __( '\nUse "studio sync connect" to connect to a remote site' ) ); return; } // Display status - console.log( '\n' + sprintf( __( 'Sync Status for "%s"' ), localSite.name || localSiteId ) ); + console.log( '\n' + sprintf( __( 'Sync Status for "%s"' ), localSite.name || localSite.id ) ); console.log( __( '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' ) ); console.log( sprintf( __( 'Remote Site: %s' ), syncSite.name ) ); console.log( sprintf( __( 'Remote URL: %s' ), syncSite.url ) ); @@ -97,21 +92,20 @@ export async function runCommand( localSiteId: string ): Promise< void > { export const registerCommand = ( yargs: StudioArgv ) => { return yargs.command( { - command: 'status ', + command: 'status [local-site-id]', describe: __( 'Show sync status for a local site' ), builder: ( yargs ) => { return yargs .positional( 'local-site-id', { - describe: __( 'ID of the local site' ), + describe: __( 'ID of the local site (optional if run from site directory)' ), type: 'string', - demandOption: true, } ) .option( 'path', { hidden: true, } ); }, handler: async ( argv ) => { - await runCommand( argv[ 'local-site-id' ] as string ); + await runCommand( argv[ 'local-site-id' ] as string | undefined ); }, } ); }; diff --git a/cli/index.ts b/cli/index.ts index 8f9561fe2d..f90c164903 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -21,6 +21,12 @@ import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/ import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop'; import { commandHandler as wpCliCommandHandler } from 'cli/commands/wp'; import { registerCommand as registerChatCommand } from 'cli/commands/chat'; +import { registerCommand as registerSyncListCommand } from 'cli/commands/sync/list'; +import { registerCommand as registerSyncStatusCommand } from 'cli/commands/sync/status'; +import { registerCommand as registerSyncConnectCommand } from 'cli/commands/sync/connect'; +import { registerCommand as registerSyncDisconnectCommand } from 'cli/commands/sync/disconnect'; +import { registerCommand as registerSyncPullCommand } from 'cli/commands/sync/pull'; +import { registerCommand as registerSyncPushCommand } from 'cli/commands/sync/push'; import { readAppdata, lockAppdata, unlockAppdata, saveAppdata } from 'cli/lib/appdata'; import { loadTranslations } from 'cli/lib/i18n'; import { untildify } from 'cli/lib/utils'; @@ -100,6 +106,15 @@ async function main() { registerSiteSetCommand( sitesYargs ); sitesYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) ); } ) + .command( 'sync', __( 'Sync sites with WordPress.com' ), ( syncYargs ) => { + registerSyncListCommand( syncYargs ); + registerSyncStatusCommand( syncYargs ); + registerSyncConnectCommand( syncYargs ); + registerSyncDisconnectCommand( syncYargs ); + registerSyncPullCommand( syncYargs ); + registerSyncPushCommand( syncYargs ); + syncYargs.version( false ).demandCommand( 1, __( 'You must provide a valid sync command' ) ); + } ) .command( { command: 'wp', describe: __( 'WP-CLI' ), diff --git a/cli/lib/sync-export.ts b/cli/lib/sync-export.ts new file mode 100644 index 0000000000..d9988f811c --- /dev/null +++ b/cli/lib/sync-export.ts @@ -0,0 +1,173 @@ +import { __, sprintf } from '@wordpress/i18n'; +import fs from 'fs'; +import fsPromises from 'fs/promises'; +import path from 'path'; +import * as tar from 'tar'; +import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; +import { SiteData } from 'cli/lib/appdata'; +import { Logger } from 'cli/logger'; + +const EXCLUDED_PATHS = [ + 'database', + 'db.php', + 'debug.log', + 'sqlite-database-integration', + '.DS_Store', + 'Thumbs.db', + '.git', + 'node_modules', + 'cache', +]; + +export async function exportSiteToTarGz( + site: SiteData, + archivePath: string, + options: { + includeDatabase?: boolean; + includeWpContent?: boolean; + specificPaths?: string[]; + }, + logger: Logger +): Promise< void > { + const tempDir = path.join( path.dirname( archivePath ), 'temp-export-' + Date.now() ); + + try { + await fsPromises.mkdir( tempDir, { recursive: true } ); + + const filesToArchive: string[] = []; + + // Export database if requested + if ( options.includeDatabase !== false ) { + logger.reportProgress( __( 'Exporting database…' ) ); + + const sqlDir = path.join( tempDir, 'sql' ); + await fsPromises.mkdir( sqlDir, { recursive: true } ); + + // Get list of tables + const [ tablesResponse, exitTablesPhp ] = await runWpCliCommand( site.path, site.phpVersion, [ + 'db', + 'tables', + '--format=csv', + ] ); + + const tablesText = await tablesResponse.text; + exitTablesPhp(); + + const tables = tablesText + .trim() + .split( '\n' ) + .slice( 1 ) // Skip header + .map( ( line ) => line.trim() ) + .filter( Boolean ); + + // Export each table + for ( const table of tables ) { + const tableSqlPath = path.join( sqlDir, `${ table }.sql` ); + + try { + const [ exportResponse, exitExportPhp ] = await runWpCliCommand( + site.path, + site.phpVersion, + [ 'db', 'export', '-', `--tables=${ table }`, '--no-tablespaces' ] + ); + + const sqlContent = await exportResponse.text; + await fsPromises.writeFile( tableSqlPath, sqlContent ); + exitExportPhp(); + } catch ( error ) { + console.warn( sprintf( __( 'Warning: Failed to export table %s' ), table ) ); + } + } + + filesToArchive.push( 'sql' ); + } + + // Copy wp-content if requested + if ( options.includeWpContent !== false ) { + logger.reportProgress( __( 'Copying wp-content…' ) ); + + const wpContentSrc = path.join( site.path, 'wp-content' ); + const wpContentDest = path.join( tempDir, 'wp-content' ); + + if ( fs.existsSync( wpContentSrc ) ) { + await copyDirectoryFiltered( wpContentSrc, wpContentDest, options.specificPaths ); + filesToArchive.push( 'wp-content' ); + } + } + + // Copy wp-config.php + const wpConfigSrc = path.join( site.path, 'wp-config.php' ); + if ( fs.existsSync( wpConfigSrc ) ) { + await fsPromises.copyFile( wpConfigSrc, path.join( tempDir, 'wp-config.php' ) ); + filesToArchive.push( 'wp-config.php' ); + } + + // Create meta.json + const meta = { + version: 1, + created: new Date().toISOString(), + site: { + name: site.name, + path: site.path, + }, + }; + await fsPromises.writeFile( path.join( tempDir, 'meta.json' ), JSON.stringify( meta, null, 2 ) ); + filesToArchive.push( 'meta.json' ); + + // Create tar.gz archive + logger.reportProgress( __( 'Creating archive…' ) ); + + await tar.create( + { + gzip: true, + file: archivePath, + cwd: tempDir, + }, + filesToArchive + ); + } finally { + // Cleanup temp directory + if ( fs.existsSync( tempDir ) ) { + await fsPromises.rm( tempDir, { recursive: true, force: true } ).catch( () => {} ); + } + } +} + +async function copyDirectoryFiltered( + src: string, + dest: string, + specificPaths?: string[] +): Promise< void > { + await fsPromises.mkdir( dest, { recursive: true } ); + + const entries = await fsPromises.readdir( src, { withFileTypes: true } ); + + for ( const entry of entries ) { + // Skip excluded paths + if ( EXCLUDED_PATHS.includes( entry.name ) ) { + continue; + } + + // Check if this path is in specific paths (if specified) + if ( specificPaths && specificPaths.length > 0 ) { + const relativePath = path.relative( src, path.join( src, entry.name ) ); + const isIncluded = specificPaths.some( ( p ) => relativePath.startsWith( p ) ); + if ( ! isIncluded ) { + continue; + } + } + + const srcPath = path.join( src, entry.name ); + const destPath = path.join( dest, entry.name ); + + if ( entry.isDirectory() ) { + // Skip .git and node_modules + if ( entry.name === '.git' || entry.name === 'node_modules' ) { + continue; + } + await copyDirectoryFiltered( srcPath, destPath, specificPaths ); + } else { + await fsPromises.copyFile( srcPath, destPath ); + } + } +} diff --git a/cli/lib/sync-helpers.ts b/cli/lib/sync-helpers.ts new file mode 100644 index 0000000000..d542834bd3 --- /dev/null +++ b/cli/lib/sync-helpers.ts @@ -0,0 +1,31 @@ +import { __ } from '@wordpress/i18n'; +import { getSiteByFolder, readAppdata, SiteData } from 'cli/lib/appdata'; +import { Logger, LoggerError } from 'cli/logger'; + +/** + * Get a site by ID or from the current working directory. + * If siteId is provided, uses that. Otherwise, tries to detect from cwd. + */ +export async function getSiteForSync( siteId?: string, cwd?: string ): Promise< SiteData > { + if ( siteId ) { + // Use provided site ID + const appdata = await readAppdata(); + const site = appdata.sites.find( ( s ) => s.id === siteId ); + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + return site; + } + + // Try to detect from current directory + const workingDir = cwd || process.cwd(); + try { + return await getSiteByFolder( workingDir ); + } catch ( error ) { + throw new LoggerError( + __( + 'No site ID provided and current directory is not a Studio site.\n\nEither run this command from a site directory or provide a site ID:\n studio sync ' + ) + ); + } +} diff --git a/cli/lib/sync-import.ts b/cli/lib/sync-import.ts new file mode 100644 index 0000000000..acdcda7ec0 --- /dev/null +++ b/cli/lib/sync-import.ts @@ -0,0 +1,101 @@ +import { __, sprintf } from '@wordpress/i18n'; +import fs from 'fs'; +import fsPromises from 'fs/promises'; +import path from 'path'; +import * as tar from 'tar'; +import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; +import { SiteData } from 'cli/lib/appdata'; +import { Logger } from 'cli/logger'; + +export async function importBackupFromFile( + backupPath: string, + site: SiteData, + logger: Logger +): Promise< void > { + const extractDir = path.join( path.dirname( backupPath ), 'extract-' + Date.now() ); + + try { + // Extract tar.gz + logger.reportProgress( __( 'Extracting backup…' ) ); + await fsPromises.mkdir( extractDir, { recursive: true } ); + + await tar.extract( { + file: backupPath, + cwd: extractDir, + } ); + + // Import wp-content + const wpContentSrc = path.join( extractDir, 'wp-content' ); + const wpContentDest = path.join( site.path, 'wp-content' ); + + if ( fs.existsSync( wpContentSrc ) ) { + logger.reportProgress( __( 'Importing wp-content…' ) ); + + // Copy wp-content files + await copyDirectory( wpContentSrc, wpContentDest ); + } + + // Import SQL files + const sqlDir = path.join( extractDir, 'sql' ); + if ( fs.existsSync( sqlDir ) ) { + logger.reportProgress( __( 'Importing database…' ) ); + + const sqlFiles = await fsPromises.readdir( sqlDir ); + + for ( const sqlFile of sqlFiles ) { + if ( ! sqlFile.endsWith( '.sql' ) ) { + continue; + } + + const sqlPath = path.join( sqlDir, sqlFile ); + const sqlContent = await fsPromises.readFile( sqlPath, 'utf-8' ); + + // Copy SQL file to WordPress directory so WP-CLI can access it + const tempSqlFileName = `.temp-import-${ Date.now() }.sql`; + const tempSqlPath = path.join( site.path, tempSqlFileName ); + await fsPromises.writeFile( tempSqlPath, sqlContent ); + + // Import via WP-CLI db import command + try { + const [ response, exitPhp ] = await runWpCliCommand( site.path, site.phpVersion, [ + 'db', + 'import', + tempSqlFileName, + ] ); + + await response.text; // Ensure command completes + exitPhp(); + } catch ( error ) { + console.warn( sprintf( __( 'Warning: Failed to import %s' ), sqlFile ) ); + } finally { + // Cleanup temp SQL file + await fsPromises.unlink( tempSqlPath ).catch( () => {} ); + } + } + } + + logger.reportProgress( __( 'Import complete' ) ); + } finally { + // Cleanup extraction directory + if ( fs.existsSync( extractDir ) ) { + await fsPromises.rm( extractDir, { recursive: true, force: true } ).catch( () => {} ); + } + } +} + +async function copyDirectory( src: string, dest: string ): Promise< void > { + await fsPromises.mkdir( dest, { recursive: true } ); + + const entries = await fsPromises.readdir( src, { withFileTypes: true } ); + + for ( const entry of entries ) { + const srcPath = path.join( src, entry.name ); + const destPath = path.join( dest, entry.name ); + + if ( entry.isDirectory() ) { + await copyDirectory( srcPath, destPath ); + } else { + await fsPromises.copyFile( srcPath, destPath ); + } + } +} From 0864e38e1b0498e0989ed0916bf85aa816e1a4cb Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:05:25 -0800 Subject: [PATCH 09/20] Update agent instructions with comprehensive Studio CLI reference Enhances the default AGENTS.md template with complete guidelines for AI assistants working in WordPress Studio sites. Key additions: - SQLite database documentation (not MySQL/MariaDB) - Complete sync command reference (pull, push, connect, disconnect) - Auto-detect site ID information for commands - Telex CLI documentation - Best practices for AI agents - Common workflow examples - Environment details and architecture notes This provides AI assistants with the context they need to effectively work with Studio sites, including database operations, WP-CLI access, and WordPress.com sync capabilities. --- src/modules/agent-instructions/constants.ts | 158 +++++++++++++++----- 1 file changed, 118 insertions(+), 40 deletions(-) diff --git a/src/modules/agent-instructions/constants.ts b/src/modules/agent-instructions/constants.ts index 28353cc780..e707147828 100644 --- a/src/modules/agent-instructions/constants.ts +++ b/src/modules/agent-instructions/constants.ts @@ -38,44 +38,122 @@ export const INSTRUCTION_FILE_TYPES: InstructionFileType[] = [ 'claude', 'agents */ export const AGENTS_FILE_NAME = 'AGENTS.md'; -export const DEFAULT_AGENT_INSTRUCTIONS = `# Studio Agent Instructions - -This project is managed with WordPress Studio. - -## Studio CLI -- Command: \`studio\` -- Use \`studio --help\` to see all commands. -- The CLI uses the current directory as the site path (override with \`--path\`). -- Common examples: - - \`studio site list\` - - \`studio site status --path ""\` - - \`studio site start --path ""\` - - \`studio site stop --path ""\` - - \`studio site stop --all\` - - \`studio preview list\` - - \`studio preview create --path ""\` - - \`studio preview update --path ""\` - - \`studio preview delete \` - - \`studio wp --path "" \` - - \`studio chat\` - Interactive chat with WordPress AI - - \`studio chat "your message"\` - Single-shot chat message - -## Telex CLI -- Command: \`telex\` -- Telex is a sandboxed AI environment for building WordPress assets. -- Use \`telex --help\` to see all commands. - -### Generate -- \`telex gen block \` - Generate a new block -- \`telex gen plugin \` - Generate a new plugin -- \`telex gen theme \` - Generate a new theme - -### Edit -- \`telex edit block \` - Edit an existing block conversationally -- \`telex edit plugin \` - Edit an existing plugin conversationally -- \`telex edit theme \` - Edit an existing theme conversationally - -### Chat -- \`telex chat\` - Start a conversational session for building and editing -- Telex chat understands your project context and can make changes interactively. +export const DEFAULT_AGENT_INSTRUCTIONS = `# WordPress Studio Site + +This is a local WordPress development site managed by WordPress Studio. + +## Environment Details + +**Database**: This site uses **SQLite** (not MySQL/MariaDB) +- Database file: \`wp-content/database/.ht.sqlite\` +- Managed by the SQLite Database Integration plugin +- Standard WordPress database operations work normally +- Use WP-CLI commands for database operations (\`studio wp db\` commands) + +**Server**: WordPress Playground (PHP WebAssembly) +- Runs entirely in Studio's environment +- Supports standard WordPress functionality +- Full WP-CLI integration available + +## Studio CLI Commands + +**Site Management** (when run from this directory, site ID is auto-detected): +\`\`\`bash +studio site list # List all Studio sites +studio site start # Start this site +studio site stop # Stop this site +studio site status # Check site status +\`\`\` + +**WP-CLI Access** (full WordPress CLI): +\`\`\`bash +studio wp # Run any WP-CLI command +studio wp plugin list # List plugins +studio wp theme list # List themes +studio wp db query "SELECT..." # Run SQL queries +studio wp export # Export site content +\`\`\` + +**WordPress.com Sync** (requires authentication): +\`\`\`bash +studio auth login # Authenticate with WordPress.com +studio sync list # Show connected sites +studio sync connect # Connect this site to WordPress.com +studio sync status # Check sync status +studio sync pull # Pull changes from WordPress.com +studio sync push # Push changes to WordPress.com +studio sync disconnect # Disconnect from WordPress.com +\`\`\` + +**Preview Sites** (temporary WordPress.com staging): +\`\`\`bash +studio preview create # Create preview from this site +studio preview list # List your preview sites +studio preview update # Update existing preview +studio preview delete # Delete preview site +\`\`\` + +**AI Assistance**: +\`\`\`bash +studio chat # Interactive WordPress AI chat +studio chat "your question" # Single question to AI +\`\`\` + +## Telex CLI (AI-Powered WordPress Development) + +Telex is an AI environment for generating and editing WordPress themes, plugins, and blocks. + +**Generate**: +\`\`\`bash +telex gen block # Generate a new block +telex gen plugin # Generate a new plugin +telex gen theme # Generate a new theme +\`\`\` + +**Edit** (conversational editing): +\`\`\`bash +telex edit block # Edit a block interactively +telex edit plugin # Edit a plugin interactively +telex edit theme # Edit a theme interactively +\`\`\` + +**Chat**: +\`\`\`bash +telex chat # Conversational WordPress development +\`\`\` + +## Best Practices for AI Agents + +1. **Always verify the database type**: This site uses SQLite, not MySQL +2. **Use WP-CLI via Studio**: Run \`studio wp\` commands instead of direct database access +3. **Check site status**: Run \`studio site status\` before making changes +4. **Test changes locally**: Start the site with \`studio site start\` to verify +5. **Use sync carefully**: Always pull before push when syncing with WordPress.com +6. **Leverage preview sites**: Use \`studio preview create\` for safe testing +7. **Auto-detect site**: Most commands work without specifying site ID when run from site directory + +## Common Workflows + +**Local Development**: +\`\`\`bash +studio site start # Start development server +studio wp plugin activate # Activate your plugin +# Make changes to files... +studio site stop # Stop when done +\`\`\` + +**Deploy to WordPress.com**: +\`\`\`bash +studio sync connect # First time: connect to remote +studio sync pull # Get latest from remote +# Make local changes... +studio sync push # Deploy changes +\`\`\` + +**Quick Testing**: +\`\`\`bash +studio preview create # Create temporary test site +# Test at provided URL... +studio preview delete # Clean up when done +\`\`\` `; From aba8d84467a8a1df5a09e5651f6a12ba662db1ee Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:14:55 -0800 Subject: [PATCH 10/20] Add timeout and error handling for ACP agent initialization - Add 30s timeout to initialize() and newSession() calls - Wrap initialization in try-catch with detailed error logging - Prevents indefinite hanging when agents don't respond --- src/modules/acp/lib/acp-process-manager.ts | 153 +++++++++++++++++---- 1 file changed, 124 insertions(+), 29 deletions(-) diff --git a/src/modules/acp/lib/acp-process-manager.ts b/src/modules/acp/lib/acp-process-manager.ts index 2a0b188a49..1e5c962ca9 100644 --- a/src/modules/acp/lib/acp-process-manager.ts +++ b/src/modules/acp/lib/acp-process-manager.ts @@ -78,6 +78,7 @@ interface AcpTerminal { interface RunningProcess { agentId: string; siteId: string; + workingDirectory: string; process: ChildProcess | pty.IPty; connection: ClientSideConnection; session: AcpSession; @@ -255,6 +256,7 @@ export class AcpProcessManager extends EventEmitter { const runningProcess: RunningProcess = { agentId, siteId, + workingDirectory, process: proc, connection, session, @@ -278,6 +280,19 @@ export class AcpProcessManager extends EventEmitter { session.state = 'ready'; session.acpSessionId = newSessionResult.sessionId; + // Store model info if available + if ( newSessionResult.models ) { + session.models = { + availableModels: newSessionResult.models.availableModels.map( ( m ) => ( { + modelId: m.modelId, + name: m.name, + description: m.description ?? undefined, + } ) ), + currentModelId: newSessionResult.models.currentModelId, + }; + console.log( `ACP [${ sessionId }] Models available:`, session.models.availableModels.map( ( m ) => m.name ) ); + } + this.emit( 'session_ready', sessionId, newSessionResult ); return session; @@ -374,6 +389,14 @@ export class AcpProcessManager extends EventEmitter { const outputByteLimit = Number( params.outputByteLimit ?? 1024 * 1024 ); // Default 1MB console.log( `ACP [${ sessionId }] createTerminal: ${ params.command } ${ ( params.args ?? [] ).join( ' ' ) }` ); + console.log( `ACP [${ sessionId }] createTerminal params.cwd: ${ params.cwd ?? '(not set, will use session working directory)' }` ); + console.log( `ACP [${ sessionId }] createTerminal session workingDirectory: ${ runningProcess.workingDirectory }` ); + console.log( `ACP [${ sessionId }] createTerminal process.cwd(): ${ process.cwd() }` ); + + // Use the session's working directory as fallback instead of process.cwd() + // This ensures commands run in the site directory, not the Studio app directory + const effectiveCwd = params.cwd ?? runningProcess.workingDirectory; + console.log( `ACP [${ sessionId }] createTerminal effective cwd: ${ effectiveCwd }` ); // Build environment const env: Record< string, string > = { ...process.env } as Record< string, string >; @@ -401,9 +424,9 @@ export class AcpProcessManager extends EventEmitter { exitResolve, }; - // Spawn the process + // Spawn the process with the effective working directory const proc = spawn( params.command, params.args ?? [], { - cwd: params.cwd ?? process.cwd(), + cwd: effectiveCwd, env, stdio: [ 'pipe', 'pipe', 'pipe' ], shell: true, @@ -618,6 +641,18 @@ export class AcpProcessManager extends EventEmitter { } } + /** + * Helper to add timeout to a promise. + */ + private withTimeout< T >( promise: Promise< T >, timeoutMs: number, operation: string ): Promise< T > { + return Promise.race( [ + promise, + new Promise< T >( ( _, reject ) => { + setTimeout( () => reject( new Error( `${ operation } timed out after ${ timeoutMs }ms` ) ), timeoutMs ); + } ), + ] ); + } + /** * Initialize the ACP connection and create a session. */ @@ -632,33 +667,48 @@ export class AcpProcessManager extends EventEmitter { const { connection } = runningProcess; - // Step 1: Initialize the connection - console.log( `ACP [${ sessionId }] Calling initialize()...` ); - const initResult = await connection.initialize( { - protocolVersion: 1, - clientInfo: { - name: 'WordPress Studio', - version: '1.0.0', - }, - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, - }, - terminal: true, - }, - } ); - console.log( `ACP [${ sessionId }] initialize() complete:`, initResult ); - - // Step 2: Create the session - console.log( `ACP [${ sessionId }] Calling newSession() with cwd: ${ workingDirectory }` ); - const result = await connection.newSession( { - cwd: workingDirectory, - mcpServers: [], - } ); - console.log( `ACP [${ sessionId }] newSession() complete:`, result ); - - return result; + try { + // Step 1: Initialize the connection + console.log( `ACP [${ sessionId }] Calling initialize()...` ); + const initResult = await this.withTimeout( + connection.initialize( { + protocolVersion: 1, + clientInfo: { + name: 'WordPress Studio', + version: '1.0.0', + }, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + } ), + 30000, + 'initialize()' + ); + console.log( `ACP [${ sessionId }] initialize() complete:`, initResult ); + + // Step 2: Create the session + console.log( `ACP [${ sessionId }] Calling newSession() with cwd: ${ workingDirectory }` ); + console.log( `ACP [${ sessionId }] Current process.cwd() before newSession: ${ process.cwd() }` ); + const result = await this.withTimeout( + connection.newSession( { + cwd: workingDirectory, + mcpServers: [], + } ), + 30000, + 'newSession()' + ); + console.log( `ACP [${ sessionId }] newSession() complete:`, result ); + console.log( `ACP [${ sessionId }] Current process.cwd() after newSession: ${ process.cwd() }` ); + + return result; + } catch ( error ) { + console.error( `ACP [${ sessionId }] Initialization failed:`, error ); + throw error; + } } /** @@ -712,6 +762,51 @@ export class AcpProcessManager extends EventEmitter { } ); } + /** + * Set the model for a session. + * Only works if the agent supports model selection. + */ + async setModel( sessionId: string, modelId: string ): Promise< void > { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + throw new Error( `Session not found: ${ sessionId }` ); + } + + const { connection, session } = runningProcess; + + if ( ! session.models ) { + throw new Error( 'Agent does not support model selection' ); + } + + console.log( `ACP [${ sessionId }] Setting model to: ${ modelId }` ); + + // Use the unstable_setSessionModel method from the SDK + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await ( connection as any ).unstable_setSessionModel( { + sessionId: session.acpSessionId!, + modelId, + } ); + + // Update the session's current model + session.models.currentModelId = modelId; + + console.log( `ACP [${ sessionId }] Model set to: ${ modelId }`, result ); + + this.emit( 'model_changed', sessionId, modelId ); + } + + /** + * Get the current model state for a session. + */ + getSessionModels( sessionId: string ): { availableModels: Array< { modelId: string; name: string; description?: string } >; currentModelId: string } | null { + const runningProcess = this.processes.get( sessionId ); + if ( ! runningProcess ) { + return null; + } + + return runningProcess.session.models ?? null; + } + /** * Close a session. */ From 1661a5a5daffc46e71de7fb9c713c9687759c1e7 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:38:30 -0800 Subject: [PATCH 11/20] Fix Opencode agent to use 'acp' subcommand - Add AGENT_ARGS map for agent-specific arguments - Configure opencode to use 'acp' subcommand to start ACP server - Fixes initialization timeout issue --- src/modules/acp/lib/agent-detection.ts | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/modules/acp/lib/agent-detection.ts b/src/modules/acp/lib/agent-detection.ts index 10de7621f3..a83e5441fd 100644 --- a/src/modules/acp/lib/agent-detection.ts +++ b/src/modules/acp/lib/agent-detection.ts @@ -8,6 +8,7 @@ import { exec } from 'child_process'; import fs from 'fs'; import nodePath from 'path'; import { promisify } from 'util'; +import { getResourcesPath } from 'src/storage/paths'; import { BUILTIN_AGENTS } from '../config/agents'; import { getAcpRegistry, getAgentCommand, type RegistryAgent } from './acp-registry'; import type { AgentConfig, AgentStatus } from '../types'; @@ -52,6 +53,20 @@ function getSupplementalPaths( home: string ): string[] { ]; } +/** + * Get the Studio bin directory path. + * This contains stub scripts like the telex CLI stub. + */ +function getStudioBinPath(): string { + try { + const resourcesPath = getResourcesPath(); + return nodePath.join( resourcesPath, 'bin' ); + } catch { + // In child processes or tests, fall back to the development bin directory + return nodePath.join( __dirname, '..', '..', '..', '..', 'bin' ); + } +} + /** * Get the augmented PATH for agent detection. * On macOS/Linux, GUI apps don't inherit shell PATH, so we add common paths. @@ -59,9 +74,11 @@ function getSupplementalPaths( home: string ): string[] { function getAugmentedPath(): string { const basePath = process.env.PATH ?? ''; const home = process.env.HOME ?? ''; + const studioBinPath = getStudioBinPath(); if ( process.platform === 'darwin' ) { const additionalPaths = [ + studioBinPath, // Studio bin directory with stub scripts (highest priority) '/usr/local/bin', '/opt/homebrew/bin', '/opt/local/bin', @@ -80,6 +97,7 @@ function getAugmentedPath(): string { if ( process.platform === 'linux' ) { const additionalPaths = [ + studioBinPath, // Studio bin directory with stub scripts (highest priority) '/usr/local/bin', `${ home }/.local/bin`, `${ home }/.npm-global/bin`, @@ -94,6 +112,11 @@ function getAugmentedPath(): string { return `${ additionalPaths.join( ':' ) }:${ basePath }`; } + if ( process.platform === 'win32' ) { + // Windows uses semicolon as PATH separator + return `${ studioBinPath };${ basePath }`; + } + return basePath; } @@ -177,6 +200,14 @@ const ALTERNATIVE_BINARIES: Record< string, string[] > = { opencode: [ 'opencode' ], }; +/** + * Required arguments for specific agents. + * Some agents need subcommands to start the ACP server. + */ +const AGENT_ARGS: Record< string, string[] > = { + opencode: [ 'acp' ], +}; + /** * NPX package fallbacks for agents where the registry only lists binary distribution * but an npm package also exists. @@ -210,7 +241,7 @@ async function detectRegistryAgent( agent: RegistryAgent ): Promise< AgentConfig // Use the local binary instead of npx resolvedCommandInfo = { command: cmd, - args: [], + args: AGENT_ARGS[ agent.id ] ?? [], env: commandInfo?.env, }; break; From ffab786af6ad76130a5a927702057c73f33922c8 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:14:03 -0800 Subject: [PATCH 12/20] Add experimental Hot Reload setting for sites - Add enableHotReload field to site details - Add toggle in Edit Site modal with experimental label - Display current status in site settings - Includes helpful description about automatic reloading --- src/components/content-tab-settings.tsx | 13 +++++++++ src/ipc-types.d.ts | 2 ++ .../site-settings/edit-site-details.tsx | 27 ++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/components/content-tab-settings.tsx b/src/components/content-tab-settings.tsx index cabc2121e1..4dbdf1248d 100644 --- a/src/components/content-tab-settings.tsx +++ b/src/components/content-tab-settings.tsx @@ -130,6 +130,19 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) { selectedSite.enableXdebug ? __( 'Enabled' ) : __( 'Disabled' ) } ) } + +
+ { selectedSite.enableHotReload ? __( 'Enabled' ) : __( 'Disabled' ) } + { __( '(Experimental)' ) } +
+
+ + { __( + 'Automatically reload the site when files change. Instantly see updates to CSS/JS, and smart reload for PHP changes.' + ) } + +
+
diff --git a/src/ipc-types.d.ts b/src/ipc-types.d.ts index 0cfc231cbb..5143267953 100644 --- a/src/ipc-types.d.ts +++ b/src/ipc-types.d.ts @@ -31,6 +31,7 @@ interface StoppedSiteDetails { autoStart?: boolean; latestCliPid?: number; enableXdebug?: boolean; + enableHotReload?: boolean; } interface StartedSiteDetails extends StoppedSiteDetails { @@ -92,6 +93,7 @@ interface AppGlobals extends FeatureFlags { platform: NodeJS.Platform; appName: string; appVersion: string; + siteSpecUrl?: string; arm64Translation: boolean; } diff --git a/src/modules/site-settings/edit-site-details.tsx b/src/modules/site-settings/edit-site-details.tsx index 061f5c09c0..95b251f646 100644 --- a/src/modules/site-settings/edit-site-details.tsx +++ b/src/modules/site-settings/edit-site-details.tsx @@ -39,6 +39,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const [ needsRestart, setNeedsRestart ] = useState( false ); const [ enableXdebug, setEnableXdebug ] = useState( selectedSite?.enableXdebug ?? false ); const [ xdebugEnabledSite, setXdebugEnabledSite ] = useState< SiteDetails | null >( null ); + const [ enableHotReload, setEnableHotReload ] = useState( selectedSite?.enableHotReload ?? false ); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); const closeModal = useCallback( () => { @@ -103,7 +104,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = Boolean( selectedSite.customDomain ) === useCustomDomain && usedCustomDomain === customDomain && !! selectedSite.enableHttps === ( !! usedCustomDomain && enableHttps ) && - !! selectedSite.enableXdebug === enableXdebug; + !! selectedSite.enableXdebug === enableXdebug && + !! selectedSite.enableHotReload === enableHotReload; const hasValidationErrors = ! selectedSite || ! siteName.trim() || ( useCustomDomain && !! customDomainError ); @@ -120,6 +122,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = setErrorUpdatingWpVersion( null ); setEnableHttps( selectedSite.enableHttps ?? false ); setEnableXdebug( selectedSite.enableXdebug ?? false ); + setEnableHotReload( selectedSite.enableHotReload ?? false ); }, [ selectedSite, getEffectiveWpVersion ] ); const onSiteEdit = async ( event: FormEvent ) => { @@ -166,6 +169,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = customDomain: usedCustomDomain, enableHttps: !! usedCustomDomain && enableHttps, enableXdebug, + enableHotReload, }, hasWpVersionChanged ? selectedWpVersion : undefined ); @@ -370,6 +374,27 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ) } + +
+
+ setEnableHotReload( e.target.checked ) } + disabled={ isEditingSite } + /> + +
+
+ { __( + 'Automatically reload the site when files change. Instantly see updates to CSS/JS, and smart reload for PHP changes.' + ) } +
+
From 6a2377c2000a46bcfe59c97a68e865f5f4c59765 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:16:27 -0800 Subject: [PATCH 13/20] Fix Hot Reload setting persistence - Save enableHotReload directly to storage (not a CLI option) - Update server details to reflect the change - Now properly persists when toggled in Edit Site modal --- src/ipc-handlers.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index e6fca9d371..9d49d5c7ff 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -122,6 +122,12 @@ export { showUserSettings, } from 'src/modules/user-settings/lib/ipc-handlers'; +export { + getAgentInstructionsStatus, + installAgentInstructions, + installAllAgentInstructions, +} from 'src/modules/agent-instructions/lib/ipc-handlers'; + function mergeSiteDetailsWithRunningDetails( sites: SiteDetails[] ): SiteDetails[] { return sites.map( ( site ) => { const server = SiteServer.get( site.id ); @@ -329,6 +335,9 @@ export async function updateSite( const hasCliChanges = Object.keys( options ).length > 2; + // Track non-CLI changes that need direct storage update + const hasHotReloadChange = updatedSite.enableHotReload !== currentSite.enableHotReload; + if ( hasCliChanges ) { await editSiteViaCli( options ); @@ -357,6 +366,22 @@ export async function updateSite( } } } + + // Update Studio-only settings directly in storage + if ( hasHotReloadChange ) { + const userData = await loadUserData(); + const siteIndex = userData.sites.findIndex( ( s ) => s.id === updatedSite.id ); + if ( siteIndex !== -1 ) { + userData.sites[ siteIndex ].enableHotReload = updatedSite.enableHotReload; + await saveUserData( userData ); + + // Update server details to reflect the change + server.details = { + ...server.details, + enableHotReload: updatedSite.enableHotReload, + }; + } + } } export async function startServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { @@ -637,6 +662,7 @@ export function getAppGlobals(): AppGlobals { platform: process.platform, appName: app.name, appVersion: app.getVersion(), + siteSpecUrl: process.env.SITE_SPEC_URL, arm64Translation: app.runningUnderARM64Translation, ...buildFeatureFlags(), }; @@ -1400,3 +1426,37 @@ export async function setWindowControlVisibility( event: IpcMainInvokeEvent, vis parentWindow.setWindowButtonVisibility( visible ); } } + +// Agent server IPC handlers +export { + getAgentServerPort, + getAgentStatus, + configureAgentApiKey, + removeAgentApiKey, +} from 'src/modules/ai-agent/lib/ipc-handlers'; + +// ACP (Agent Client Protocol) handlers +export { + getAvailableAgents, + detectAgents, + getSelectedAgentId, + setSelectedAgentId, + createAcpSession, + sendAcpPrompt, + sendAcpApproval, + closeAcpSession, + getAcpSession, + getAcpSessionsForSite, + closeAllAcpSessions, + getAcpSessionModels, + setAcpSessionModel, +} from 'src/modules/acp/lib/ipc-handlers'; + +// AgentSkills handlers +export { + getSiteSkills, + installSkill, + removeSkill, + listAvailableSkills, + getSkillsPromptXml, +} from 'src/modules/agent-skills/lib/ipc-handlers'; From 64777c908f392585143d84ee5337680d92fb3218 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:17:59 -0800 Subject: [PATCH 14/20] Add CLI support for Hot Reload and update agent instructions - Add --hot-reload flag to 'studio site set' command - Enable/disable hot reload from command line - Update agent instructions template with hot reload info - Document in site management commands and best practices --- cli/commands/site/set.ts | 19 +++++++++++++++---- src/modules/agent-instructions/constants.ts | 6 ++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cli/commands/site/set.ts b/cli/commands/site/set.ts index 245d789210..8d6ec67a45 100644 --- a/cli/commands/site/set.ts +++ b/cli/commands/site/set.ts @@ -45,10 +45,11 @@ export interface SetCommandOptions { php?: string; wp?: string; xdebug?: boolean; + hotReload?: boolean; } export async function runCommand( sitePath: string, options: SetCommandOptions ): Promise< void > { - const { name, domain, https, php, wp, xdebug } = options; + const { name, domain, https, php, wp, xdebug, hotReload } = options; if ( name === undefined && @@ -56,10 +57,11 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) https === undefined && php === undefined && wp === undefined && - xdebug === undefined + xdebug === undefined && + hotReload === undefined ) { throw new LoggerError( - __( 'At least one option (--name, --domain, --https, --php, --wp, --xdebug) is required.' ) + __( 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --hot-reload) is required.' ) ); } @@ -119,9 +121,10 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) const phpChanged = php !== undefined && php !== site.phpVersion; const wpChanged = wp !== undefined; const xdebugChanged = xdebug !== undefined && xdebug !== site.enableXdebug; + const hotReloadChanged = hotReload !== undefined && hotReload !== site.enableHotReload; const hasChanges = - nameChanged || domainChanged || httpsChanged || phpChanged || wpChanged || xdebugChanged; + nameChanged || domainChanged || httpsChanged || phpChanged || wpChanged || xdebugChanged || hotReloadChanged; if ( ! hasChanges ) { throw new LoggerError( __( 'No changes to apply. The site already has the specified settings.' ) @@ -160,6 +163,9 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) if ( xdebugChanged ) { foundSite.enableXdebug = xdebug; } + if ( hotReloadChanged ) { + foundSite.enableHotReload = hotReload; + } await saveAppdata( appdata ); site = foundSite; @@ -296,6 +302,10 @@ export const registerCommand = ( yargs: StudioArgv ) => { type: 'boolean', description: __( 'Enable Xdebug (beta feature)' ), hidden: ! showXdebug, + } ) + .option( 'hot-reload', { + type: 'boolean', + description: __( 'Enable Hot Reload (experimental - auto-reload on file changes)' ), } ); }, handler: async ( argv ) => { @@ -307,6 +317,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { php: argv.php, wp: argv.wp, xdebug: argv.xdebug, + hotReload: argv[ 'hot-reload' ], } ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/src/modules/agent-instructions/constants.ts b/src/modules/agent-instructions/constants.ts index e707147828..363cc7a369 100644 --- a/src/modules/agent-instructions/constants.ts +++ b/src/modules/agent-instructions/constants.ts @@ -63,6 +63,8 @@ studio site list # List all Studio sites studio site start # Start this site studio site stop # Stop this site studio site status # Check site status +studio site set --hot-reload=true # Enable hot reload (experimental) +studio site set --hot-reload=false # Disable hot reload \`\`\` **WP-CLI Access** (full WordPress CLI): @@ -131,6 +133,10 @@ telex chat # Conversational WordPress development 5. **Use sync carefully**: Always pull before push when syncing with WordPress.com 6. **Leverage preview sites**: Use \`studio preview create\` for safe testing 7. **Auto-detect site**: Most commands work without specifying site ID when run from site directory +8. **Hot Reload (Experimental)**: Enable with \`studio site set --hot-reload=true\` to automatically see file changes: + - CSS/JS changes inject instantly (no page reload) + - PHP changes trigger smart page reload + - Ideal for iterative AI development workflows ## Common Workflows From 6bc9cf84bed11f3cb1e8c2e145401372fff97773 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:22:43 -0800 Subject: [PATCH 15/20] Add 'studio site open' command for auto-login browser access - New command: studio site open [path] - Automatically starts site if not running - Auto-login enabled by default (--no-login to skip) - Navigate to specific paths: studio site open /wp-admin - Perfect for AI agents to build + test in one workflow - Updated agent instructions with examples --- cli/commands/site/open.ts | 86 ++++ cli/index.ts | 2 + package-lock.json | 506 +++++++++++++++++++- package.json | 7 +- src/modules/agent-instructions/constants.ts | 13 + 5 files changed, 605 insertions(+), 9 deletions(-) create mode 100644 cli/commands/site/open.ts diff --git a/cli/commands/site/open.ts b/cli/commands/site/open.ts new file mode 100644 index 0000000000..1ba3289943 --- /dev/null +++ b/cli/commands/site/open.ts @@ -0,0 +1,86 @@ +import { __ } from '@wordpress/i18n'; +import { getSiteByFolder } from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { isServerRunning, startWordPressServer } from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; +import open from 'open'; + +const logger = new Logger(); + +export async function runCommand( + sitePath: string, + urlPath: string = '', + autoLogin: boolean = true +): Promise< void > { + try { + logger.reportStart( undefined, __( 'Loading site…' ) ); + const site = await getSiteByFolder( sitePath ); + logger.reportSuccess( __( 'Site loaded' ) ); + + logger.reportStart( undefined, __( 'Checking site status…' ) ); + await connect(); + const isRunning = await isServerRunning( site.id ); + + if ( ! isRunning ) { + logger.reportStart( undefined, __( 'Starting WordPress server…' ) ); + await startWordPressServer( site, logger ); + logger.reportSuccess( __( 'WordPress server started' ) ); + } else { + logger.reportSuccess( __( 'Site is running' ) ); + } + + // Construct URL + const protocol = site.customDomain && site.enableHttps ? 'https' : 'http'; + const domain = site.customDomain || `localhost:${ site.port }`; + let url = `${ protocol }://${ domain }${ urlPath }`; + + // Add auto-login if enabled + if ( autoLogin ) { + const autoLoginUrl = `${ protocol }://${ domain }/studio-auto-login?redirect_to=${ encodeURIComponent( + url + ) }`; + url = autoLoginUrl; + } + + logger.reportStart( undefined, __( 'Opening in browser…' ) ); + await open( url ); + logger.reportSuccess( __( 'Browser opened' ) ); + } catch ( error ) { + throw new LoggerError( __( 'Failed to open site' ), error ); + } finally { + await disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'open [url-path]', + describe: __( 'Open site in browser (starts site if needed)' ), + builder: ( yargs ) => { + return yargs + .positional( 'url-path', { + describe: __( 'Path to open (e.g., /wp-admin, /my-page)' ), + type: 'string', + default: '', + } ) + .option( 'no-login', { + describe: __( 'Skip auto-login' ), + type: 'boolean', + default: false, + } ); + }, + handler: async ( argv ) => { + try { + await runCommand( argv.path, argv[ 'url-path' ] as string || '', ! argv[ 'no-login' ] ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to open site' ), error ) ); + } + process.exit( 1 ); + } + }, + } ); +}; diff --git a/cli/index.ts b/cli/index.ts index f90c164903..97318a6b14 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -15,6 +15,7 @@ import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/u import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/create'; import { registerCommand as registerSiteDeleteCommand } from 'cli/commands/site/delete'; import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; +import { registerCommand as registerSiteOpenCommand } from 'cli/commands/site/open'; import { registerCommand as registerSiteSetCommand } from 'cli/commands/site/set'; import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start'; import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status'; @@ -102,6 +103,7 @@ async function main() { registerSiteListCommand( sitesYargs ); registerSiteStartCommand( sitesYargs ); registerSiteStopCommand( sitesYargs ); + registerSiteOpenCommand( sitesYargs ); registerSiteDeleteCommand( sitesYargs ); registerSiteSetCommand( sitesYargs ); sitesYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) ); diff --git a/package-lock.json b/package-lock.json index 79e5c83ff7..03615a8801 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { + "@agentclientprotocol/sdk": "^0.13.1", + "@anthropic-ai/sdk": "^0.71.2", "@automattic/generate-password": "^0.1.0", "@automattic/interpolate-components": "^1.2.1", "@formatjs/intl-locale": "^3.4.5", @@ -42,6 +44,8 @@ "http-proxy": "^1.18.1", "lockfile": "^1.0.4", "node-forge": "^1.3.3", + "node-pty": "^1.1.0", + "open": "^9.1.0", "ora": "^8.2.0", "react-markdown": "^9.0.1", "react-redux": "^9.2.0", @@ -78,6 +82,7 @@ "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", "@types/archiver": "^6.0.4", + "@types/express": "^5.0.6", "@types/follow-redirects": "^1.14.4", "@types/fs-extra": "^11.0.4", "@types/http-proxy": "^1.17.17", @@ -145,6 +150,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.13.1.tgz", + "integrity": "sha512-6byvu+F/xc96GBkdAx4hq6/tB3vT63DSBO4i3gYCz8nuyZMerVFna2Gkhm8EHNpZX0J9DjUxzZCW+rnHXUg0FA==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -157,6 +171,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.71.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", + "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@ariakit/core": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.15.tgz", @@ -9385,6 +9419,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/btoa-lite": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz", @@ -9456,6 +9501,31 @@ "@types/estree": "*" } }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/follow-redirects": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@types/follow-redirects/-/follow-redirects-1.14.4.tgz", @@ -9512,6 +9582,13 @@ "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "dev": true }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-proxy": { "version": "1.17.17", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", @@ -9746,6 +9823,20 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", @@ -9790,6 +9881,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/shell-quote": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz", @@ -12437,6 +12549,15 @@ "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", "dev": true }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -12542,6 +12663,18 @@ "stream-buffers": "~2.2.0" } }, + "node_modules/bplist-parser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", + "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.44" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -12675,6 +12808,21 @@ "integrity": "sha512-8KPx+JfZWi0K8L5sycIOA6/ZFZbaFKXDeUIXaqwUnhed1Ge1cB0wyq+bNDjKnL9AR2Uj3m/khkF6CDolsyMitA==", "license": "MIT" }, + "node_modules/bundle-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", + "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", + "license": "MIT", + "dependencies": { + "run-applescript": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -14039,6 +14187,162 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", + "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", + "license": "MIT", + "dependencies": { + "bundle-name": "^3.0.0", + "default-browser-id": "^3.0.0", + "execa": "^7.1.1", + "titleize": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", + "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", + "license": "MIT", + "dependencies": { + "bplist-parser": "^0.2.0", + "untildify": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/default-browser/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/default-browser/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -14079,6 +14383,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -17447,7 +17763,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -18091,7 +18406,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, "bin": { "is-docker": "cli.js" }, @@ -18183,6 +18497,39 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -18473,7 +18820,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -20070,6 +20416,19 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -21238,8 +21597,7 @@ "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" }, "node_modules/merge2": { "version": "1.4.1", @@ -21840,7 +22198,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "engines": { "node": ">=6" } @@ -22219,6 +22576,12 @@ "node": ">= 10.13" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -22290,6 +22653,16 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -22550,7 +22923,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -22561,6 +22933,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", + "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", + "license": "MIT", + "dependencies": { + "default-browser": "^4.0.0", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -24811,6 +25201,80 @@ "dev": true, "license": "MIT" }, + "node_modules/run-applescript": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", + "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", + "license": "MIT", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/run-applescript/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -25899,7 +26363,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -26511,6 +26974,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/titleize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", + "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -26701,6 +27176,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -27365,6 +27846,15 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index c72b84a9f0..406008beb7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prestart": "npm run cli:build", "start": "electron-vite dev --outDir=dist --watch", "start-wayland": "npm run prestart && electron-forge start -- --enable-features=UseOzonePlatform --ozone-platform=wayland .", - "postinstall": "patch-package && ts-node ./scripts/download-wp-server-files.ts && node ./scripts/download-available-site-translations.mjs && npx @electron/rebuild -o fs-ext", + "postinstall": "patch-package && ts-node ./scripts/download-wp-server-files.ts && node ./scripts/download-available-site-translations.mjs && npx @electron/rebuild -o fs-ext,node-pty", "package": "electron-vite build --outDir=dist && electron-forge package", "make": "electron-vite build --outDir=dist && electron-forge make", "make:windows-x64": "electron-vite build --outDir=dist && electron-forge make --arch=x64 --platform=win32", @@ -57,6 +57,7 @@ "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", "@types/archiver": "^6.0.4", + "@types/express": "^5.0.6", "@types/follow-redirects": "^1.14.4", "@types/fs-extra": "^11.0.4", "@types/http-proxy": "^1.17.17", @@ -110,6 +111,8 @@ "web-streams-polyfill": "^4.2.0" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.13.1", + "@anthropic-ai/sdk": "^0.71.2", "@automattic/generate-password": "^0.1.0", "@automattic/interpolate-components": "^1.2.1", "@formatjs/intl-locale": "^3.4.5", @@ -142,6 +145,8 @@ "http-proxy": "^1.18.1", "lockfile": "^1.0.4", "node-forge": "^1.3.3", + "node-pty": "^1.1.0", + "open": "^9.1.0", "ora": "^8.2.0", "react-markdown": "^9.0.1", "react-redux": "^9.2.0", diff --git a/src/modules/agent-instructions/constants.ts b/src/modules/agent-instructions/constants.ts index 363cc7a369..13d7e371b7 100644 --- a/src/modules/agent-instructions/constants.ts +++ b/src/modules/agent-instructions/constants.ts @@ -63,6 +63,10 @@ studio site list # List all Studio sites studio site start # Start this site studio site stop # Stop this site studio site status # Check site status +studio site open # Open site in browser (auto-login, starts if needed) +studio site open /wp-admin # Open wp-admin +studio site open /my-page # Open specific page +studio site open --no-login # Open without auto-login studio site set --hot-reload=true # Enable hot reload (experimental) studio site set --hot-reload=false # Disable hot reload \`\`\` @@ -144,10 +148,19 @@ telex chat # Conversational WordPress development \`\`\`bash studio site start # Start development server studio wp plugin activate # Activate your plugin +studio site open # Open in browser (auto-login) # Make changes to files... studio site stop # Stop when done \`\`\` +**AI Agent Development Loop**: +\`\`\`bash +studio site set --hot-reload=true # Enable hot reload +# Edit plugin/theme files... +studio site open /my-plugin-page # Open page to see changes +# Hot reload shows changes automatically +\`\`\` + **Deploy to WordPress.com**: \`\`\`bash studio sync connect # First time: connect to remote From 7ea6086df771038e697c19d5351ef007e7d5e54d Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:26:39 -0800 Subject: [PATCH 16/20] Add versioning to agent instruction templates - Add AGENT_INSTRUCTIONS_VERSION constant (YYYYMMDD.revision format) - Embed version in template as HTML comment - Extract and compare versions from installed files - Show 'Update Available' badge for outdated files - Change button text to 'Update' when file is outdated - Show 'Update All' when any files are outdated - Help users stay current with latest Studio CLI commands --- src/components/ai-settings-modal.tsx | 33 +++-- src/modules/agent-instructions/constants.ts | 27 +++- .../agent-instructions/lib/instructions.ts | 127 ++++++++++++++++++ 3 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 src/modules/agent-instructions/lib/instructions.ts diff --git a/src/components/ai-settings-modal.tsx b/src/components/ai-settings-modal.tsx index 2572cfe327..a6097b9183 100644 --- a/src/components/ai-settings-modal.tsx +++ b/src/components/ai-settings-modal.tsx @@ -264,6 +264,7 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { const installedCount = statuses.filter( ( s ) => s.exists ).length; const allInstalled = installedCount === INSTRUCTION_FILE_TYPES.length; + const hasOutdated = statuses.some( ( s ) => s.isOutdated ); return (
@@ -282,9 +283,11 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { > { installingFile === 'all' ? __( 'Installing...' ) - : allInstalled - ? __( 'Reinstall All' ) - : __( 'Install All' ) } + : hasOutdated + ? __( 'Update All' ) + : allInstalled + ? __( 'Reinstall All' ) + : __( 'Install All' ) }
@@ -315,14 +318,26 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { { config.displayName } - { status.exists && ( + { status.exists && ! status.isOutdated && ( { __( 'Installed' ) } ) } + { status.exists && status.isOutdated && ( + + { __( 'Update Available' ) } + + ) } +
+
+ { config.description } + { status.isOutdated && ( + + { __( 'A newer version is available. Reinstall to get the latest commands.' ) } + + ) }
-
{ config.description }
{ status.exists && ( @@ -344,9 +359,11 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { > { isInstalling ? __( 'Installing...' ) - : status.exists - ? __( 'Reinstall' ) - : __( 'Install' ) } + : status.exists && status.isOutdated + ? __( 'Update' ) + : status.exists + ? __( 'Reinstall' ) + : __( 'Install' ) }
diff --git a/src/modules/agent-instructions/constants.ts b/src/modules/agent-instructions/constants.ts index 13d7e371b7..0b518ba31d 100644 --- a/src/modules/agent-instructions/constants.ts +++ b/src/modules/agent-instructions/constants.ts @@ -38,7 +38,32 @@ export const INSTRUCTION_FILE_TYPES: InstructionFileType[] = [ 'claude', 'agents */ export const AGENTS_FILE_NAME = 'AGENTS.md'; -export const DEFAULT_AGENT_INSTRUCTIONS = `# WordPress Studio Site +/** + * Template version - increment when making significant changes to instructions. + * Format: YYYYMMDD.revision (e.g., 20250131.1) + */ +export const AGENT_INSTRUCTIONS_VERSION = '20250131.1'; + +/** + * Extract version from instruction file content. + */ +export function extractInstructionVersion( content: string ): string | null { + const match = content.match( // ); + return match ? match[ 1 ] : null; +} + +/** + * Check if installed instructions are outdated. + */ +export function isInstructionVersionOutdated( installedVersion: string | null ): boolean { + if ( ! installedVersion ) { + return true; // No version = outdated + } + return installedVersion !== AGENT_INSTRUCTIONS_VERSION; +} + +export const DEFAULT_AGENT_INSTRUCTIONS = ` +# WordPress Studio Site This is a local WordPress development site managed by WordPress Studio. diff --git a/src/modules/agent-instructions/lib/instructions.ts b/src/modules/agent-instructions/lib/instructions.ts new file mode 100644 index 0000000000..e9b74e0af9 --- /dev/null +++ b/src/modules/agent-instructions/lib/instructions.ts @@ -0,0 +1,127 @@ +import fs from 'fs/promises'; +import nodePath from 'path'; +import { + INSTRUCTION_FILES, + INSTRUCTION_FILE_TYPES, + type InstructionFileType, + extractInstructionVersion, + isInstructionVersionOutdated, +} from '../constants'; + +export interface InstructionFileStatus { + id: InstructionFileType; + fileName: string; + displayName: string; + description: string; + exists: boolean; + path: string; + version?: string | null; + isOutdated?: boolean; +} + +export function getInstructionFilePath( sitePath: string, fileType: InstructionFileType ): string { + return nodePath.join( sitePath, INSTRUCTION_FILES[ fileType ].fileName ); +} + +export async function getInstructionFileStatus( + sitePath: string, + fileType: InstructionFileType +): Promise< InstructionFileStatus > { + const config = INSTRUCTION_FILES[ fileType ]; + const filePath = getInstructionFilePath( sitePath, fileType ); + + try { + await fs.access( filePath ); + + // Read file to check version + const content = await fs.readFile( filePath, 'utf-8' ); + const version = extractInstructionVersion( content ); + const isOutdated = isInstructionVersionOutdated( version ); + + return { + id: config.id, + fileName: config.fileName, + displayName: config.displayName, + description: config.description, + exists: true, + path: filePath, + version, + isOutdated, + }; + } catch { + return { + id: config.id, + fileName: config.fileName, + displayName: config.displayName, + description: config.description, + exists: false, + path: filePath, + }; + } +} + +export async function getAllInstructionFilesStatus( + sitePath: string +): Promise< InstructionFileStatus[] > { + return Promise.all( + INSTRUCTION_FILE_TYPES.map( ( fileType ) => getInstructionFileStatus( sitePath, fileType ) ) + ); +} + +export async function installInstructionFile( + sitePath: string, + fileType: InstructionFileType, + content: string, + overwrite: boolean +): Promise< { path: string; overwritten: boolean } > { + const filePath = getInstructionFilePath( sitePath, fileType ); + let overwritten = false; + + if ( ! overwrite ) { + try { + await fs.access( filePath ); + return { path: filePath, overwritten: false }; + } catch { + // File does not exist, continue with install. + } + } else { + overwritten = true; + } + + await fs.writeFile( filePath, content, 'utf-8' ); + return { path: filePath, overwritten }; +} + +export async function installAllInstructionFiles( + sitePath: string, + content: string, + overwrite: boolean +): Promise< Array< { fileType: InstructionFileType; path: string; overwritten: boolean } > > { + const results = await Promise.all( + INSTRUCTION_FILE_TYPES.map( async ( fileType ) => { + const result = await installInstructionFile( sitePath, fileType, content, overwrite ); + return { fileType, ...result }; + } ) + ); + return results; +} + +// Legacy exports for backwards compatibility +export function getAgentInstructionsPath( sitePath: string ): string { + return getInstructionFilePath( sitePath, 'agents' ); +} + +export async function getAgentInstructionsStatus( + sitePath: string +): Promise< { exists: boolean; path: string } > { + const status = await getInstructionFileStatus( sitePath, 'agents' ); + return { exists: status.exists, path: status.path }; +} + +export async function installAgentInstructions( + sitePath: string, + content: string, + overwrite: boolean +): Promise< { path: string; overwritten: boolean } > { + return installInstructionFile( sitePath, 'agents', content, overwrite ); +} From e7f7697e35a4fb3b1d8c2eb3b5f922a6d184fab7 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:36:10 -0800 Subject: [PATCH 17/20] Update agent skills repository org from Automattic to WordPress - Change DEFAULT_SKILLS_REPO to WordPress/agent-skills - Update all GitHub URLs and references - Update comments and documentation - Agent skills now fetched from WordPress org --- .../components/install-skill-modal.tsx | 138 ++++++++ .../agent-skills/components/skill-card.tsx | 164 ++++++++++ .../agent-skills/components/skills-panel.tsx | 306 ++++++++++++++++++ .../agent-skills/hooks/use-site-skills.ts | 189 +++++++++++ src/modules/agent-skills/index.ts | 30 ++ src/modules/agent-skills/lib/constants.ts | 18 ++ src/modules/agent-skills/lib/ipc-handlers.ts | 120 +++++++ .../agent-skills/lib/skill-discovery.ts | 186 +++++++++++ .../agent-skills/lib/skill-installer.ts | 283 ++++++++++++++++ src/modules/agent-skills/lib/skill-parser.ts | 153 +++++++++ .../agent-skills/lib/skill-prompt-builder.ts | 107 ++++++ src/modules/agent-skills/main.ts | 40 +++ src/modules/agent-skills/types.ts | 86 +++++ 13 files changed, 1820 insertions(+) create mode 100644 src/modules/agent-skills/components/install-skill-modal.tsx create mode 100644 src/modules/agent-skills/components/skill-card.tsx create mode 100644 src/modules/agent-skills/components/skills-panel.tsx create mode 100644 src/modules/agent-skills/hooks/use-site-skills.ts create mode 100644 src/modules/agent-skills/index.ts create mode 100644 src/modules/agent-skills/lib/constants.ts create mode 100644 src/modules/agent-skills/lib/ipc-handlers.ts create mode 100644 src/modules/agent-skills/lib/skill-discovery.ts create mode 100644 src/modules/agent-skills/lib/skill-installer.ts create mode 100644 src/modules/agent-skills/lib/skill-parser.ts create mode 100644 src/modules/agent-skills/lib/skill-prompt-builder.ts create mode 100644 src/modules/agent-skills/main.ts create mode 100644 src/modules/agent-skills/types.ts diff --git a/src/modules/agent-skills/components/install-skill-modal.tsx b/src/modules/agent-skills/components/install-skill-modal.tsx new file mode 100644 index 0000000000..b9fb99eac4 --- /dev/null +++ b/src/modules/agent-skills/components/install-skill-modal.tsx @@ -0,0 +1,138 @@ +/** + * InstallSkillModal - Modal for browsing and installing skills from GitHub. + */ + +import { Spinner } from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { useMemo } from 'react'; +import Button from 'src/components/button'; +import Modal from 'src/components/modal'; +import { useAvailableSkills, useInstallSkill } from '../hooks/use-site-skills'; +import { AvailableSkillCard } from './skill-card'; +import type { Skill } from '../types'; + +interface InstallSkillModalProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Callback when the modal is closed */ + onClose: () => void; + /** The site ID to install skills to */ + siteId: string; + /** List of already installed skills */ + installedSkills: Skill[]; + /** Callback when a skill is successfully installed */ + onInstallSuccess: () => void; +} + +export function InstallSkillModal( { + isOpen, + onClose, + siteId, + installedSkills, + onInstallSuccess, +}: InstallSkillModalProps ) { + const { __ } = useI18n(); + const { availableSkills, isLoading, error, refresh } = useAvailableSkills(); + const { installSkill, isInstalling, installError } = useInstallSkill( siteId, () => { + onInstallSuccess(); + } ); + + // Set of installed skill names for quick lookup + const installedNames = useMemo( + () => new Set( installedSkills.map( ( s ) => s.name ) ), + [ installedSkills ] + ); + + const handleInstall = async ( skillPath: string ) => { + const result = await installSkill( skillPath ); + if ( result.success ) { + // Keep modal open so user can install more skills + } + }; + + if ( ! isOpen ) { + return null; + } + + return ( + +
+

+ { __( + 'Skills provide specialized capabilities for the AI assistant. Install skills to help the assistant with specific tasks.' + ) } +

+ + { /* Error display */ } + { ( error || installError ) && ( +
+ { error || installError } +
+ ) } + + { /* Loading state */ } + { isLoading ? ( +
+ + { __( 'Loading available skills...' ) } +
+ ) : availableSkills.length === 0 && ! error ? ( +
+

{ __( 'No skills available in the repository.' ) }

+

+ { __( + 'The WordPress/agent-skills repository may not exist yet or does not contain a skills/ directory.' + ) } +

+ +
+ ) : availableSkills.length === 0 && error ? ( +
+

{ __( 'Could not load skills from the repository.' ) }

+ +
+ ) : ( +
+ { availableSkills.map( ( skill ) => ( + + ) ) } +
+ ) } + + { /* Footer */ } +
+

+ { __( 'Skills from ' ) } + + WordPress/agent-skills + +

+ +
+
+
+ ); +} diff --git a/src/modules/agent-skills/components/skill-card.tsx b/src/modules/agent-skills/components/skill-card.tsx new file mode 100644 index 0000000000..1e04cfbf30 --- /dev/null +++ b/src/modules/agent-skills/components/skill-card.tsx @@ -0,0 +1,164 @@ +/** + * SkillCard component - displays information about a single installed skill. + */ + +import { Icon, trash, file, archive } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState, useCallback } from 'react'; +import Button from 'src/components/button'; +import { cx } from 'src/lib/cx'; +import type { Skill } from '../types'; + +interface SkillCardProps { + /** The skill to display */ + skill: Skill; + /** Callback when the remove button is clicked */ + onRemove: ( skillName: string ) => Promise< void >; +} + +export function SkillCard( { skill, onRemove }: SkillCardProps ) { + const { __ } = useI18n(); + const [ isRemoving, setIsRemoving ] = useState( false ); + const [ showConfirm, setShowConfirm ] = useState( false ); + + const handleRemove = useCallback( async () => { + setIsRemoving( true ); + try { + await onRemove( skill.name ); + } catch ( err ) { + console.error( 'Failed to remove skill:', err ); + } finally { + setIsRemoving( false ); + setShowConfirm( false ); + } + }, [ onRemove, skill.name ] ); + + return ( +
+
+
+

{ skill.name }

+

{ skill.description }

+ + { /* Optional capabilities */ } +
+ { skill.hasScripts && ( + + + { __( 'Scripts' ) } + + ) } + { skill.hasReferences && ( + + + { __( 'References' ) } + + ) } + { skill.allowedTools && skill.allowedTools.length > 0 && ( + + { skill.allowedTools.length } { __( 'tools' ) } + + ) } +
+
+ +
+ { showConfirm ? ( +
+ + +
+ ) : ( + + ) } +
+
+
+ ); +} + +interface AvailableSkillCardProps { + /** Skill name */ + name: string; + /** Skill description */ + description: string; + /** Path within the repository */ + path: string; + /** Whether this skill is already installed */ + isInstalled: boolean; + /** Callback when install is clicked */ + onInstall: ( path: string ) => void; + /** Whether installation is in progress */ + isInstalling: boolean; +} + +export function AvailableSkillCard( { + name, + description, + path, + isInstalled, + onInstall, + isInstalling, +}: AvailableSkillCardProps ) { + const { __ } = useI18n(); + + return ( +
+
+
+

{ name }

+

{ description }

+
+ +
+ { isInstalled ? ( + + { __( 'Installed' ) } + + ) : ( + + ) } +
+
+
+ ); +} diff --git a/src/modules/agent-skills/components/skills-panel.tsx b/src/modules/agent-skills/components/skills-panel.tsx new file mode 100644 index 0000000000..988b02ce0e --- /dev/null +++ b/src/modules/agent-skills/components/skills-panel.tsx @@ -0,0 +1,306 @@ +/** + * SkillsPanel - Main panel for managing site skills. + * + * Displays installed and available skills in a single view. + */ + +import { Spinner } from '@wordpress/components'; +import { Icon, check } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState, useCallback, useMemo, useEffect } from 'react'; +import Button from 'src/components/button'; +import { cx } from 'src/lib/cx'; +import { useSiteSkills, useAvailableSkills, useInstallSkill } from '../hooks/use-site-skills'; +import type { Skill } from '../types'; + +interface SkillsPanelProps { + /** The site ID to manage skills for */ + siteId: string; + /** Optional CSS class name */ + className?: string; +} + +interface SkillRowProps { + name: string; + description: string; + isInstalled: boolean; + onInstall?: () => void; + onRemove?: () => void; + isInstalling?: boolean; + isRemoving?: boolean; +} + +function SkillRow( { + name, + description, + isInstalled, + onInstall, + onRemove, + isInstalling, + isRemoving, +}: SkillRowProps ) { + const { __ } = useI18n(); + const [ showConfirm, setShowConfirm ] = useState( false ); + + const handleRemoveClick = useCallback( () => { + if ( showConfirm ) { + onRemove?.(); + setShowConfirm( false ); + } else { + setShowConfirm( true ); + } + }, [ showConfirm, onRemove ] ); + + return ( +
+
+
{ name }
+
{ description }
+
+
+ { isInstalled ? ( + <> + + + { __( 'Installed' ) } + + { showConfirm ? ( +
+ + +
+ ) : ( + + ) } + + ) : ( + + ) } +
+
+ ); +} + +export function SkillsPanel( { siteId, className }: SkillsPanelProps ) { + const { __ } = useI18n(); + const { + skills: installedSkills, + isLoading: isLoadingInstalled, + error: installedError, + refresh: refreshInstalled, + removeSkill, + } = useSiteSkills( siteId ); + const { + availableSkills, + isLoading: isLoadingAvailable, + error: availableError, + refresh: refreshAvailable, + } = useAvailableSkills(); + const { installSkill, isInstalling, installError } = useInstallSkill( siteId, () => { + void refreshInstalled(); + } ); + + const [ removeError, setRemoveError ] = useState< string | null >( null ); + const [ removingSkill, setRemovingSkill ] = useState< string | null >( null ); + const [ installingPath, setInstallingPath ] = useState< string | null >( null ); + + // Load available skills on mount + useEffect( () => { + if ( availableSkills.length === 0 && ! isLoadingAvailable && ! availableError ) { + void refreshAvailable(); + } + }, [ availableSkills.length, isLoadingAvailable, availableError, refreshAvailable ] ); + + // Set of installed skill names for quick lookup + const installedNames = useMemo( + () => new Set( installedSkills.map( ( s ) => s.name ) ), + [ installedSkills ] + ); + + // Filter available skills that aren't installed + const uninstalledSkills = useMemo( + () => availableSkills.filter( ( s ) => ! installedNames.has( s.name ) ), + [ availableSkills, installedNames ] + ); + + const handleInstall = useCallback( + async ( skillPath: string ) => { + setInstallingPath( skillPath ); + try { + await installSkill( skillPath ); + } finally { + setInstallingPath( null ); + } + }, + [ installSkill ] + ); + + const handleRemove = useCallback( + async ( skillName: string ) => { + setRemoveError( null ); + setRemovingSkill( skillName ); + try { + await removeSkill( skillName ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setRemoveError( errorMessage ); + } finally { + setRemovingSkill( null ); + } + }, + [ removeSkill ] + ); + + const handleRefresh = useCallback( () => { + void refreshInstalled(); + void refreshAvailable(); + }, [ refreshInstalled, refreshAvailable ] ); + + const isLoading = isLoadingInstalled || isLoadingAvailable; + const error = installedError || availableError || installError || removeError; + + return ( +
+ { /* Header */ } +
+
+

{ __( 'Agent Skills' ) }

+

+ { __( 'Enhance the AI assistant with specialized capabilities' ) } +

+
+ +
+ + { /* Error display */ } + { error && ( +
+ { error } +
+ ) } + + { /* Skills list */ } +
+ { /* Installed section */ } +
+ { __( 'Installed' ) } + { installedSkills.length > 0 && ( + ({ installedSkills.length }) + ) } +
+ + { isLoadingInstalled ? ( +
+ + { __( 'Loading...' ) } +
+ ) : installedSkills.length === 0 ? ( +
+ { __( 'No skills installed yet' ) } +
+ ) : ( + installedSkills.map( ( skill ) => ( + handleRemove( skill.name ) } + isRemoving={ removingSkill === skill.name } + /> + ) ) + ) } + + { /* Available section */ } +
+
+ { __( 'Available' ) } + { uninstalledSkills.length > 0 && ( + ({ uninstalledSkills.length }) + ) } +
+ { uninstalledSkills.length > 0 && ( + + ) } +
+ + { isLoadingAvailable ? ( +
+ + { __( 'Loading...' ) } +
+ ) : uninstalledSkills.length === 0 ? ( +
+ { availableError + ? __( 'Could not load available skills' ) + : __( 'All available skills are installed' ) } +
+ ) : ( + uninstalledSkills.map( ( skill ) => ( + handleInstall( skill.path ) } + isInstalling={ isInstalling && installingPath === skill.path } + /> + ) ) + ) } +
+ + { /* Footer */ } +

+ { __( 'Skills from ' ) } + + WordPress/agent-skills + +

+
+ ); +} diff --git a/src/modules/agent-skills/hooks/use-site-skills.ts b/src/modules/agent-skills/hooks/use-site-skills.ts new file mode 100644 index 0000000000..367ad2f89e --- /dev/null +++ b/src/modules/agent-skills/hooks/use-site-skills.ts @@ -0,0 +1,189 @@ +/** + * React hook for loading and managing site skills. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { Skill, AvailableSkill, SkillInstallResult } from '../types'; + +interface UseSiteSkillsResult { + /** List of installed skills */ + skills: Skill[]; + /** Whether skills are currently loading */ + isLoading: boolean; + /** Error message if loading failed */ + error: string | null; + /** Reload the skills list */ + refresh: () => Promise< void >; + /** Remove a skill */ + removeSkill: ( skillName: string ) => Promise< void >; +} + +/** + * Hook for loading and managing skills for a specific site. + * + * @param siteId - The site ID to load skills for + * @returns Object containing skills, loading state, and management functions + */ +export function useSiteSkills( siteId: string ): UseSiteSkillsResult { + const [ skills, setSkills ] = useState< Skill[] >( [] ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + + const loadSkills = useCallback( async () => { + if ( ! siteId ) { + setSkills( [] ); + setIsLoading( false ); + return; + } + + setIsLoading( true ); + setError( null ); + + try { + const loadedSkills = await getIpcApi().getSiteSkills( siteId ); + setSkills( loadedSkills ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + console.error( 'Failed to load skills:', err ); + setError( errorMessage ); + setSkills( [] ); + } finally { + setIsLoading( false ); + } + }, [ siteId ] ); + + useEffect( () => { + void loadSkills(); + }, [ loadSkills ] ); + + const removeSkill = useCallback( + async ( skillName: string ) => { + try { + await getIpcApi().removeSkill( siteId, skillName ); + await loadSkills(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + console.error( 'Failed to remove skill:', err ); + throw new Error( errorMessage ); + } + }, + [ siteId, loadSkills ] + ); + + return { + skills, + isLoading, + error, + refresh: loadSkills, + removeSkill, + }; +} + +interface UseAvailableSkillsResult { + /** List of available skills from the repository */ + availableSkills: AvailableSkill[]; + /** Whether skills are currently loading */ + isLoading: boolean; + /** Error message if loading failed */ + error: string | null; + /** Reload the available skills */ + refresh: () => Promise< void >; +} + +/** + * Hook for loading available skills from a GitHub repository. + * + * @param repo - Repository to load skills from (optional) + * @returns Object containing available skills and loading state + */ +export function useAvailableSkills( repo?: string ): UseAvailableSkillsResult { + const [ availableSkills, setAvailableSkills ] = useState< AvailableSkill[] >( [] ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + + const loadSkills = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + const skills = await getIpcApi().listAvailableSkills( repo ); + setAvailableSkills( skills ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + console.error( 'Failed to load available skills:', err ); + setError( errorMessage ); + setAvailableSkills( [] ); + } finally { + setIsLoading( false ); + } + }, [ repo ] ); + + useEffect( () => { + void loadSkills(); + }, [ loadSkills ] ); + + return { + availableSkills, + isLoading, + error, + refresh: loadSkills, + }; +} + +interface UseInstallSkillResult { + /** Install a skill from a repository */ + installSkill: ( skillPath: string, repo?: string ) => Promise< SkillInstallResult >; + /** Whether an installation is in progress */ + isInstalling: boolean; + /** Error from the last installation attempt */ + installError: string | null; +} + +/** + * Hook for installing skills from GitHub repositories. + * + * @param siteId - The site ID to install skills to + * @param onSuccess - Callback when installation succeeds + * @returns Object containing install function and status + */ +export function useInstallSkill( siteId: string, onSuccess?: () => void ): UseInstallSkillResult { + const [ isInstalling, setIsInstalling ] = useState( false ); + const [ installError, setInstallError ] = useState< string | null >( null ); + + const installSkill = useCallback( + async ( skillPath: string, repo?: string ): Promise< SkillInstallResult > => { + setIsInstalling( true ); + setInstallError( null ); + + try { + const result = await getIpcApi().installSkill( + siteId, + repo ?? 'WordPress/agent-skills', + skillPath + ); + + if ( result.success ) { + onSuccess?.(); + } else { + setInstallError( result.error ?? 'Installation failed' ); + } + + return result; + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setInstallError( errorMessage ); + return { success: false, error: errorMessage }; + } finally { + setIsInstalling( false ); + } + }, + [ siteId, onSuccess ] + ); + + return { + installSkill, + isInstalling, + installError, + }; +} diff --git a/src/modules/agent-skills/index.ts b/src/modules/agent-skills/index.ts new file mode 100644 index 0000000000..b19f54176e --- /dev/null +++ b/src/modules/agent-skills/index.ts @@ -0,0 +1,30 @@ +/** + * AgentSkills module - Renderer-safe exports. + * + * This file exports only renderer-compatible code (hooks, components, types). + * For main process code (Node.js functions), import from './main' instead. + */ + +// React hooks (renderer-safe, use IPC) +export { useSiteSkills, useAvailableSkills, useInstallSkill } from './hooks/use-site-skills'; + +// React components (renderer-safe) +export { SkillsPanel } from './components/skills-panel'; + +// Type exports (renderer-safe) +export type { + Skill, + SkillMetadata, + SkillInstallSource, + InstalledSkill, + SkillInstallResult, + AvailableSkill, +} from './types'; + +// Constants (renderer-safe, no Node.js dependencies) +export { + SKILLS_DIRECTORY_PATH, + SKILL_FILE_NAME, + DEFAULT_SKILLS_REPO, + DEFAULT_BRANCH, +} from './lib/constants'; diff --git a/src/modules/agent-skills/lib/constants.ts b/src/modules/agent-skills/lib/constants.ts new file mode 100644 index 0000000000..5b3644b4a0 --- /dev/null +++ b/src/modules/agent-skills/lib/constants.ts @@ -0,0 +1,18 @@ +/** + * Constants for the agent-skills module. + * + * These are kept separate so they can be imported in renderer code + * without pulling in Node.js dependencies. + */ + +/** Path to the skills directory within a site (Claude Code compatible) */ +export const SKILLS_DIRECTORY_PATH = '.claude/skills'; + +/** Name of the skill definition file */ +export const SKILL_FILE_NAME = 'SKILL.md'; + +/** Default repository for skills */ +export const DEFAULT_SKILLS_REPO = 'WordPress/agent-skills'; + +/** Default branch to download from */ +export const DEFAULT_BRANCH = 'trunk'; diff --git a/src/modules/agent-skills/lib/ipc-handlers.ts b/src/modules/agent-skills/lib/ipc-handlers.ts new file mode 100644 index 0000000000..6dd2c0b1c3 --- /dev/null +++ b/src/modules/agent-skills/lib/ipc-handlers.ts @@ -0,0 +1,120 @@ +/** + * IPC handlers for the AgentSkills module. + * + * These handlers are exposed to the renderer process via preload.ts + * and handle all skill-related operations. + */ + +import { loadUserData } from 'src/storage/user-data'; +import { DEFAULT_BRANCH, DEFAULT_SKILLS_REPO } from './constants'; +import { discoverSiteSkills } from './skill-discovery'; +import { + installSkillFromGitHub, + listAvailableSkills as listAvailableSkillsFromRepo, + removeSkill as removeSkillFromDisk, +} from './skill-installer'; +import { buildSkillsPromptXml } from './skill-prompt-builder'; +import type { AvailableSkill, Skill, SkillInstallResult } from '../types'; +import type { IpcMainInvokeEvent } from 'electron'; + +/** + * Get the site path for a given site ID. + * + * @param siteId - The site ID to look up + * @returns The site's file path + * @throws Error if site is not found + */ +async function getSitePath( siteId: string ): Promise< string > { + const userData = await loadUserData(); + const site = userData.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new Error( `Site not found: ${ siteId }` ); + } + + return site.path; +} + +/** + * Get all skills installed for a site. + * + * @param _event - IPC event + * @param siteId - The site ID to get skills for + * @returns Array of installed skills + */ +export async function getSiteSkills( + _event: IpcMainInvokeEvent, + siteId: string +): Promise< Skill[] > { + const sitePath = await getSitePath( siteId ); + return discoverSiteSkills( sitePath ); +} + +/** + * Install a skill from a GitHub repository. + * + * @param _event - IPC event + * @param siteId - The site ID to install the skill to + * @param repo - Repository in "owner/repo" format + * @param skillPath - Path to the skill within the repo + * @param branch - Branch to install from (optional) + * @returns Installation result + */ +export async function installSkill( + _event: IpcMainInvokeEvent, + siteId: string, + repo: string, + skillPath: string, + branch?: string +): Promise< SkillInstallResult > { + const sitePath = await getSitePath( siteId ); + return installSkillFromGitHub( sitePath, repo, skillPath, branch || DEFAULT_BRANCH ); +} + +/** + * Remove an installed skill from a site. + * + * @param _event - IPC event + * @param siteId - The site ID to remove the skill from + * @param skillName - Name of the skill to remove + */ +export async function removeSkill( + _event: IpcMainInvokeEvent, + siteId: string, + skillName: string +): Promise< void > { + const sitePath = await getSitePath( siteId ); + return removeSkillFromDisk( sitePath, skillName ); +} + +/** + * List available skills from a GitHub repository. + * + * @param _event - IPC event + * @param repo - Repository in "owner/repo" format (optional, defaults to WordPress/agent-skills) + * @param branch - Branch to list from (optional) + * @returns Array of available skills + */ +export async function listAvailableSkills( + _event: IpcMainInvokeEvent, + repo?: string, + branch?: string +): Promise< AvailableSkill[] > { + return listAvailableSkillsFromRepo( repo || DEFAULT_SKILLS_REPO, branch || DEFAULT_BRANCH ); +} + +/** + * Get the skills XML for injection into an AI agent's system prompt. + * + * @param _event - IPC event + * @param siteId - The site ID to get skills for + * @returns XML string for the system prompt, or empty string if no skills + */ +export async function getSkillsPromptXml( + _event: IpcMainInvokeEvent, + siteId: string +): Promise< string > { + const sitePath = await getSitePath( siteId ); + const skills = await discoverSiteSkills( sitePath ); + return buildSkillsPromptXml( skills ); +} diff --git a/src/modules/agent-skills/lib/skill-discovery.ts b/src/modules/agent-skills/lib/skill-discovery.ts new file mode 100644 index 0000000000..39d4c383c0 --- /dev/null +++ b/src/modules/agent-skills/lib/skill-discovery.ts @@ -0,0 +1,186 @@ +/** + * Skill discovery functions for scanning and finding installed skills. + * + * Skills are stored in a .claude/skills/ directory within each site's path. + * This matches Claude Code's standard skill discovery location, making skills + * automatically available to both Studio's built-in agent and Claude Code. + * + * Each skill is a subdirectory containing a SKILL.md file and optional + * scripts/, references/, and assets/ directories. + */ + +import fs from 'fs/promises'; +import nodePath from 'path'; +import { SKILLS_DIRECTORY_PATH, SKILL_FILE_NAME } from './constants'; +import { parseSkillFile } from './skill-parser'; +import type { Skill } from '../types'; + +// Re-export constants for backward compatibility +export { SKILLS_DIRECTORY_PATH, SKILL_FILE_NAME } from './constants'; + +/** + * Get the path to the skills directory for a site. + * + * @param sitePath - Absolute path to the WordPress site + * @returns Absolute path to the .claude/skills directory + */ +export function getSkillsPath( sitePath: string ): string { + return nodePath.join( sitePath, SKILLS_DIRECTORY_PATH ); +} + +/** + * Get the path to a specific skill's directory. + * + * @param sitePath - Absolute path to the WordPress site + * @param skillName - Name of the skill + * @returns Absolute path to the skill's directory + */ +export function getSkillPath( sitePath: string, skillName: string ): string { + return nodePath.join( getSkillsPath( sitePath ), skillName ); +} + +/** + * Check if a skill exists in a site. + * + * @param sitePath - Absolute path to the WordPress site + * @param skillName - Name of the skill to check + * @returns True if the skill exists + */ +export async function skillExists( sitePath: string, skillName: string ): Promise< boolean > { + const skillPath = getSkillPath( sitePath, skillName ); + const skillFilePath = nodePath.join( skillPath, SKILL_FILE_NAME ); + + try { + await fs.access( skillFilePath ); + return true; + } catch { + return false; + } +} + +/** + * Check if a directory has a subdirectory with the given name. + * + * @param dirPath - Path to check in + * @param subdirName - Name of subdirectory to look for + * @returns True if the subdirectory exists + */ +async function hasSubdirectory( dirPath: string, subdirName: string ): Promise< boolean > { + try { + const stat = await fs.stat( nodePath.join( dirPath, subdirName ) ); + return stat.isDirectory(); + } catch { + return false; + } +} + +/** + * Parse a single skill from its directory. + * + * @param skillDirPath - Absolute path to the skill's directory + * @returns Parsed skill object or null if invalid + */ +async function parseSkillFromDirectory( skillDirPath: string ): Promise< Skill | null > { + const skillFilePath = nodePath.join( skillDirPath, SKILL_FILE_NAME ); + + try { + const content = await fs.readFile( skillFilePath, 'utf-8' ); + const { metadata, body } = parseSkillFile( content ); + + // Check for optional directories + const [ hasScripts, hasReferences, hasAssets ] = await Promise.all( [ + hasSubdirectory( skillDirPath, 'scripts' ), + hasSubdirectory( skillDirPath, 'references' ), + hasSubdirectory( skillDirPath, 'assets' ), + ] ); + + return { + ...metadata, + path: skillDirPath, + body, + hasScripts, + hasReferences, + hasAssets, + }; + } catch ( error ) { + console.error( `Failed to parse skill at ${ skillDirPath }:`, error ); + return null; + } +} + +/** + * Discover all skills installed in a site's .claude/skills directory. + * + * @param sitePath - Absolute path to the WordPress site + * @returns Array of discovered skills + */ +export async function discoverSiteSkills( sitePath: string ): Promise< Skill[] > { + const skillsPath = getSkillsPath( sitePath ); + + try { + const entries = await fs.readdir( skillsPath, { withFileTypes: true } ); + const skills: Skill[] = []; + + for ( const entry of entries ) { + // Skip non-directories and hidden directories (except .agentskills itself) + if ( ! entry.isDirectory() || entry.name.startsWith( '.' ) ) { + continue; + } + + const skillDirPath = nodePath.join( skillsPath, entry.name ); + const skill = await parseSkillFromDirectory( skillDirPath ); + + if ( skill ) { + skills.push( skill ); + } + } + + // Sort by name for consistent ordering + skills.sort( ( a, b ) => a.name.localeCompare( b.name ) ); + + return skills; + } catch ( error ) { + // If directory doesn't exist, return empty array + if ( ( error as NodeJS.ErrnoException ).code === 'ENOENT' ) { + return []; + } + console.error( `Failed to discover skills at ${ skillsPath }:`, error ); + return []; + } +} + +/** + * Get a single skill by name from a site. + * + * @param sitePath - Absolute path to the WordPress site + * @param skillName - Name of the skill to get + * @returns The skill if found, null otherwise + */ +export async function getSkillByName( + sitePath: string, + skillName: string +): Promise< Skill | null > { + const skillDirPath = getSkillPath( sitePath, skillName ); + return parseSkillFromDirectory( skillDirPath ); +} + +/** + * Ensure the .claude/skills directory exists for a site. + * + * @param sitePath - Absolute path to the WordPress site + * @returns Path to the created/existing directory + */ +export async function ensureSkillsDirectory( sitePath: string ): Promise< string > { + const skillsPath = getSkillsPath( sitePath ); + + try { + await fs.mkdir( skillsPath, { recursive: true } ); + } catch ( error ) { + // Ignore if directory already exists + if ( ( error as NodeJS.ErrnoException ).code !== 'EEXIST' ) { + throw error; + } + } + + return skillsPath; +} diff --git a/src/modules/agent-skills/lib/skill-installer.ts b/src/modules/agent-skills/lib/skill-installer.ts new file mode 100644 index 0000000000..e0e49ae1fc --- /dev/null +++ b/src/modules/agent-skills/lib/skill-installer.ts @@ -0,0 +1,283 @@ +/** + * Skill installer for downloading and managing skills from GitHub. + * + * Handles downloading skills from GitHub repositories and installing them + * into the site's .claude/skills directory. + */ + +import fs from 'fs/promises'; +import https from 'https'; +import nodePath from 'path'; +import { DEFAULT_BRANCH, DEFAULT_SKILLS_REPO, SKILL_FILE_NAME } from './constants'; +import { ensureSkillsDirectory, getSkillPath, skillExists } from './skill-discovery'; +import { parseSkillFile } from './skill-parser'; +import type { AvailableSkill, Skill, SkillInstallResult } from '../types'; + +// Re-export constants for backward compatibility +export { DEFAULT_BRANCH, DEFAULT_SKILLS_REPO } from './constants'; + +/** + * Make an HTTPS GET request and return the response body. + * + * @param url - URL to fetch + * @returns Response body as string + */ +async function fetchUrl( url: string ): Promise< string > { + return new Promise( ( resolve, reject ) => { + const request = https.get( + url, + { + headers: { + 'User-Agent': 'WordPress-Studio', + Accept: 'application/vnd.github.v3+json', + }, + }, + ( response ) => { + // Handle redirects + if ( response.statusCode === 301 || response.statusCode === 302 ) { + if ( response.headers.location ) { + fetchUrl( response.headers.location ).then( resolve ).catch( reject ); + return; + } + } + + if ( response.statusCode !== 200 ) { + reject( new Error( `HTTP ${ response.statusCode }: ${ response.statusMessage }` ) ); + return; + } + + let data = ''; + response.on( 'data', ( chunk ) => ( data += chunk ) ); + response.on( 'end', () => resolve( data ) ); + response.on( 'error', reject ); + } + ); + + request.on( 'error', reject ); + request.end(); + } ); +} + +/** + * Download raw file content from GitHub. + * + * @param repo - Repository in "owner/repo" format + * @param filePath - Path to the file within the repo + * @param branch - Branch to download from + * @returns File content + */ +async function downloadRawFile( + repo: string, + filePath: string, + branch: string = DEFAULT_BRANCH +): Promise< string > { + const url = `https://raw.githubusercontent.com/${ repo }/${ branch }/${ filePath }`; + return fetchUrl( url ); +} + +/** + * List contents of a directory in a GitHub repository using the API. + * + * @param repo - Repository in "owner/repo" format + * @param path - Path within the repo + * @param branch - Branch to list from + * @returns Array of directory entries + */ +async function listGitHubDirectory( + repo: string, + path: string, + branch: string = DEFAULT_BRANCH +): Promise< Array< { name: string; path: string; type: 'file' | 'dir' } > > { + const url = `https://api.github.com/repos/${ repo }/contents/${ path }?ref=${ branch }`; + const response = await fetchUrl( url ); + const entries = JSON.parse( response ); + + if ( ! Array.isArray( entries ) ) { + throw new Error( 'Expected directory listing from GitHub API' ); + } + + return entries.map( ( entry: { name: string; path: string; type: string } ) => ( { + name: entry.name, + path: entry.path, + type: entry.type === 'dir' ? 'dir' : 'file', + } ) ); +} + +/** + * Recursively download a directory from GitHub. + * + * @param repo - Repository in "owner/repo" format + * @param remotePath - Path within the repo + * @param localPath - Local path to download to + * @param branch - Branch to download from + */ +async function downloadDirectory( + repo: string, + remotePath: string, + localPath: string, + branch: string = DEFAULT_BRANCH +): Promise< void > { + await fs.mkdir( localPath, { recursive: true } ); + + const entries = await listGitHubDirectory( repo, remotePath, branch ); + + for ( const entry of entries ) { + const localEntryPath = nodePath.join( localPath, entry.name ); + + if ( entry.type === 'dir' ) { + await downloadDirectory( repo, entry.path, localEntryPath, branch ); + } else { + const content = await downloadRawFile( repo, entry.path, branch ); + await fs.writeFile( localEntryPath, content, 'utf-8' ); + } + } +} + +/** + * Install a skill from a GitHub repository. + * + * @param sitePath - Absolute path to the WordPress site + * @param repo - Repository in "owner/repo" format + * @param skillPath - Path to the skill within the repo + * @param branch - Branch to install from + * @returns Installation result + */ +export async function installSkillFromGitHub( + sitePath: string, + repo: string, + skillPath: string, + branch: string = DEFAULT_BRANCH +): Promise< SkillInstallResult > { + try { + // First, fetch the SKILL.md to get the skill name + const skillMdPath = `${ skillPath }/${ SKILL_FILE_NAME }`; + const skillContent = await downloadRawFile( repo, skillMdPath, branch ); + const { metadata, body } = parseSkillFile( skillContent ); + + // Check if skill already exists + if ( await skillExists( sitePath, metadata.name ) ) { + return { + success: false, + error: `Skill "${ metadata.name }" is already installed`, + }; + } + + // Ensure .agentskills directory exists + await ensureSkillsDirectory( sitePath ); + + // Create the skill directory using the skill name + const localSkillPath = getSkillPath( sitePath, metadata.name ); + + // Download the entire skill directory + await downloadDirectory( repo, skillPath, localSkillPath, branch ); + + // Check for optional directories + const [ hasScripts, hasReferences, hasAssets ] = await Promise.all( [ + fs + .stat( nodePath.join( localSkillPath, 'scripts' ) ) + .then( ( s ) => s.isDirectory() ) + .catch( () => false ), + fs + .stat( nodePath.join( localSkillPath, 'references' ) ) + .then( ( s ) => s.isDirectory() ) + .catch( () => false ), + fs + .stat( nodePath.join( localSkillPath, 'assets' ) ) + .then( ( s ) => s.isDirectory() ) + .catch( () => false ), + ] ); + + const skill: Skill = { + ...metadata, + path: localSkillPath, + body, + hasScripts, + hasReferences, + hasAssets, + }; + + return { + success: true, + skill, + }; + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + console.error( `Failed to install skill from ${ repo }/${ skillPath }:`, error ); + return { + success: false, + error: `Failed to install skill: ${ errorMessage }`, + }; + } +} + +/** + * Remove an installed skill from a site. + * + * @param sitePath - Absolute path to the WordPress site + * @param skillName - Name of the skill to remove + */ +export async function removeSkill( sitePath: string, skillName: string ): Promise< void > { + const skillPath = getSkillPath( sitePath, skillName ); + + // Verify the skill exists + if ( ! ( await skillExists( sitePath, skillName ) ) ) { + throw new Error( `Skill "${ skillName }" is not installed` ); + } + + // Remove the skill directory recursively + await fs.rm( skillPath, { recursive: true, force: true } ); +} + +/** + * List available skills from a GitHub repository. + * + * Expects the repository to have a skills/ directory containing subdirectories + * for each skill, each with a SKILL.md file. + * + * @param repo - Repository in "owner/repo" format + * @param branch - Branch to list from + * @returns Array of available skills + */ +export async function listAvailableSkills( + repo: string = DEFAULT_SKILLS_REPO, + branch: string = DEFAULT_BRANCH +): Promise< AvailableSkill[] > { + // List the skills directory in the repo + let entries; + try { + entries = await listGitHubDirectory( repo, 'skills', branch ); + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + console.error( `Failed to list skills from ${ repo }:`, error ); + // Re-throw with a more helpful message + throw new Error( + `Could not access skills in ${ repo }. ${ errorMessage }. Make sure the repository exists and has a "skills/" directory.` + ); + } + + const availableSkills: AvailableSkill[] = []; + + // For each directory, try to read its SKILL.md + for ( const entry of entries ) { + if ( entry.type !== 'dir' ) { + continue; + } + + try { + const skillMdPath = `${ entry.path }/${ SKILL_FILE_NAME }`; + const content = await downloadRawFile( repo, skillMdPath, branch ); + const { metadata } = parseSkillFile( content ); + + availableSkills.push( { + name: metadata.name, + description: metadata.description, + path: entry.path, + } ); + } catch ( skillError ) { + // Skip skills that can't be parsed + console.warn( `Failed to parse skill at ${ entry.path }:`, skillError ); + } + } + + return availableSkills; +} diff --git a/src/modules/agent-skills/lib/skill-parser.ts b/src/modules/agent-skills/lib/skill-parser.ts new file mode 100644 index 0000000000..67b1b1eb71 --- /dev/null +++ b/src/modules/agent-skills/lib/skill-parser.ts @@ -0,0 +1,153 @@ +/** + * Parser for SKILL.md files using the AgentSkills format. + * + * SKILL.md files contain YAML frontmatter followed by markdown content. + * The frontmatter contains metadata about the skill (name, description, etc.) + * and the body contains the actual instructions for AI agents. + */ + +import type { SkillMetadata } from '../types'; + +/** + * Regular expression to match YAML frontmatter in markdown files. + * Matches content between --- delimiters at the start of the file. + */ +const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; + +/** + * Parse a SKILL.md file and extract metadata from YAML frontmatter. + * + * @param content - The raw content of the SKILL.md file + * @returns Object containing parsed metadata and the markdown body + * @throws Error if frontmatter is invalid or missing required fields + */ +export function parseSkillFile( content: string ): { metadata: SkillMetadata; body: string } { + const match = content.match( FRONTMATTER_REGEX ); + + if ( ! match ) { + throw new Error( 'Invalid SKILL.md format: missing YAML frontmatter' ); + } + + const [ , yamlContent, body ] = match; + const metadata = parseYamlFrontmatter( yamlContent ); + + if ( ! validateSkillMetadata( metadata ) ) { + throw new Error( 'Invalid skill metadata: missing required fields (name, description)' ); + } + + return { + metadata, + body: body.trim(), + }; +} + +/** + * Parse YAML frontmatter into a JavaScript object. + * This is a simple parser that handles the common cases for skill metadata. + * + * @param yaml - The YAML string to parse + * @returns Parsed object + */ +function parseYamlFrontmatter( yaml: string ): Record< string, unknown > { + const result: Record< string, unknown > = {}; + const lines = yaml.split( /\r?\n/ ); + let currentKey: string | null = null; + let currentArray: string[] | null = null; + + for ( const line of lines ) { + // Skip empty lines and comments + if ( ! line.trim() || line.trim().startsWith( '#' ) ) { + continue; + } + + // Check for array item (starts with -) + if ( line.match( /^\s+-\s+/ ) && currentKey && currentArray ) { + const value = line.replace( /^\s+-\s+/, '' ).trim(); + // Remove quotes if present + currentArray.push( value.replace( /^["']|["']$/g, '' ) ); + continue; + } + + // Check for key-value pair + const keyValueMatch = line.match( /^(\w+):\s*(.*)$/ ); + if ( keyValueMatch ) { + // Save any pending array + if ( currentKey && currentArray ) { + result[ currentKey ] = currentArray; + currentArray = null; + } + + const [ , key, value ] = keyValueMatch; + currentKey = key; + + if ( value.trim() === '' ) { + // Could be start of an array or empty value + currentArray = []; + } else { + // Simple value - remove quotes if present + result[ key ] = value.trim().replace( /^["']|["']$/g, '' ); + currentArray = null; + } + } + } + + // Save any pending array + if ( currentKey && currentArray ) { + result[ currentKey ] = currentArray; + } + + return result; +} + +/** + * Validate that skill metadata contains all required fields. + * + * @param metadata - The metadata object to validate + * @returns True if the metadata is valid + */ +export function validateSkillMetadata( metadata: unknown ): metadata is SkillMetadata { + if ( ! metadata || typeof metadata !== 'object' ) { + return false; + } + + const obj = metadata as Record< string, unknown >; + + // Required fields + if ( typeof obj.name !== 'string' || obj.name.trim() === '' ) { + return false; + } + if ( typeof obj.description !== 'string' || obj.description.trim() === '' ) { + return false; + } + + // Optional fields type checking + if ( obj.license !== undefined && typeof obj.license !== 'string' ) { + return false; + } + if ( obj.compatibility !== undefined && typeof obj.compatibility !== 'string' ) { + return false; + } + if ( obj.allowedTools !== undefined && ! Array.isArray( obj.allowedTools ) ) { + return false; + } + if ( + obj.metadata !== undefined && + ( typeof obj.metadata !== 'object' || obj.metadata === null ) + ) { + return false; + } + + return true; +} + +/** + * Extract just the name from a SKILL.md file without fully parsing it. + * Useful for quick lookups. + * + * @param content - The raw content of the SKILL.md file + * @returns The skill name or null if not found + */ +export function extractSkillName( content: string ): string | null { + const match = content.match( /^---[\s\S]*?name:\s*["']?([^"'\r\n]+)["']?/m ); + return match ? match[ 1 ].trim() : null; +} diff --git a/src/modules/agent-skills/lib/skill-prompt-builder.ts b/src/modules/agent-skills/lib/skill-prompt-builder.ts new file mode 100644 index 0000000000..46864b0b9c --- /dev/null +++ b/src/modules/agent-skills/lib/skill-prompt-builder.ts @@ -0,0 +1,107 @@ +/** + * Prompt builder for integrating skills into AI agent system prompts. + * + * Generates XML-formatted skill information that can be injected into + * the system prompt to make the AI agent aware of installed skills. + */ + +import nodePath from 'path'; +import { SKILL_FILE_NAME } from './skill-discovery'; +import type { Skill } from '../types'; + +/** + * Escape XML special characters in a string. + * + * @param str - String to escape + * @returns Escaped string safe for XML + */ +function escapeXml( str: string ): string { + return str + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); +} + +/** + * Build XML representation of a single skill for the system prompt. + * + * @param skill - The skill to convert to XML + * @returns XML string for the skill + */ +function buildSkillXml( skill: Skill ): string { + const skillMdPath = nodePath.join( skill.path, SKILL_FILE_NAME ); + + const lines = [ + ' ', + ` ${ escapeXml( skill.name ) }`, + ` ${ escapeXml( skill.description ) }`, + ` ${ escapeXml( skillMdPath ) }`, + ]; + + // Add optional fields if present + if ( skill.allowedTools && skill.allowedTools.length > 0 ) { + lines.push( + ` ${ escapeXml( skill.allowedTools.join( ', ' ) ) }` + ); + } + + if ( skill.hasScripts ) { + lines.push( + ` ${ escapeXml( nodePath.join( skill.path, 'scripts' ) ) }` + ); + } + + if ( skill.hasReferences ) { + lines.push( + ` ${ escapeXml( + nodePath.join( skill.path, 'references' ) + ) }` + ); + } + + lines.push( ' ' ); + + return lines.join( '\n' ); +} + +/** + * Build the complete XML block for all skills to include in the system prompt. + * + * @param skills - Array of skills to include + * @returns XML string for all skills, or empty string if no skills + */ +export function buildSkillsPromptXml( skills: Skill[] ): string { + if ( skills.length === 0 ) { + return ''; + } + + const skillsXml = skills.map( buildSkillXml ).join( '\n' ); + + return ` +${ skillsXml } + + +When a user's request matches a skill's description, read the skill's SKILL.md file at the location shown to get detailed instructions for that task. The SKILL.md contains step-by-step guidance and best practices that will help you complete the task effectively.`; +} + +/** + * Build a simple skills summary for display purposes (not for prompts). + * + * @param skills - Array of skills + * @returns Human-readable summary of installed skills + */ +export function buildSkillsSummary( skills: Skill[] ): string { + if ( skills.length === 0 ) { + return 'No skills installed.'; + } + + const lines = [ `${ skills.length } skill${ skills.length > 1 ? 's' : '' } installed:` ]; + + for ( const skill of skills ) { + lines.push( `- ${ skill.name }: ${ skill.description }` ); + } + + return lines.join( '\n' ); +} diff --git a/src/modules/agent-skills/main.ts b/src/modules/agent-skills/main.ts new file mode 100644 index 0000000000..5975cc976e --- /dev/null +++ b/src/modules/agent-skills/main.ts @@ -0,0 +1,40 @@ +/** + * AgentSkills module - Main process exports. + * + * This file exports Node.js-dependent code for use in the main process only. + * For renderer code, import from './index' instead. + */ + +// Core functionality (requires Node.js fs/path) +export { + getSkillsPath, + getSkillPath, + skillExists, + discoverSiteSkills, + getSkillByName, + ensureSkillsDirectory, +} from './lib/skill-discovery'; + +export { parseSkillFile, validateSkillMetadata, extractSkillName } from './lib/skill-parser'; + +export { installSkillFromGitHub, removeSkill, listAvailableSkills } from './lib/skill-installer'; + +export { buildSkillsPromptXml, buildSkillsSummary } from './lib/skill-prompt-builder'; + +// Constants (also available from index.ts) +export { + SKILLS_DIRECTORY_PATH, + SKILL_FILE_NAME, + DEFAULT_SKILLS_REPO, + DEFAULT_BRANCH, +} from './lib/constants'; + +// Type exports +export type { + Skill, + SkillMetadata, + SkillInstallSource, + InstalledSkill, + SkillInstallResult, + AvailableSkill, +} from './types'; diff --git a/src/modules/agent-skills/types.ts b/src/modules/agent-skills/types.ts new file mode 100644 index 0000000000..007657b68c --- /dev/null +++ b/src/modules/agent-skills/types.ts @@ -0,0 +1,86 @@ +/** + * Types for AgentSkills module. + * + * AgentSkills are AI agent instructions that can be installed per-site + * to provide specialized capabilities and knowledge. + */ + +/** + * Metadata extracted from a SKILL.md file's YAML frontmatter. + */ +export interface SkillMetadata { + /** Required: skill identifier/name */ + name: string; + /** Required: what the skill does */ + description: string; + /** Optional: license (e.g., "MIT", "Apache-2.0") */ + license?: string; + /** Optional: compatibility notes (e.g., "WordPress 6.0+") */ + compatibility?: string; + /** Optional: additional metadata key-value pairs */ + metadata?: Record< string, string >; + /** Optional: tools the skill is allowed to use */ + allowedTools?: string[]; +} + +/** + * A fully parsed skill including metadata and content. + */ +export interface Skill extends SkillMetadata { + /** Absolute path to the skill directory */ + path: string; + /** Markdown content after the YAML frontmatter */ + body: string; + /** Whether the skill has a scripts/ directory */ + hasScripts: boolean; + /** Whether the skill has a references/ directory */ + hasReferences: boolean; + /** Whether the skill has an assets/ directory */ + hasAssets: boolean; +} + +/** + * Source information for installing a skill from GitHub. + */ +export interface SkillInstallSource { + type: 'github'; + /** Repository in format "owner/repo" (e.g., "WordPress/agent-skills") */ + repo: string; + /** Path within the repo to the skill (e.g., "skills/wordpress") */ + skillPath: string; + /** Branch to install from (default: "main") */ + branch?: string; +} + +/** + * Record of an installed skill for tracking purposes. + */ +export interface InstalledSkill { + /** Skill name */ + name: string; + /** Unix timestamp when the skill was installed */ + installedAt: number; + /** Where the skill was installed from */ + source: SkillInstallSource; +} + +/** + * Result of a skill installation attempt. + */ +export interface SkillInstallResult { + success: boolean; + error?: string; + skill?: Skill; +} + +/** + * Information about a skill available for installation from a repository. + */ +export interface AvailableSkill { + /** Skill name */ + name: string; + /** Skill description */ + description: string; + /** Path within the repository */ + path: string; +} From 7f44ffe067b924652f1cc2af24c4e83562b768c3 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:18:36 -0800 Subject: [PATCH 18/20] Add agent chat UI and ACP scaffolding --- bin/telex | 40 +- bin/telex.bat | 44 +- index.html | 4 +- src/components/agent-chat-view.tsx | 357 ++++++++++++ src/components/agent-code-block.tsx | 129 +++++ src/components/agent-selector/agent-icon.tsx | 77 +++ src/components/agent-selector/index.tsx | 356 ++++++++++++ src/components/agent-tool-result.tsx | 182 +++++++ src/components/ai-settings-modal.tsx | 2 +- src/components/api-key-setup.tsx | 142 +++++ src/components/assistant-icon.tsx | 21 + src/components/assistant-input.tsx | 270 ++++++++++ src/components/chat-message.tsx | 9 +- src/components/content-tab-assistant.tsx | 95 +++- src/components/message-actions.tsx | 152 ++++++ src/components/model-selector/index.tsx | 150 ++++++ src/components/site-content-tabs.tsx | 130 +++-- src/components/welcome-message-prompt.tsx | 10 +- src/hooks/use-agent-chat.ts | 160 +++++- src/hooks/use-content-tabs.tsx | 7 - src/index.css | 155 +++++- src/index.ts | 36 +- src/ipc-utils.ts | 16 + src/modules/acp/index.ts | 46 ++ src/modules/acp/lib/acp-callbacks.ts | 153 ++++++ src/modules/acp/lib/ipc-handlers.ts | 28 + src/modules/acp/types.ts | 323 +++++++++++ src/modules/add-site/components/options.tsx | 18 +- src/modules/add-site/components/site-spec.tsx | 50 ++ src/modules/add-site/hooks/use-stepper.ts | 11 + src/modules/add-site/index.tsx | 8 +- .../agent-instructions/lib/ipc-handlers.ts | 77 +++ src/modules/ai-agent/index.ts | 35 ++ src/modules/ai-agent/lib/agent-server.ts | 241 +++++++++ src/modules/ai-agent/lib/anthropic-client.ts | 407 ++++++++++++++ src/modules/ai-agent/lib/api-key-storage.ts | 77 +++ src/modules/ai-agent/lib/ipc-handlers.ts | 69 +++ src/modules/ai-agent/lib/tool-executor.ts | 48 ++ src/modules/ai-agent/lib/tools/base-tool.ts | 27 + src/modules/ai-agent/lib/tools/create-site.ts | 104 ++++ .../ai-agent/lib/tools/execute-wp-cli.ts | 118 ++++ .../ai-agent/lib/tools/explore-file-tree.ts | 82 +++ src/modules/ai-agent/lib/tools/get-sites.ts | 73 +++ .../ai-agent/lib/tools/get-theme-details.ts | 73 +++ src/modules/ai-agent/lib/tools/index.ts | 39 ++ src/modules/ai-agent/lib/tools/manage-site.ts | 79 +++ .../ai-agent/lib/tools/search-support-docs.ts | 173 ++++++ src/modules/ai-agent/lib/tools/update-site.ts | 123 +++++ .../ai-agent/lib/tools/validate-blueprint.ts | 68 +++ src/modules/ai-agent/types.ts | 171 ++++++ src/preload.ts | 46 ++ src/storage/paths.ts | 8 +- src/storage/storage-types.ts | 2 + src/storage/user-data.ts | 3 +- src/stores/agent-chat-slice.ts | 508 ++++++++++++++++++ src/stores/chat-slice.ts | 10 + src/stores/index.ts | 13 + 57 files changed, 5694 insertions(+), 161 deletions(-) create mode 100644 src/components/agent-chat-view.tsx create mode 100644 src/components/agent-code-block.tsx create mode 100644 src/components/agent-selector/agent-icon.tsx create mode 100644 src/components/agent-selector/index.tsx create mode 100644 src/components/agent-tool-result.tsx create mode 100644 src/components/api-key-setup.tsx create mode 100644 src/components/assistant-icon.tsx create mode 100644 src/components/assistant-input.tsx create mode 100644 src/components/message-actions.tsx create mode 100644 src/components/model-selector/index.tsx create mode 100644 src/modules/acp/index.ts create mode 100644 src/modules/acp/lib/acp-callbacks.ts create mode 100644 src/modules/acp/types.ts create mode 100644 src/modules/add-site/components/site-spec.tsx create mode 100644 src/modules/agent-instructions/lib/ipc-handlers.ts create mode 100644 src/modules/ai-agent/index.ts create mode 100644 src/modules/ai-agent/lib/agent-server.ts create mode 100644 src/modules/ai-agent/lib/anthropic-client.ts create mode 100644 src/modules/ai-agent/lib/api-key-storage.ts create mode 100644 src/modules/ai-agent/lib/ipc-handlers.ts create mode 100644 src/modules/ai-agent/lib/tool-executor.ts create mode 100644 src/modules/ai-agent/lib/tools/base-tool.ts create mode 100644 src/modules/ai-agent/lib/tools/create-site.ts create mode 100644 src/modules/ai-agent/lib/tools/execute-wp-cli.ts create mode 100644 src/modules/ai-agent/lib/tools/explore-file-tree.ts create mode 100644 src/modules/ai-agent/lib/tools/get-sites.ts create mode 100644 src/modules/ai-agent/lib/tools/get-theme-details.ts create mode 100644 src/modules/ai-agent/lib/tools/index.ts create mode 100644 src/modules/ai-agent/lib/tools/manage-site.ts create mode 100644 src/modules/ai-agent/lib/tools/search-support-docs.ts create mode 100644 src/modules/ai-agent/lib/tools/update-site.ts create mode 100644 src/modules/ai-agent/lib/tools/validate-blueprint.ts create mode 100644 src/modules/ai-agent/types.ts create mode 100644 src/stores/agent-chat-slice.ts diff --git a/bin/telex b/bin/telex index e227763b7b..a248d85978 100755 --- a/bin/telex +++ b/bin/telex @@ -1,20 +1,38 @@ #!/bin/sh -# Telex stub - returns upgrade message for users not on a Telex-enabled plan +# Telex stub - Feature gate with agent-aware messaging cat << 'EOF' ╭────────────────────────────────────────────────────────╮ -│ │ -│ Telex is not available on your current plan │ -│ │ -│ Telex is a premium feature that provides a │ -│ sandboxed AI environment for building WordPress │ -│ blocks, plugins, and themes. │ -│ │ -│ To access Telex, please upgrade your plan at: │ -│ https://wordpress.com/plans │ -│ │ +│ FEATURE UNAVAILABLE: Telex │ ╰────────────────────────────────────────────────────────╯ + +WHAT IS TELEX? +Telex is a premium AI-powered development environment for WordPress. +It enables agents to generate and edit WordPress blocks, plugins, and +themes through conversational commands. + +Commands available with Telex: + • telex gen block - Generate WordPress blocks + • telex gen plugin - Generate WordPress plugins + • telex gen theme - Generate WordPress themes + • telex edit block - Edit blocks conversationally + • telex chat - WordPress development assistance + +WHY IS IT UNAVAILABLE? +Telex is not included in the current WordPress.com plan. + +SUGGESTED AGENT ACTION: +1. Inform the user that Telex is required for this task +2. Explain what Telex does and why it would be helpful +3. Offer to open the upgrade page for them +4. If user agrees, open: https://wordpress.com/plans?feature=telex&utm_source=studio&utm_medium=agent&utm_campaign=telex-upsell + +ALTERNATIVE APPROACHES: +Consider if the task can be accomplished without Telex using: + • Direct file editing with available tools + • WordPress CLI commands (wp-cli) + • Manual block/plugin development guidance EOF exit 1 diff --git a/bin/telex.bat b/bin/telex.bat index c59fd10706..ed66ba4621 100644 --- a/bin/telex.bat +++ b/bin/telex.bat @@ -1,19 +1,37 @@ @echo off -REM Telex stub - returns upgrade message for users not on a Telex-enabled plan +REM Telex stub - Feature gate with agent-aware messaging echo. -echo +--------------------------------------------------------+ -echo ^| ^| -echo ^| Telex is not available on your current plan ^| -echo ^| ^| -echo ^| Telex is a premium feature that provides a ^| -echo ^| sandboxed AI environment for building WordPress ^| -echo ^| blocks, plugins, and themes. ^| -echo ^| ^| -echo ^| To access Telex, please upgrade your plan at: ^| -echo ^| https://wordpress.com/plans ^| -echo ^| ^| -echo +--------------------------------------------------------+ +echo +------------------------------------------------------------+ +echo ^| FEATURE UNAVAILABLE: Telex ^| +echo +------------------------------------------------------------+ +echo. +echo WHAT IS TELEX? +echo Telex is a premium AI-powered development environment for WordPress. +echo It enables agents to generate and edit WordPress blocks, plugins, and +echo themes through conversational commands. +echo. +echo Commands available with Telex: +echo * telex gen block ^ - Generate WordPress blocks +echo * telex gen plugin ^ - Generate WordPress plugins +echo * telex gen theme ^ - Generate WordPress themes +echo * telex edit block ^ - Edit blocks conversationally +echo * telex chat - WordPress development assistance +echo. +echo WHY IS IT UNAVAILABLE? +echo Telex is not included in the current WordPress.com plan. +echo. +echo SUGGESTED AGENT ACTION: +echo 1. Inform the user that Telex is required for this task +echo 2. Explain what Telex does and why it would be helpful +echo 3. Offer to open the upgrade page for them +echo 4. If user agrees, open: https://wordpress.com/plans?feature=telex^&utm_source=studio^&utm_medium=agent^&utm_campaign=telex-upsell +echo. +echo ALTERNATIVE APPROACHES: +echo Consider if the task can be accomplished without Telex using: +echo * Direct file editing with available tools +echo * WordPress CLI commands (wp-cli) +echo * Manual block/plugin development guidance echo. exit /b 1 diff --git a/index.html b/index.html index 86705b27ab..2cee51c869 100644 --- a/index.html +++ b/index.html @@ -3,11 +3,11 @@ - + WordPress Studio
- \ No newline at end of file + diff --git a/src/components/agent-chat-view.tsx b/src/components/agent-chat-view.tsx new file mode 100644 index 0000000000..856f31f033 --- /dev/null +++ b/src/components/agent-chat-view.tsx @@ -0,0 +1,357 @@ +import { __ } from '@wordpress/i18n'; +import { useEffect, useRef, useCallback, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { AgentCodeBlock } from 'src/components/agent-code-block'; +import { AgentSelector } from 'src/components/agent-selector'; +import { AgentToolResultsList } from 'src/components/agent-tool-result'; +import { AiSettingsModal } from 'src/components/ai-settings-modal'; +import { ApiKeySetup, ApiKeySetupBanner } from 'src/components/api-key-setup'; +import { AssistantInput } from 'src/components/assistant-input'; +import { MessageThinking } from 'src/components/assistant-thinking'; +import { ChatMessage } from 'src/components/chat-message'; +import { ModelSelector } from 'src/components/model-selector'; +import WelcomeComponent from 'src/components/welcome-message-prompt'; +import { useAgentChat } from 'src/hooks/use-agent-chat'; +import { cx } from 'src/lib/cx'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { + agentChatActions, + agentChatSelectors, + agentChatThunks, + AgentMessage, +} from 'src/stores/agent-chat-slice'; +import { useGetWelcomeMessages } from 'src/stores/wpcom-api'; +import type { ContentBlock } from 'src/modules/ai-agent/types'; + +interface AgentChatViewProps { + selectedSite: SiteDetails; +} + +function renderContentBlock( + block: ContentBlock, + index: number, + toolCalls?: AgentMessage[ 'toolCalls' ] +) { + if ( block.type === 'text' && block.text ) { + // Heading style override - uniform weight + const headingStyle = { fontWeight: 600, fontSize: 'inherit', margin: '1rem 0 0.5rem 0' }; + return ( +
+ <>{ children }, + // Normalize heading styles + h1: ( { children } ) =>

{ children }

, + h2: ( { children } ) =>

{ children }

, + h3: ( { children } ) =>

{ children }

, + h4: ( { children } ) =>

{ children }

, + h5: ( { children } ) =>
{ children }
, + h6: ( { children } ) =>
{ children }
, + } } + > + { block.text } +
+
+ ); + } + + if ( block.type === 'tool_call' && block.toolCall ) { + // Find the matching tool call with result from toolCalls array + const toolCallWithResult = toolCalls?.find( ( tc ) => tc.id === block.toolCall.id ); + const toolCallToRender = toolCallWithResult || block.toolCall; + + return ; + } + + return null; +} + +function AgentMessageContent( { message }: { message: AgentMessage } ) { + // Use contentBlocks for interleaved rendering if available + const hasContentBlocks = message.contentBlocks && message.contentBlocks.length > 0; + + return ( +
+ { hasContentBlocks ? ( + // Render content blocks in order (interleaved text and tool calls) + message.contentBlocks!.map( ( block, index ) => + renderContentBlock( block, index, message.toolCalls ) + ) + ) : ( + // Fallback to old rendering for backwards compatibility + <> + { message.content && ( +
+ <>{ children }, + // Normalize heading styles + h1: ( { children } ) => ( +

+ { children } +

+ ), + h2: ( { children } ) => ( +

+ { children } +

+ ), + h3: ( { children } ) => ( +

+ { children } +

+ ), + h4: ( { children } ) => ( +

+ { children } +

+ ), + h5: ( { children } ) => ( +
+ { children } +
+ ), + h6: ( { children } ) => ( +
+ { children } +
+ ), + } } + > + { message.content } +
+
+ ) } + { message.toolCalls && message.toolCalls.length > 0 && ( + + ) } + + ) } + { message.error && ( +
+ { message.error } +
+ ) } +
+ ); +} + +export function AgentChatView( { selectedSite }: AgentChatViewProps ) { + const dispatch = useAppDispatch(); + const inputRef = useRef< HTMLTextAreaElement >( null ); + const messagesEndRef = useRef< HTMLDivElement >( null ); + const scrollContainerRef = useRef< HTMLDivElement >( null ); + const wrapperRef = useRef< HTMLDivElement >( null ); + const shouldAutoScrollRef = useRef( true ); + + const { + messages, + isLoading, + isStreaming, + sendMessage, + clearConversation, + instanceId, + availableModels, + currentModelId, + setModel, + } = useAgentChat( { siteId: selectedSite.id } ); + + const isConfigured = useRootSelector( agentChatSelectors.selectIsConfigured ); + const serverPort = useRootSelector( agentChatSelectors.selectAgentServerPort ); + const chatInput = useRootSelector( ( state ) => + agentChatSelectors.selectChatInput( state, selectedSite.id ) + ); + + const [ showApiKeySetup, setShowApiKeySetup ] = useState( false ); + const [ showAiSettingsModal, setShowAiSettingsModal ] = useState( false ); + + // Get welcome messages and example prompts from API (same as WordPress.com chat) + const { data: welcomeData, isLoading: isLoadingWelcome } = useGetWelcomeMessages(); + + // Check agent status on mount + useEffect( () => { + if ( serverPort ) { + void dispatch( agentChatThunks.checkAgentStatus() ); + } + }, [ dispatch, serverPort ] ); + + // Extract last message content for dependency array + const lastMessageContent = messages[ messages.length - 1 ]?.content; + + // Check if user is near the bottom of the scroll container + const isNearBottom = useCallback( () => { + const container = scrollContainerRef.current; + if ( ! container ) { + return true; + } + const threshold = 100; // pixels from bottom + return container.scrollHeight - container.scrollTop - container.clientHeight < threshold; + }, [] ); + + // Handle scroll events to detect when user scrolls away from bottom + useEffect( () => { + const container = scrollContainerRef.current; + if ( ! container ) { + return; + } + + const handleScroll = () => { + shouldAutoScrollRef.current = isNearBottom(); + }; + + container.addEventListener( 'scroll', handleScroll ); + return () => container.removeEventListener( 'scroll', handleScroll ); + }, [ isNearBottom ] ); + + // Scroll to bottom when messages change (only if user hasn't scrolled up) + useEffect( () => { + if ( messagesEndRef.current && shouldAutoScrollRef.current ) { + messagesEndRef.current.scrollIntoView( { behavior: 'smooth' } ); + } + }, [ messages.length, lastMessageContent ] ); + + const handleSendMessage = useCallback( () => { + const message = chatInput.trim(); + if ( ! message ) { + return; + } + + dispatch( agentChatActions.setChatInput( { siteId: selectedSite.id, input: '' } ) ); + void sendMessage( message ); + }, [ chatInput, dispatch, selectedSite.id, sendMessage ] ); + + const handleExampleClick = useCallback( + ( prompt: string ) => { + void sendMessage( prompt ); + inputRef.current?.focus(); + }, + [ sendMessage ] + ); + + const handleClearConversation = useCallback( () => { + dispatch( agentChatActions.setChatInput( { siteId: selectedSite.id, input: '' } ) ); + clearConversation(); + inputRef.current?.focus(); + }, [ dispatch, selectedSite.id, clearConversation ] ); + + // Show API key setup if not configured + if ( ! isConfigured ) { + return ( +
+ setShowApiKeySetup( true ) } /> + setShowApiKeySetup( false ) } + onSuccess={ () => dispatch( agentChatActions.setIsConfigured( true ) ) } + /> +
+ ); + } + + const hasMessages = messages.length > 0; + const isAssistantThinking = isLoading || isStreaming; + + return ( +
+ { /* Messages area - scrollable */ } +
+
+ { /* Welcome message and suggestions (same as WordPress.com chat) */ } + + + { /* Chat messages */ } + { messages.map( ( message ) => ( + + { message.role === 'user' ? ( + message.content + ) : ( + + ) } + { message.isStreaming && ! message.content && } + + ) ) } +
+
+
+ + { /* Gradient fade - messages scroll behind this */ } +
+ + { /* Input area */ } +
+
+ { + dispatch( agentChatActions.setChatInput( { siteId: selectedSite.id, input } ) ); + } } + handleSend={ handleSendMessage } + handleKeyDown={ ( event ) => { + if ( event.key === 'Enter' && ! event.shiftKey ) { + event.preventDefault(); + handleSendMessage(); + } + } } + onClearConversation={ handleClearConversation } + onOpenAgentSettings={ () => setShowAiSettingsModal( true ) } + onOpenSkillSettings={ () => setShowAiSettingsModal( true ) } + onOpenInstructionSettings={ () => setShowAiSettingsModal( true ) } + isLoading={ isAssistantThinking } + agentSelector={ } + modelSelector={ + availableModels && currentModelId ? ( + + ) : undefined + } + /> +
+
+ + setShowApiKeySetup( false ) } + onSuccess={ () => dispatch( agentChatActions.setIsConfigured( true ) ) } + /> + + setShowAiSettingsModal( false ) } + siteId={ selectedSite.id } + /> +
+ ); +} diff --git a/src/components/agent-code-block.tsx b/src/components/agent-code-block.tsx new file mode 100644 index 0000000000..fe30927c34 --- /dev/null +++ b/src/components/agent-code-block.tsx @@ -0,0 +1,129 @@ +import { __ } from '@wordpress/i18n'; +import { ExtraProps } from 'react-markdown'; +import { CopyTextButton } from 'src/components/copy-text-button'; + +export type AgentCodeBlockProps = JSX.IntrinsicElements[ 'code' ] & ExtraProps; + +/** + * Extract language name from className (e.g., "language-javascript" -> "javascript") + */ +function getLanguageFromClassName( className?: string ): string | null { + if ( ! className ) { + return null; + } + const match = /language-(\w+)/.exec( className ); + return match ? match[ 1 ] : null; +} + +/** + * Format language name for display + */ +function formatLanguageName( language: string ): string { + const languageMap: Record< string, string > = { + js: 'JavaScript', + javascript: 'JavaScript', + ts: 'TypeScript', + typescript: 'TypeScript', + jsx: 'JSX', + tsx: 'TSX', + php: 'PHP', + css: 'CSS', + scss: 'SCSS', + html: 'HTML', + json: 'JSON', + yaml: 'YAML', + yml: 'YAML', + sh: 'Shell', + bash: 'Bash', + sql: 'SQL', + md: 'Markdown', + markdown: 'Markdown', + xml: 'XML', + python: 'Python', + py: 'Python', + ruby: 'Ruby', + rb: 'Ruby', + go: 'Go', + rust: 'Rust', + java: 'Java', + c: 'C', + cpp: 'C++', + csharp: 'C#', + swift: 'Swift', + kotlin: 'Kotlin', + }; + return languageMap[ language.toLowerCase() ] || language.toUpperCase(); +} + +export function AgentCodeBlock( { children, className, node, ...props }: AgentCodeBlockProps ) { + const content = String( children ).replace( /\n$/, '' ); + const language = getLanguageFromClassName( className ); + const isInline = + ! node?.position?.start.line || node?.position?.start.line === node?.position?.end.line; + + // Inline code - just render as simple code element + // Using inline styles to override .assistant-markdown code CSS + if ( isInline && ! language ) { + return ( + + { children } + + ); + } + + // Block code with header + // Using inline styles to override .assistant-markdown pre/code CSS + return ( +
+ { /* Header */ } +
+ + { language ? formatLanguageName( language ) : __( 'Code' ) } + + +
+ { /* Code content */ } +
+				
+					{ content }
+				
+			
+
+ ); +} diff --git a/src/components/agent-selector/agent-icon.tsx b/src/components/agent-selector/agent-icon.tsx new file mode 100644 index 0000000000..06cb5eab20 --- /dev/null +++ b/src/components/agent-selector/agent-icon.tsx @@ -0,0 +1,77 @@ +/** + * Agent Icon Component + * + * Renders an agent's icon using local SVG icons based on agent ID. + * Returns null if no icon is available for the agent. + */ + +import { memo } from 'react'; +import { AGENT_ICONS } from 'src/modules/acp/config/agents'; + +interface AgentIconProps { + agentId?: string; + icon?: string; + size?: number; + className?: string; +} + +/** + * Check if a string is an inline SVG (starts with = { + // Built-in agents + wpcom: AGENT_ICONS.wpcom, + 'anthropic-builtin': AGENT_ICONS.anthropic, + // Registry agents (from cdn.agentclientprotocol.com) + 'claude-code-acp': AGENT_ICONS.claudeCode, + 'codex-acp': AGENT_ICONS.codex, + gemini: AGENT_ICONS.gemini, + 'github-copilot': AGENT_ICONS.copilot, + opencode: AGENT_ICONS.opencode, + }; + + return iconMap[ agentId ]; +} + +export const AgentIcon = memo( function AgentIcon( { + agentId, + icon, + size = 24, + className = '', +}: AgentIconProps ) { + // Get local icon based on agent ID + const localIcon = getLocalIconForAgent( agentId ); + + // Use local icon if available, otherwise use provided icon if it's inline SVG + const iconSource = localIcon ?? ( icon && isInlineSvg( icon ) ? icon : undefined ); + + // Don't render anything if no icon + if ( ! iconSource ) { + return null; + } + + return ( +
) } -
+
{ groupedAgents.map( ( group ) => (
{ group.agents.length > 0 && ( diff --git a/src/components/api-key-setup.tsx b/src/components/api-key-setup.tsx new file mode 100644 index 0000000000..7a4f726d35 --- /dev/null +++ b/src/components/api-key-setup.tsx @@ -0,0 +1,142 @@ +import { TextControl } from '@wordpress/components'; +import { Icon, cog } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState, useCallback } from 'react'; +import Button from 'src/components/button'; +import Modal from 'src/components/modal'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; + +interface ApiKeySetupProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export function ApiKeySetup( { isOpen, onClose, onSuccess }: ApiKeySetupProps ) { + const { __ } = useI18n(); + const [ apiKey, setApiKey ] = useState( '' ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + const handleSubmit = useCallback( async () => { + if ( ! apiKey.trim() ) { + setError( __( 'Please enter an API key' ) ); + return; + } + + setIsLoading( true ); + setError( null ); + + try { + const result = await getIpcApi().configureAgentApiKey( apiKey.trim() ); + + if ( result.success ) { + setApiKey( '' ); + onSuccess(); + onClose(); + } else { + setError( result.error || __( 'Failed to configure API key' ) ); + } + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setIsLoading( false ); + } + }, [ apiKey, onClose, onSuccess, __ ] ); + + const handleKeyDown = useCallback( + ( e: React.KeyboardEvent ) => { + if ( e.key === 'Enter' && ! isLoading ) { + void handleSubmit(); + } + }, + [ handleSubmit, isLoading ] + ); + + if ( ! isOpen ) { + return null; + } + + return ( + +
+

+ { __( + 'Enter your Anthropic API key to enable the AI assistant. Your key will be stored securely on your device.' + ) } +

+ +
+ + + { error &&

{ error }

} +
+ +

+ { __( 'Get your API key from ' ) } + { + e.preventDefault(); + getIpcApi().openURL( 'https://console.anthropic.com/settings/keys' ); + } } + > + { __( 'Anthropic Console' ) } + +

+ +
+ + +
+
+
+ ); +} + +interface ApiKeySetupBannerProps { + onConfigure: () => void; +} + +export function ApiKeySetupBanner( { onConfigure }: ApiKeySetupBannerProps ) { + const { __ } = useI18n(); + + return ( +
+
+ +
+

{ __( 'AI Assistant Setup Required' ) }

+

+ { __( + 'To use the AI assistant, you need to configure your Anthropic API key. The assistant uses Claude to help you manage your WordPress sites.' + ) } +

+ +
+ ); +} diff --git a/src/components/assistant-icon.tsx b/src/components/assistant-icon.tsx new file mode 100644 index 0000000000..595b91e5bd --- /dev/null +++ b/src/components/assistant-icon.tsx @@ -0,0 +1,21 @@ +export function AssistantIcon( { + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +} ) { + return ( + + + + ); +} diff --git a/src/components/assistant-input.tsx b/src/components/assistant-input.tsx new file mode 100644 index 0000000000..4162be9b59 --- /dev/null +++ b/src/components/assistant-input.tsx @@ -0,0 +1,270 @@ +import { DropdownMenu, MenuGroup, MenuItem, Tooltip } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Icon, arrowUp, plus, moreVertical } from '@wordpress/icons'; +import React, { forwardRef, useRef, useEffect, useState } from 'react'; +import { cx } from 'src/lib/cx'; + +interface AssistantInputProps { + disabled: boolean; + input: string; + setInput: ( input: string ) => void; + handleSend: () => void; + handleKeyDown?: ( e: React.KeyboardEvent< HTMLTextAreaElement > ) => void; + isLoading?: boolean; + placeholder?: string; + onClearConversation?: () => void; + /** Optional slot for agent selector (rendered next to extended thinking) */ + agentSelector?: React.ReactNode; + /** Optional slot for model selector (rendered next to agent selector) */ + modelSelector?: React.ReactNode; + /** Optional footer content (e.g., disclaimer) */ + footer?: React.ReactNode; + /** Callback when agent settings is clicked */ + onOpenAgentSettings?: () => void; + /** Callback when skill settings is clicked */ + onOpenSkillSettings?: () => void; + /** Callback when instruction settings is clicked */ + onOpenInstructionSettings?: () => void; +} + +const MAX_ROWS = 10; + +// Clock/timer icon for thinking mode (kept for future use) +const _ThinkingIcon = () => ( + + + + +); + +const UnforwardedAssistantInput = ( + { + disabled, + input, + setInput, + handleSend, + handleKeyDown, + isLoading, + placeholder, + onClearConversation, + agentSelector, + modelSelector, + footer, + onOpenAgentSettings, + onOpenSkillSettings, + onOpenInstructionSettings, + }: AssistantInputProps, + inputRef: React.RefObject< HTMLTextAreaElement > | React.RefCallback< HTMLTextAreaElement > | null +) => { + const [ thinkingDuration, setThinkingDuration ] = useState< + 'short' | 'medium' | 'long' | 'veryLong' + >( 'short' ); + const thinkingTimeout = useRef< NodeJS.Timeout[] >( [] ); + + useEffect( () => { + if ( ! disabled && inputRef && 'current' in inputRef && inputRef.current ) { + inputRef.current?.focus(); + } + }, [ disabled, inputRef ] ); + + const handleInput = ( e: React.ChangeEvent< HTMLTextAreaElement > ) => { + setInput( e.target.value ); + + if ( inputRef && 'current' in inputRef && inputRef.current ) { + inputRef.current.style.height = 'auto'; + const lineHeight = parseInt( window.getComputedStyle( inputRef.current ).lineHeight, 10 ); + const maxHeight = MAX_ROWS * lineHeight; + inputRef.current.style.height = `${ Math.min( inputRef.current.scrollHeight, maxHeight ) }px`; + inputRef.current.style.overflowY = + inputRef.current.scrollHeight > maxHeight ? 'auto' : 'hidden'; + inputRef.current.scrollTop = inputRef.current.scrollHeight; + } + }; + + const handleKeyDownWrapper = ( e: React.KeyboardEvent< HTMLTextAreaElement > ) => { + if ( e.key === 'Enter' && ! e.shiftKey ) { + e.preventDefault(); + if ( isLoading ) { + return; + } + if ( input.trim() !== '' ) { + handleSend(); + if ( inputRef && 'current' in inputRef && inputRef.current ) { + inputRef.current.style.height = 'auto'; + } + } + } else if ( e.key === 'Enter' && e.shiftKey ) { + return; + } else if ( handleKeyDown ) { + handleKeyDown( e ); + } + }; + + useEffect( () => { + function clearThinkingTimeouts() { + thinkingTimeout.current.forEach( clearTimeout ); + thinkingTimeout.current = []; + } + if ( isLoading ) { + thinkingTimeout.current.push( + setTimeout( () => setThinkingDuration( 'medium' ), 3000 ), + setTimeout( () => setThinkingDuration( 'long' ), 6000 ), + setTimeout( () => setThinkingDuration( 'veryLong' ), 10000 ) + ); + } else { + clearThinkingTimeouts(); + setThinkingDuration( 'short' ); + } + return () => clearThinkingTimeouts(); + }, [ isLoading ] ); + + const getPlaceholderText = () => { + if ( placeholder ) { + return placeholder; + } + if ( isLoading ) { + switch ( thinkingDuration ) { + case 'veryLong': + return __( 'Stick with me...' ); + case 'long': + return __( 'This is taking a little longer than I thought...' ); + case 'medium': + return __( 'Still working on it...' ); + default: + return __( 'Thinking about that...' ); + } + } + return __( 'Ask anything' ); + }; + + const canSend = input.trim() !== '' && ! disabled && ! isLoading; + + return ( +
+ { /* Textarea row */ } +