diff --git a/README.md b/README.md index 0ab8420..3166a55 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,23 @@ To install the latest development version of this package, use the following com wp package install mcp-wp/ai-command:dev-main ``` -Right now, the plugin requires a WordPress site with the [AI Services plugin](https://wordpress.org/plugins/ai-services) installed. +This package uses the **WP AI Client** (available in WordPress 7.0+ or via the AI plugin) for AI functionality. + +### Configuration + +Configure credentials for AI providers: + +```bash +# Configure credentials for AI providers +wp ai credentials set openai sk-proj-YOUR-API-KEY +wp ai credentials set anthropic sk-ant-YOUR-API-KEY +wp ai credentials set google YOUR-GOOGLE-API-KEY + +# List configured credentials +wp ai credentials list +``` + +Credentials are stored in the WordPress database and can also be managed through the WordPress admin settings screen. ### Reporting a bug diff --git a/ai-command.php b/ai-command.php index d959925..9a64f8d 100644 --- a/ai-command.php +++ b/ai-command.php @@ -12,7 +12,8 @@ require_once __DIR__ . '/vendor/autoload.php'; } -WP_CLI::add_command( 'ai', AiCommand::class ); -WP_CLI::add_command( 'mcp prompt', AiCommand::class ); -WP_CLI::add_command( 'mcp', McpCommand::class ); -WP_CLI::add_command( 'mcp server', McpServerCommand::class ); +WP_CLI::add_command( 'ai', \McpWp\AiCommand\AiCommand::class ); +WP_CLI::add_command( 'ai credentials', \McpWp\AiCommand\CredentialsCommand::class ); +WP_CLI::add_command( 'mcp prompt', \McpWp\AiCommand\AiCommand::class ); +WP_CLI::add_command( 'mcp', \McpWp\AiCommand\McpCommand::class ); +WP_CLI::add_command( 'mcp server', \McpWp\AiCommand\McpServerCommand::class ); diff --git a/composer.json b/composer.json index fdc41c4..52412eb 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "roave/security-advisories": "dev-latest", "wp-cli/extension-command": "^2.1", "wp-cli/wp-cli-tests": "^v4.3.9", - "wpackagist-plugin/ai-services": "^0.6.0" + "wordpress/wp-ai-client": "^0.2.1" }, "repositories":[ { @@ -49,6 +49,9 @@ "bundled": false, "commands": [ "ai", + "ai credentials list", + "ai credentials set", + "ai credentials delete", "mcp prompt", "mcp server add", "mcp server list", diff --git a/features/ai-credentials.feature b/features/ai-credentials.feature new file mode 100644 index 0000000..13f8565 --- /dev/null +++ b/features/ai-credentials.feature @@ -0,0 +1,12 @@ +Feature: AI Credentials command + Scenario: Credentials management with WP AI Client + Given a WP installation + + When I try `wp ai credentials list` + Then STDERR should contain: + """ + The WP AI Client is not available. + """ + + # TODO: Add tests for when WP AI Client is available + # This would require installing the AI plugin or WordPress 7.0+ diff --git a/features/ai.feature b/features/ai.feature index ffbeab4..38655c6 100644 --- a/features/ai.feature +++ b/features/ai.feature @@ -1,5 +1,5 @@ Feature: AI command - Scenario: Missing AI Services plugin + Scenario: Missing WP AI Client When I try `wp ai "Hello World"` Then STDERR should contain: """ @@ -16,12 +16,5 @@ Feature: AI command When I try `wp ai "Hello World"` Then STDERR should contain: """ - This command currently requires the AI Services plugin. - """ - - When I run `wp plugin install ai-services --activate` - When I try `wp ai "Hello World"` - Then STDERR should contain: - """ - No service satisfying the given arguments is registered and available. + This command requires the WP AI Client. """ diff --git a/src/AI/WpAiClient.php b/src/AI/WpAiClient.php new file mode 100644 index 0000000..49b3236 --- /dev/null +++ b/src/AI/WpAiClient.php @@ -0,0 +1,144 @@ +>, server: string, callback: callable} + */ +class WpAiClient { + private bool $needs_approval = true; + + /** + * @param array $tools List of tools. + * @param bool $approval_mode Whether tool usage needs to be approved. + * @param string|null $service Service to use. + * @param string|null $model Model to use. + * + * @phpstan-param ToolDefinition[] $tools + */ + public function __construct( + private readonly array $tools, + private readonly bool $approval_mode, + private readonly ?string $service, + private readonly ?string $model + ) {} + + /** + * Calls a given tool. + * + * @param string $tool_name Tool name. + * @param mixed $tool_args Tool args. + * @return mixed + */ + private function call_tool( string $tool_name, mixed $tool_args ): mixed { + foreach ( $this->tools as $tool ) { + if ( $tool_name === $tool['name'] ) { + return call_user_func( $tool['callback'], $tool_args ); + } + } + + throw new InvalidArgumentException( 'Tool "' . $tool_name . '" not found.' ); + } + + /** + * Returns the name of the server a given tool is coming from. + * + * @param string $tool_name Tool name. + * @return mixed + */ + private function get_tool_server_name( string $tool_name ): mixed { + foreach ( $this->tools as $tool ) { + if ( $tool_name === $tool['name'] ) { + return $tool['server']; + } + } + + throw new InvalidArgumentException( 'Tool "' . $tool_name . '" not found.' ); + } + + /** + * Calls AI service with a prompt. + * + * @param string $prompt The prompt to send. + */ + public function call_ai_service_with_prompt( string $prompt ): void { + try { + // Initialize WP AI Client if not already done. + if ( ! did_action( 'init' ) ) { + do_action( 'init' ); + } + + \WordPress\AI_Client\AI_Client::init(); + + // Create a prompt builder. + $prompt_builder = \WordPress\AI_Client\AI_Client::prompt( $prompt ); + + // Apply model preference if specified. + if ( $this->service && $this->model ) { + $prompt_builder = $prompt_builder->using_model_preference( [ $this->service, $this->model ] ); + } elseif ( $this->model ) { + // If only model is specified without a service, try common providers. + // This provides a reasonable fallback that works with most configurations. + // The WP AI Client will automatically use the first available provider + // that has the specified model and is properly configured. + $prompt_builder = $prompt_builder->using_model_preference( + [ 'anthropic', $this->model ], + [ 'openai', $this->model ], + [ 'google', $this->model ] + ); + } + + // Generate text response. + $text = $prompt_builder->generate_text(); + + // Output the response. + WP_CLI::line( WP_CLI::colorize( "%G$text%n" ) ); + + // Keep the session open for follow-up questions. + $this->continue_conversation( $prompt, $text ); + + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Continues the conversation with follow-up prompts. + * + * @param string $initial_prompt The initial prompt. + * @param string $response The AI response. + */ + private function continue_conversation( string $initial_prompt, string $response ): void { + $user_response = prompt( '', false, '' ); + + if ( empty( $user_response ) ) { + return; + } + + try { + $prompt_builder = \WordPress\AI_Client\AI_Client::prompt( $user_response ); + + if ( $this->service && $this->model ) { + $prompt_builder = $prompt_builder->using_model_preference( [ $this->service, $this->model ] ); + } + + $text = $prompt_builder->generate_text(); + + WP_CLI::line( WP_CLI::colorize( "%G$text%n" ) ); + + $this->continue_conversation( $user_response, $text ); + } catch ( Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } +} diff --git a/src/AiCommand.php b/src/AiCommand.php index 6e2ca5d..270cdef 100644 --- a/src/AiCommand.php +++ b/src/AiCommand.php @@ -2,12 +2,7 @@ namespace McpWp\AiCommand; -use Mcp\Client\ClientSession; -use McpWp\AiCommand\AI\AiClient; -use McpWp\AiCommand\MCP\Client; -use McpWp\AiCommand\Utils\CliLogger; -use McpWp\AiCommand\Utils\McpConfig; -use McpWp\MCP\Servers\WordPress\WordPress; +use McpWp\AiCommand\AI\WpAiClient; use WP_CLI; use WP_CLI\Utils; use WP_CLI_Command; @@ -15,9 +10,7 @@ /** * AI command class. * - * Allows interacting with an LLM using MCP. - * - * @phpstan-import-type ToolDefinition from AiClient + * Allows interacting with an LLM using the WP AI Client. */ class AiCommand extends WP_CLI_Command { @@ -29,38 +22,26 @@ class AiCommand extends WP_CLI_Command { * * : AI prompt. * - * [--skip-builtin-servers[=]] - * : Skip loading the built-in servers for WP-CLI and the current WordPress site. - * Can be set to 'all' (skip both), 'cli' (skip the WP-CLI server), - * or 'wp' (skip the WordPress server). - * * [--skip-wordpress] * : Run command without loading WordPress. (Not implemented yet) * - * [--approval-mode] - * : Approve tool usage before running. - * * [--service=] * : Manually specify the AI service to use. - * Depends on the available AI services. * Examples: 'google', 'anthropic', 'openai'. * * [--model=] * : Manually specify the LLM model that should be used. - * Depends on the available AI services. - * Examples: 'gemini-2.0-flash', 'gpt-4o'. + * Examples: 'gemini-2.0-flash', 'gpt-4o', 'claude-sonnet-4-5'. * * ## EXAMPLES * - * # Get data from WordPress - * $ wp ai "What are the titles of my last three posts?" - * - Hello world - * - My awesome post - * - Another post + * # Ask a simple question + * $ wp ai "Explain WordPress in one sentence" + * WordPress is a free and open-source content management system... * - * # Interact with multiple MCP servers. - * $ wp ai "Take file foo.txt and create a new blog post from it" - * Success: Blog post created. + * # Use a specific model + * $ wp ai "Summarize the history of WordPress" --model=gpt-4o + * WordPress was created in 2003... * * @when before_wp_load * @@ -75,21 +56,15 @@ public function __invoke( array $args, array $assoc_args ): void { WP_CLI::error( 'Not implemented yet.' ); } - if ( ! function_exists( '\ai_services' ) ) { - WP_CLI::error( 'This command currently requires the AI Services plugin. You can install it with `wp plugin install ai-services --activate`.' ); + // Ensure WP AI Client is available. + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'This command requires the WP AI Client. Please ensure WordPress 7.0+ or the AI plugin is installed and activated.' ); } - $skip_builtin_servers = Utils\get_flag_value( $assoc_args, 'skip-builtin-servers', 'all' ); - - $sessions = $this->get_sessions( $with_wordpress && 'cli' === $skip_builtin_servers, 'wp' === $skip_builtin_servers ); - $tools = $this->get_tools( $sessions ); - - $approval_mode = (bool) Utils\get_flag_value( $assoc_args, 'approval-mode', false ); - $service = Utils\get_flag_value( $assoc_args, 'service' ); - $model = Utils\get_flag_value( $assoc_args, 'model' ); - - $ai_client = new AiClient( $tools, $approval_mode, $service, $model ); + $service = Utils\get_flag_value( $assoc_args, 'service' ); + $model = Utils\get_flag_value( $assoc_args, 'model' ); + $ai_client = new WpAiClient( [], false, $service, $model ); $ai_client->call_ai_service_with_prompt( $args[0] ); } diff --git a/src/CredentialsCommand.php b/src/CredentialsCommand.php new file mode 100644 index 0000000..bfa90c8 --- /dev/null +++ b/src/CredentialsCommand.php @@ -0,0 +1,142 @@ + $assoc_args Associative arguments. + */ + public function list( array $args, array $assoc_args ): void { + $this->ensure_wp_ai_client_available(); + + WP_CLI::get_runner()->load_wordpress(); + + $credentials = get_option( 'wp_ai_client_provider_credentials', [] ); + + if ( empty( $credentials ) ) { + WP_CLI::log( 'No credentials configured.' ); + return; + } + + $rows = []; + foreach ( $credentials as $provider => $data ) { + $rows[] = [ + 'provider' => $provider, + 'status' => ! empty( $data['api_key'] ) ? 'configured' : 'not configured', + ]; + } + + Utils\format_items( 'table', $rows, [ 'provider', 'status' ] ); + } + + /** + * Set credentials for an AI provider. + * + * ## OPTIONS + * + * + * : The AI provider to configure (e.g., 'openai', 'anthropic', 'google'). + * + * + * : The API key for the provider. + * + * ## EXAMPLES + * + * # Set OpenAI credentials + * $ wp ai credentials set openai sk-proj-... + * Success: Credentials for 'openai' saved. + * + * @when before_wp_load + * + * @param string[] $args Indexed array of positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function set( array $args, array $assoc_args ): void { + $this->ensure_wp_ai_client_available(); + + WP_CLI::get_runner()->load_wordpress(); + + $provider = $args[0]; + $api_key = $args[1]; + + $credentials = get_option( 'wp_ai_client_provider_credentials', [] ); + $credentials[ $provider ] = [ 'api_key' => $api_key ]; + + update_option( 'wp_ai_client_provider_credentials', $credentials ); + + WP_CLI::success( "Credentials for '$provider' saved." ); + } + + /** + * Delete credentials for an AI provider. + * + * ## OPTIONS + * + * + * : The AI provider to remove credentials for. + * + * ## EXAMPLES + * + * # Delete OpenAI credentials + * $ wp ai credentials delete openai + * Success: Credentials for 'openai' deleted. + * + * @when before_wp_load + * + * @param string[] $args Indexed array of positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function delete( array $args, array $assoc_args ): void { + $this->ensure_wp_ai_client_available(); + + WP_CLI::get_runner()->load_wordpress(); + + $provider = $args[0]; + + $credentials = get_option( 'wp_ai_client_provider_credentials', [] ); + + if ( ! isset( $credentials[ $provider ] ) ) { + WP_CLI::error( "No credentials found for '$provider'." ); + } + + unset( $credentials[ $provider ] ); + update_option( 'wp_ai_client_provider_credentials', $credentials ); + + WP_CLI::success( "Credentials for '$provider' deleted." ); + } + + /** + * Ensures the WP AI Client is available. + */ + private function ensure_wp_ai_client_available(): void { + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'The WP AI Client is not available. Please ensure the WP AI plugin is installed and activated.' ); + } + } +}