From e6ffbfcfac69b0dead810d3c3e58053da2922aed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:54:49 +0000 Subject: [PATCH 01/27] Initial plan From d4cacd0b8e84d75bf9423b3d448c2829a9e792ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:02:33 +0000 Subject: [PATCH 02/27] Implement wp ai credentials command with subcommands Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- ai-command.php | 1 + features/credentials.feature | 105 ++++++++++++++ src/Credentials_Command.php | 266 +++++++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 features/credentials.feature create mode 100644 src/Credentials_Command.php diff --git a/ai-command.php b/ai-command.php index e1f9858..e3b9b56 100644 --- a/ai-command.php +++ b/ai-command.php @@ -15,3 +15,4 @@ } WP_CLI::add_command( 'ai', AI_Command::class ); +WP_CLI::add_command( 'ai credentials', Credentials_Command::class ); diff --git a/features/credentials.feature b/features/credentials.feature new file mode 100644 index 0000000..74825a6 --- /dev/null +++ b/features/credentials.feature @@ -0,0 +1,105 @@ +Feature: Manage AI provider credentials + + Background: + Given a WP install + + Scenario: List credentials when none exist + When I run `wp ai credentials list` + Then STDOUT should contain: + """ + No credentials found. + """ + + Scenario: Set and list credentials + When I run `wp ai credentials set openai --api-key=sk-test123456789` + Then STDOUT should contain: + """ + Success: Credentials for provider "openai" have been saved. + """ + + When I run `wp ai credentials list --format=json` + Then STDOUT should be JSON containing: + """ + [{"provider":"openai","api_key":"sk-***56789"}] + """ + + Scenario: Get specific provider credentials + When I run `wp ai credentials set anthropic --api-key=sk-ant-api-key-123` + Then STDOUT should contain: + """ + Success: Credentials for provider "anthropic" have been saved. + """ + + When I run `wp ai credentials get anthropic --format=json` + Then STDOUT should contain: + """ + "provider":"anthropic" + """ + And STDOUT should contain: + """ + "api_key":"sk-***-123" + """ + + Scenario: Delete provider credentials + When I run `wp ai credentials set google --api-key=test-google-key` + Then STDOUT should contain: + """ + Success: Credentials for provider "google" have been saved. + """ + + When I run `wp ai credentials delete google` + Then STDOUT should contain: + """ + Success: Credentials for provider "google" have been deleted. + """ + + When I try `wp ai credentials get google` + Then STDERR should contain: + """ + Error: Credentials for provider "google" not found. + """ + And the return code should be 1 + + Scenario: Error when getting non-existent credentials + When I try `wp ai credentials get nonexistent` + Then STDERR should contain: + """ + Error: Credentials for provider "nonexistent" not found. + """ + And the return code should be 1 + + Scenario: Error when setting credentials without api-key + When I try `wp ai credentials set openai` + Then STDERR should contain: + """ + Error: The --api-key parameter is required. + """ + And the return code should be 1 + + Scenario: List multiple credentials in table format + When I run `wp ai credentials set openai --api-key=sk-openai123` + And I run `wp ai credentials set anthropic --api-key=sk-ant-api-456` + And I run `wp ai credentials list` + Then STDOUT should be a table containing rows: + | provider | api_key | + | openai | sk-***ai123 | + | anthropic | sk-***pi-456 | + + Scenario: Update existing credentials + When I run `wp ai credentials set openai --api-key=old-key-123` + Then STDOUT should contain: + """ + Success: Credentials for provider "openai" have been saved. + """ + + When I run `wp ai credentials set openai --api-key=new-key-456` + Then STDOUT should contain: + """ + Success: Credentials for provider "openai" have been saved. + """ + + When I run `wp ai credentials get openai --format=json` + Then STDOUT should contain: + """ + "api_key":"new***-456" + """ diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php new file mode 100644 index 0000000..401f0e6 --- /dev/null +++ b/src/Credentials_Command.php @@ -0,0 +1,266 @@ +] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # List all credentials + * $ wp ai credentials list + * +----------+----------+ + * | provider | api_key | + * +----------+----------+ + * | openai | sk-***** | + * +----------+----------+ + * + * @subcommand list + * @when after_wp_load + * + * @param array $args Indexed array of positional arguments. + * @param array $assoc_args Associative array of associative arguments. + * @return void + */ + public function list_( $args, $assoc_args ) { + $credentials = $this->get_all_credentials(); + + if ( empty( $credentials ) ) { + WP_CLI::log( 'No credentials found.' ); + return; + } + + $items = array(); + foreach ( $credentials as $provider => $data ) { + $items[] = array( + 'provider' => $provider, + 'api_key' => $this->mask_api_key( $data['api_key'] ?? '' ), + ); + } + + $format = $assoc_args['format'] ?? 'table'; + WP_CLI\Utils\format_items( $format, $items, array( 'provider', 'api_key' ) ); + } + + /** + * Gets credentials for a specific AI provider. + * + * ## OPTIONS + * + * + * : The AI provider name (e.g., openai, anthropic, google). + * + * [--format=] + * : Render output in a particular format. + * --- + * default: json + * options: + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # Get OpenAI credentials + * $ wp ai credentials get openai + * {"provider":"openai","api_key":"sk-*****"} + * + * @when after_wp_load + * + * @param array $args Indexed array of positional arguments. + * @param array $assoc_args Associative array of associative arguments. + * @return void + */ + public function get( $args, $assoc_args ) { + list( $provider ) = $args; + + $credentials = $this->get_all_credentials(); + + if ( ! isset( $credentials[ $provider ] ) ) { + WP_CLI::error( sprintf( 'Credentials for provider "%s" not found.', $provider ) ); + } + + $data = array( + 'provider' => $provider, + 'api_key' => $this->mask_api_key( $credentials[ $provider ]['api_key'] ?? '' ), + ); + + $format = $assoc_args['format'] ?? 'json'; + + if ( 'json' === $format ) { + WP_CLI::line( json_encode( $data ) ); + } else { + // For yaml and other formats + foreach ( $data as $key => $value ) { + WP_CLI::line( "$key: $value" ); + } + } + } + + /** + * Sets or updates credentials for an AI provider. + * + * ## OPTIONS + * + * + * : The AI provider name (e.g., openai, anthropic, google). + * + * --api-key= + * : The API key for the provider. + * + * ## EXAMPLES + * + * # Set OpenAI credentials + * $ wp ai credentials set openai --api-key=sk-... + * Success: Credentials for provider "openai" have been saved. + * + * @when after_wp_load + * + * @param array $args Indexed array of positional arguments. + * @param array $assoc_args Associative array of associative arguments. + * @return void + */ + public function set( $args, $assoc_args ) { + list( $provider ) = $args; + + if ( empty( $assoc_args['api-key'] ) ) { + WP_CLI::error( 'The --api-key parameter is required.' ); + } + + $api_key = $assoc_args['api-key']; + $credentials = $this->get_all_credentials(); + + $credentials[ $provider ] = array( + 'api_key' => $api_key, + ); + + $this->save_all_credentials( $credentials ); + + WP_CLI::success( sprintf( 'Credentials for provider "%s" have been saved.', $provider ) ); + } + + /** + * Deletes credentials for an AI provider. + * + * ## OPTIONS + * + * + * : The AI provider name (e.g., openai, anthropic, google). + * + * ## EXAMPLES + * + * # Delete OpenAI credentials + * $ wp ai credentials delete openai + * Success: Credentials for provider "openai" have been deleted. + * + * @when after_wp_load + * + * @param array $args Indexed array of positional arguments. + * @param array $assoc_args Associative array of associative arguments. + * @return void + */ + public function delete( $args, $assoc_args ) { + list( $provider ) = $args; + + $credentials = $this->get_all_credentials(); + + if ( ! isset( $credentials[ $provider ] ) ) { + WP_CLI::error( sprintf( 'Credentials for provider "%s" not found.', $provider ) ); + } + + unset( $credentials[ $provider ] ); + $this->save_all_credentials( $credentials ); + + WP_CLI::success( sprintf( 'Credentials for provider "%s" have been deleted.', $provider ) ); + } + + /** + * Gets all credentials from the database. + * + * @return array + */ + private function get_all_credentials() { + $credentials = get_option( self::OPTION_NAME, array() ); + + if ( ! is_array( $credentials ) ) { + return array(); + } + + return $credentials; + } + + /** + * Saves all credentials to the database. + * + * @param array $credentials The credentials to save. + * @return bool + */ + private function save_all_credentials( $credentials ) { + if ( empty( $credentials ) ) { + return delete_option( self::OPTION_NAME ); + } + + return update_option( self::OPTION_NAME, $credentials, false ); + } + + /** + * Masks an API key for display purposes. + * + * @param string $api_key The API key to mask. + * @return string + */ + private function mask_api_key( $api_key ) { + if ( empty( $api_key ) ) { + return ''; + } + + $length = strlen( $api_key ); + + if ( $length <= 8 ) { + return str_repeat( '*', $length ); + } + + // Show first 3 and last 4 characters + return substr( $api_key, 0, 3 ) . str_repeat( '*', $length - 7 ) . substr( $api_key, -4 ); + } +} From 1871646413839f5983ac429271fa3bcea8a09287 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:05:14 +0000 Subject: [PATCH 03/27] Fix API key masking expectations in tests Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/credentials.feature | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/features/credentials.feature b/features/credentials.feature index 74825a6..e8067ea 100644 --- a/features/credentials.feature +++ b/features/credentials.feature @@ -20,7 +20,7 @@ Feature: Manage AI provider credentials When I run `wp ai credentials list --format=json` Then STDOUT should be JSON containing: """ - [{"provider":"openai","api_key":"sk-***56789"}] + [{"provider":"openai","api_key":"sk-*********6789"}] """ Scenario: Get specific provider credentials @@ -37,7 +37,7 @@ Feature: Manage AI provider credentials """ And STDOUT should contain: """ - "api_key":"sk-***-123" + "api_key":"sk-************-123" """ Scenario: Delete provider credentials @@ -81,9 +81,9 @@ Feature: Manage AI provider credentials And I run `wp ai credentials set anthropic --api-key=sk-ant-api-456` And I run `wp ai credentials list` Then STDOUT should be a table containing rows: - | provider | api_key | - | openai | sk-***ai123 | - | anthropic | sk-***pi-456 | + | provider | api_key | + | openai | sk-*****i123 | + | anthropic | sk-********-456 | Scenario: Update existing credentials When I run `wp ai credentials set openai --api-key=old-key-123` @@ -101,5 +101,5 @@ Feature: Manage AI provider credentials When I run `wp ai credentials get openai --format=json` Then STDOUT should contain: """ - "api_key":"new***-456" + "api_key":"new****-456" """ From 15000143b3064405424ce3726ba1431cbc9e18f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:23:49 +0000 Subject: [PATCH 04/27] Update credentials option name and implement AI generate/check commands - Use wp_ai_client_provider_credentials option as per API_Credentials_Manager - Add wp ai generate text/image commands for AI content generation - Add wp ai check command to verify prompt support - Add tests for new commands Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/generate.feature | 20 ++++ src/AI_Command.php | 231 ++++++++++++++++++++++++++++++++++-- src/Credentials_Command.php | 2 +- 3 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 features/generate.feature diff --git a/features/generate.feature b/features/generate.feature new file mode 100644 index 0000000..a59d37c --- /dev/null +++ b/features/generate.feature @@ -0,0 +1,20 @@ +Feature: Generate AI content + + Background: + Given a WP install + + Scenario: Check for WordPress AI Client availability + When I try `wp ai check "Test prompt"` + Then the return code should be 1 + And STDERR should contain: + """ + WordPress AI Client is not available + """ + + Scenario: Generate command requires AI Client + When I try `wp ai generate text "Test prompt"` + Then the return code should be 1 + And STDERR should contain: + """ + WordPress AI Client is not available + """ diff --git a/src/AI_Command.php b/src/AI_Command.php index 9000c2e..fc983c5 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -4,25 +4,238 @@ use WP_CLI; use WP_CLI_Command; +use WordPress\AI_Client\AI_Client; +/** + * Interacts with the WordPress AI Client for text and image generation. + * + * ## EXAMPLES + * + * # Generate text from a prompt + * $ wp ai generate text "Write a haiku about WordPress" + * Success: Generated text: + * Open source and free + * Empowering creators + * WordPress shines bright + * + * # Generate an image from a prompt + * $ wp ai generate image "A futuristic WordPress logo" --output=logo.png + * Success: Image saved to logo.png + * + * # Check if a prompt is supported + * $ wp ai check "Summarize this text" + * Success: Text generation is supported for this prompt. + * + * @when after_wp_load + */ class AI_Command extends WP_CLI_Command { /** - * Greets the world. + * Generates AI content. + * + * ## OPTIONS + * + * + * : Type of content to generate. Options: text, image + * + * + * : The prompt to send to the AI. + * + * [--temperature=] + * : Temperature for text generation (0.0-2.0). Lower is more deterministic. + * + * [--model=] + * : Specific model to use in format "provider,model" (e.g., "openai,gpt-4"). + * + * [--output=] + * : For image generation, path to save the generated image. + * + * [--format=] + * : Output format for text. Options: text, json. Default: text * * ## EXAMPLES * - * # Greet the world. - * $ wp hello-world - * Success: Hello World! + * # Generate text + * $ wp ai generate text "Explain WordPress in one sentence" * - * @when before_wp_load + * # Generate text with lower temperature + * $ wp ai generate text "List 3 WordPress features" --temperature=0.1 * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. + * # Generate image + * $ wp ai generate image "A minimalist WordPress logo" --output=wp-logo.png + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. * @return void */ - public function __invoke( $args, $assoc_args ) { - WP_CLI::success( 'Hello World!' ); + public function generate( $args, $assoc_args ) { + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); + } + + list( $type, $prompt ) = $args; + + $type = strtolower( $type ); + + if ( ! in_array( $type, array( 'text', 'image' ), true ) ) { + WP_CLI::error( 'Invalid type. Must be "text" or "image".' ); + } + + try { + $builder = AI_Client::prompt( $prompt ); + + // Apply temperature if specified + if ( isset( $assoc_args['temperature'] ) ) { + $temperature = (float) $assoc_args['temperature']; + if ( $temperature < 0.0 || $temperature > 2.0 ) { + WP_CLI::error( 'Temperature must be between 0.0 and 2.0.' ); + } + $builder = $builder->using_temperature( $temperature ); + } + + // Apply specific model if specified + if ( isset( $assoc_args['model'] ) ) { + $model_parts = explode( ',', $assoc_args['model'], 2 ); + if ( count( $model_parts ) !== 2 ) { + WP_CLI::error( 'Model must be in format "provider,model" (e.g., "openai,gpt-4").' ); + } + $builder = $builder->using_model_preference( $model_parts ); + } + + if ( 'text' === $type ) { + $this->generate_text( $builder, $assoc_args ); + } elseif ( 'image' === $type ) { + $this->generate_image( $builder, $assoc_args ); + } + } catch ( \Exception $e ) { + WP_CLI::error( 'AI generation failed: ' . $e->getMessage() ); + } + } + + /** + * Checks if a prompt is supported for generation. + * + * ## OPTIONS + * + * + * : The prompt to check. + * + * [--type=] + * : Type to check. Options: text, image. Default: text + * + * ## EXAMPLES + * + * # Check if text generation is supported + * $ wp ai check "Write a poem" + * + * # Check if image generation is supported + * $ wp ai check "A sunset" --type=image + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function check( $args, $assoc_args ) { + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); + } + + list( $prompt ) = $args; + $type = $assoc_args['type'] ?? 'text'; + + try { + $builder = AI_Client::prompt( $prompt ); + + if ( 'text' === $type ) { + $supported = $builder->is_supported_for_text_generation(); + if ( $supported ) { + WP_CLI::success( 'Text generation is supported for this prompt.' ); + } else { + WP_CLI::error( 'Text generation is not supported. Make sure AI provider credentials are configured.' ); + } + } elseif ( 'image' === $type ) { + $supported = $builder->is_supported_for_image_generation(); + if ( $supported ) { + WP_CLI::success( 'Image generation is supported for this prompt.' ); + } else { + WP_CLI::error( 'Image generation is not supported. Make sure AI provider credentials are configured.' ); + } + } else { + WP_CLI::error( 'Invalid type. Must be "text" or "image".' ); + } + } catch ( \Exception $e ) { + WP_CLI::error( 'Check failed: ' . $e->getMessage() ); + } + } + + /** + * Generates text from the prompt builder. + * + * @param mixed $builder The prompt builder. + * @param array $assoc_args Associative arguments. + * @return void + */ + private function generate_text( $builder, $assoc_args ) { + $format = $assoc_args['format'] ?? 'text'; + + // Check if supported + if ( ! $builder->is_supported_for_text_generation() ) { + WP_CLI::error( 'Text generation is not supported. Make sure AI provider credentials are configured.' ); + } + + $text = $builder->generate_text(); + + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( array( 'text' => $text ) ) ); + } else { + WP_CLI::success( 'Generated text:' ); + WP_CLI::line( $text ); + } + } + + /** + * Generates an image from the prompt builder. + * + * @param mixed $builder The prompt builder. + * @param array $assoc_args Associative arguments. + * @return void + */ + private function generate_image( $builder, $assoc_args ) { + // Check if supported + if ( ! $builder->is_supported_for_image_generation() ) { + WP_CLI::error( 'Image generation is not supported. Make sure AI provider credentials are configured.' ); + } + + $image_file = $builder->generate_image(); + + if ( isset( $assoc_args['output'] ) ) { + $output_path = $assoc_args['output']; + + // Get the image content from data URI + $data_uri = $image_file->getDataUri(); + + // Extract base64 data from data URI + $data_parts = explode( ',', $data_uri, 2 ); + if ( count( $data_parts ) !== 2 ) { + WP_CLI::error( 'Invalid image data received.' ); + } + + $image_data = base64_decode( $data_parts[1] ); + if ( false === $image_data ) { + WP_CLI::error( 'Failed to decode image data.' ); + } + + // Save to file + $result = file_put_contents( $output_path, $image_data ); + if ( false === $result ) { + WP_CLI::error( 'Failed to save image to ' . $output_path ); + } + + WP_CLI::success( 'Image saved to ' . $output_path ); + } else { + // Output data URI + WP_CLI::success( 'Image generated (data URI):' ); + WP_CLI::line( $image_file->getDataUri() ); + } } } diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index 401f0e6..20f1c6b 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -27,7 +27,7 @@ class Credentials_Command extends WP_CLI_Command { /** * The option name where credentials are stored. */ - const OPTION_NAME = 'wp_ai_credentials'; + const OPTION_NAME = 'wp_ai_client_provider_credentials'; /** * Lists all stored AI provider credentials. From b57579c51237942d9c01be84775110d848347f40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:26:12 +0000 Subject: [PATCH 05/27] Address code review feedback: improve security and type hints - Add proper type hints for Prompt_Builder parameters - Use json_encode instead of wp_json_encode for consistency - Add path validation for image output to prevent writing to system directories - Use strict base64 decoding for better error handling Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index fc983c5..d9cff04 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -99,6 +99,7 @@ public function generate( $args, $assoc_args ) { if ( count( $model_parts ) !== 2 ) { WP_CLI::error( 'Model must be in format "provider,model" (e.g., "openai,gpt-4").' ); } + // using_model_preference takes arrays as variadic parameters $builder = $builder->using_model_preference( $model_parts ); } @@ -171,8 +172,8 @@ public function check( $args, $assoc_args ) { /** * Generates text from the prompt builder. * - * @param mixed $builder The prompt builder. - * @param array $assoc_args Associative arguments. + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param array $assoc_args Associative arguments. * @return void */ private function generate_text( $builder, $assoc_args ) { @@ -186,7 +187,7 @@ private function generate_text( $builder, $assoc_args ) { $text = $builder->generate_text(); if ( 'json' === $format ) { - WP_CLI::line( wp_json_encode( array( 'text' => $text ) ) ); + WP_CLI::line( json_encode( array( 'text' => $text ) ) ); } else { WP_CLI::success( 'Generated text:' ); WP_CLI::line( $text ); @@ -196,8 +197,8 @@ private function generate_text( $builder, $assoc_args ) { /** * Generates an image from the prompt builder. * - * @param mixed $builder The prompt builder. - * @param array $assoc_args Associative arguments. + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param array $assoc_args Associative arguments. * @return void */ private function generate_image( $builder, $assoc_args ) { @@ -211,6 +212,20 @@ private function generate_image( $builder, $assoc_args ) { if ( isset( $assoc_args['output'] ) ) { $output_path = $assoc_args['output']; + // Validate output path - ensure it's not trying to write to system directories + $real_path = realpath( dirname( $output_path ) ); + if ( false === $real_path ) { + WP_CLI::error( 'Invalid output directory. Directory does not exist.' ); + } + + // Prevent writing to sensitive system directories + $forbidden_paths = array( '/etc', '/bin', '/usr/bin', '/sbin', '/usr/sbin', '/boot', '/sys', '/proc' ); + foreach ( $forbidden_paths as $forbidden ) { + if ( 0 === strpos( $real_path, $forbidden ) ) { + WP_CLI::error( 'Cannot write to system directory: ' . $output_path ); + } + } + // Get the image content from data URI $data_uri = $image_file->getDataUri(); @@ -220,7 +235,7 @@ private function generate_image( $builder, $assoc_args ) { WP_CLI::error( 'Invalid image data received.' ); } - $image_data = base64_decode( $data_parts[1] ); + $image_data = base64_decode( $data_parts[1], true ); if ( false === $image_data ) { WP_CLI::error( 'Failed to decode image data.' ); } From f561230483fc38d7bd41a00305ccf1208095ad85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:28:13 +0000 Subject: [PATCH 06/27] Enhance security: add comprehensive path and data validation - Check for directory traversal (..) before resolving paths - Validate base64 format with regex before decoding - Add size limit check (50MB) to prevent memory exhaustion - Verify parent directory exists before attempting file write - Improve error messages for better debugging Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index d9cff04..b37bae0 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -212,10 +212,21 @@ private function generate_image( $builder, $assoc_args ) { if ( isset( $assoc_args['output'] ) ) { $output_path = $assoc_args['output']; + // Validate output path - check for directory traversal attempts + if ( false !== strpos( $output_path, '..' ) ) { + WP_CLI::error( 'Invalid output path: directory traversal detected.' ); + } + + // Ensure parent directory exists + $parent_dir = dirname( $output_path ); + if ( ! file_exists( $parent_dir ) ) { + WP_CLI::error( 'Invalid output directory. Directory does not exist: ' . $parent_dir ); + } + // Validate output path - ensure it's not trying to write to system directories - $real_path = realpath( dirname( $output_path ) ); + $real_path = realpath( $parent_dir ); if ( false === $real_path ) { - WP_CLI::error( 'Invalid output directory. Directory does not exist.' ); + WP_CLI::error( 'Invalid output directory. Cannot resolve path.' ); } // Prevent writing to sensitive system directories @@ -235,7 +246,18 @@ private function generate_image( $builder, $assoc_args ) { WP_CLI::error( 'Invalid image data received.' ); } - $image_data = base64_decode( $data_parts[1], true ); + // Validate base64 format before decoding + $base64_data = $data_parts[1]; + if ( ! preg_match( '/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $base64_data ) ) { + WP_CLI::error( 'Invalid base64 data format.' ); + } + + // Check reasonable size limit (e.g., 50MB) + if ( strlen( $base64_data ) > 50 * 1024 * 1024 * 4 / 3 ) { + WP_CLI::error( 'Image data exceeds maximum size limit.' ); + } + + $image_data = base64_decode( $base64_data, true ); if ( false === $image_data ) { WP_CLI::error( 'Failed to decode image data.' ); } From 2373949f361e2aef5c6e7b06402a8aaafedcb366 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:30:13 +0000 Subject: [PATCH 07/27] Refine validation: use PHP native base64 strict mode and add size constant - Replace regex validation with PHP's native base64_decode strict mode - Add MAX_IMAGE_SIZE_BASE64 constant for clarity and maintainability - Simplify base64 validation logic to rely on built-in strict validation - Improve error messages for better user feedback Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index b37bae0..e848243 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -30,6 +30,11 @@ */ class AI_Command extends WP_CLI_Command { + /** + * Maximum size for base64-encoded image data (50MB binary = ~67MB base64). + */ + const MAX_IMAGE_SIZE_BASE64 = 70000000; // 50 * 1024 * 1024 * 4 / 3 rounded up + /** * Generates AI content. * @@ -246,20 +251,18 @@ private function generate_image( $builder, $assoc_args ) { WP_CLI::error( 'Invalid image data received.' ); } - // Validate base64 format before decoding + // Validate and decode base64 data $base64_data = $data_parts[1]; - if ( ! preg_match( '/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $base64_data ) ) { - WP_CLI::error( 'Invalid base64 data format.' ); - } - // Check reasonable size limit (e.g., 50MB) - if ( strlen( $base64_data ) > 50 * 1024 * 1024 * 4 / 3 ) { - WP_CLI::error( 'Image data exceeds maximum size limit.' ); + // Check reasonable size limit + if ( strlen( $base64_data ) > self::MAX_IMAGE_SIZE_BASE64 ) { + WP_CLI::error( 'Image data exceeds maximum size limit (50MB).' ); } + // Try strict base64 decode - this validates format $image_data = base64_decode( $base64_data, true ); if ( false === $image_data ) { - WP_CLI::error( 'Failed to decode image data.' ); + WP_CLI::error( 'Invalid base64 image data format.' ); } // Save to file From e9034cb8b41481e5d978739a56024cedeb11e91c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:32:11 +0000 Subject: [PATCH 08/27] Final security hardening: improve path resolution and JSON encoding - Use realpath() to fully resolve paths and prevent all traversal attacks (including URL-encoded) - Reconstruct safe output path using resolved parent dir + basename - Add json_encode() error handling with descriptive messages - Ensure is_dir() check for parent directory validation Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index e848243..4998ef6 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -192,7 +192,11 @@ private function generate_text( $builder, $assoc_args ) { $text = $builder->generate_text(); if ( 'json' === $format ) { - WP_CLI::line( json_encode( array( 'text' => $text ) ) ); + $json = json_encode( array( 'text' => $text ) ); + if ( false === $json ) { + WP_CLI::error( 'Failed to encode text as JSON: ' . json_last_error_msg() ); + } + WP_CLI::line( $json ); } else { WP_CLI::success( 'Generated text:' ); WP_CLI::line( $text ); @@ -217,28 +221,28 @@ private function generate_image( $builder, $assoc_args ) { if ( isset( $assoc_args['output'] ) ) { $output_path = $assoc_args['output']; - // Validate output path - check for directory traversal attempts - if ( false !== strpos( $output_path, '..' ) ) { - WP_CLI::error( 'Invalid output path: directory traversal detected.' ); - } - - // Ensure parent directory exists + // Resolve the full real path $parent_dir = dirname( $output_path ); - if ( ! file_exists( $parent_dir ) ) { + + // Check if parent directory exists + if ( ! file_exists( $parent_dir ) || ! is_dir( $parent_dir ) ) { WP_CLI::error( 'Invalid output directory. Directory does not exist: ' . $parent_dir ); } - // Validate output path - ensure it's not trying to write to system directories - $real_path = realpath( $parent_dir ); - if ( false === $real_path ) { - WP_CLI::error( 'Invalid output directory. Cannot resolve path.' ); + // Resolve the real path to prevent traversal attacks + $real_parent_dir = realpath( $parent_dir ); + if ( false === $real_parent_dir ) { + WP_CLI::error( 'Cannot resolve output directory path.' ); } + // Reconstruct the output path with the resolved parent directory + $safe_output_path = $real_parent_dir . DIRECTORY_SEPARATOR . basename( $output_path ); + // Prevent writing to sensitive system directories $forbidden_paths = array( '/etc', '/bin', '/usr/bin', '/sbin', '/usr/sbin', '/boot', '/sys', '/proc' ); foreach ( $forbidden_paths as $forbidden ) { - if ( 0 === strpos( $real_path, $forbidden ) ) { - WP_CLI::error( 'Cannot write to system directory: ' . $output_path ); + if ( 0 === strpos( $real_parent_dir, $forbidden ) ) { + WP_CLI::error( 'Cannot write to system directory: ' . $safe_output_path ); } } @@ -266,12 +270,12 @@ private function generate_image( $builder, $assoc_args ) { } // Save to file - $result = file_put_contents( $output_path, $image_data ); + $result = file_put_contents( $safe_output_path, $image_data ); if ( false === $result ) { - WP_CLI::error( 'Failed to save image to ' . $output_path ); + WP_CLI::error( 'Failed to save image to ' . $safe_output_path ); } - WP_CLI::success( 'Image saved to ' . $output_path ); + WP_CLI::success( 'Image saved to ' . $safe_output_path ); } else { // Output data URI WP_CLI::success( 'Image generated (data URI):' ); From 7fa8fee57e504c70fbe66ef192e41b64575e3afd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:33:32 +0000 Subject: [PATCH 09/27] Add cross-platform security and improve constant clarity - Add Windows system directory protection (C:\Windows, C:\Program Files) - Use case-insensitive path comparison for Windows compatibility - Separate MAX_IMAGE_SIZE_BYTES constant for better clarity - Improve documentation for size constants with detailed explanation Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 4998ef6..9ad8383 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -31,9 +31,16 @@ class AI_Command extends WP_CLI_Command { /** - * Maximum size for base64-encoded image data (50MB binary = ~67MB base64). + * Maximum binary image size in bytes (50MB). */ - const MAX_IMAGE_SIZE_BASE64 = 70000000; // 50 * 1024 * 1024 * 4 / 3 rounded up + const MAX_IMAGE_SIZE_BYTES = 52428800; // 50 * 1024 * 1024 + + /** + * Maximum size for base64-encoded image data. + * Base64 encoding increases size by ~33%, so 50MB binary = ~67MB base64. + * Using 70MB as safe upper bound. + */ + const MAX_IMAGE_SIZE_BASE64 = 70000000; /** * Generates AI content. @@ -239,9 +246,24 @@ private function generate_image( $builder, $assoc_args ) { $safe_output_path = $real_parent_dir . DIRECTORY_SEPARATOR . basename( $output_path ); // Prevent writing to sensitive system directories - $forbidden_paths = array( '/etc', '/bin', '/usr/bin', '/sbin', '/usr/sbin', '/boot', '/sys', '/proc' ); + $forbidden_paths = array( + // Unix/Linux system directories + '/etc', + '/bin', + '/usr/bin', + '/sbin', + '/usr/sbin', + '/boot', + '/sys', + '/proc', + // Windows system directories (case-insensitive) + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + ); foreach ( $forbidden_paths as $forbidden ) { - if ( 0 === strpos( $real_parent_dir, $forbidden ) ) { + // Case-insensitive comparison for Windows paths + if ( 0 === stripos( $real_parent_dir, $forbidden ) ) { WP_CLI::error( 'Cannot write to system directory: ' . $safe_output_path ); } } From 128a5a9f45a02e5fce6ba0487c2b7e196da65e33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:42:39 +0000 Subject: [PATCH 10/27] Add missing Prompt_Builder options to generate command - Add --provider flag mapping to using_provider() - Update --model flag to support comma-separated list of model preferences - Add --max-tokens flag mapping to using_max_tokens() - Add --system-instruction flag mapping to using_system_instruction() - Add validation tests for new options - Update documentation with examples for all new flags Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/generate.feature | 21 ++++++++++++ src/AI_Command.php | 70 ++++++++++++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/features/generate.feature b/features/generate.feature index a59d37c..bd82638 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -18,3 +18,24 @@ Feature: Generate AI content """ WordPress AI Client is not available """ + + Scenario: Generate command validates model format + When I try `wp ai generate text "Test prompt" --model=invalidformat` + Then the return code should be 1 + + Scenario: Generate command validates temperature range + When I try `wp ai generate text "Test prompt" --temperature=3.0` + Then the return code should be 1 + And STDERR should contain: + """ + Temperature must be between 0.0 and 2.0 + """ + + Scenario: Generate command validates max-tokens + When I try `wp ai generate text "Test prompt" --max-tokens=-5` + Then the return code should be 1 + And STDERR should contain: + """ + Max tokens must be a positive integer + """ + diff --git a/src/AI_Command.php b/src/AI_Command.php index 9ad8383..f4574d4 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -53,11 +53,20 @@ class AI_Command extends WP_CLI_Command { * * : The prompt to send to the AI. * + * [--model=] + * : Comma-separated list of models in order of preference. Format: "provider,model" (e.g., "openai,gpt-4" or "openai,gpt-4,anthropic,claude-3"). + * + * [--provider=] + * : Specific AI provider to use (e.g., "openai", "anthropic", "google"). + * * [--temperature=] - * : Temperature for text generation (0.0-2.0). Lower is more deterministic. + * : Temperature for generation (0.0-2.0). Lower is more deterministic. + * + * [--max-tokens=] + * : Maximum number of tokens to generate. * - * [--model=] - * : Specific model to use in format "provider,model" (e.g., "openai,gpt-4"). + * [--system-instruction=] + * : System instruction to guide the AI's behavior. * * [--output=] * : For image generation, path to save the generated image. @@ -70,8 +79,14 @@ class AI_Command extends WP_CLI_Command { * # Generate text * $ wp ai generate text "Explain WordPress in one sentence" * - * # Generate text with lower temperature - * $ wp ai generate text "List 3 WordPress features" --temperature=0.1 + * # Generate text with specific settings + * $ wp ai generate text "List 3 WordPress features" --temperature=0.1 --max-tokens=100 + * + * # Generate with model preferences + * $ wp ai generate text "Write a haiku" --model=openai,gpt-4,anthropic,claude-3 + * + * # Generate with system instruction + * $ wp ai generate text "Explain AI" --system-instruction="Explain as if to a 5-year-old" * * # Generate image * $ wp ai generate image "A minimalist WordPress logo" --output=wp-logo.png @@ -96,6 +111,33 @@ public function generate( $args, $assoc_args ) { try { $builder = AI_Client::prompt( $prompt ); + // Apply provider if specified + if ( isset( $assoc_args['provider'] ) ) { + $builder = $builder->using_provider( $assoc_args['provider'] ); + } + + // Apply model preferences if specified + if ( isset( $assoc_args['model'] ) ) { + $model_parts = explode( ',', $assoc_args['model'] ); + + // Models should be in pairs: provider,model,provider,model,... + if ( count( $model_parts ) % 2 !== 0 ) { + WP_CLI::error( 'Model must be in format "provider,model" pairs (e.g., "openai,gpt-4" or "openai,gpt-4,anthropic,claude-3").' ); + } + + // Convert flat array to array of [provider, model] pairs + $model_preferences = array(); + for ( $i = 0; $i < count( $model_parts ); $i += 2 ) { + $model_preferences[] = array( $model_parts[ $i ], $model_parts[ $i + 1 ] ); + } + + // Pass all preferences to using_model_preference + $builder = call_user_func_array( + array( $builder, 'using_model_preference' ), + $model_preferences + ); + } + // Apply temperature if specified if ( isset( $assoc_args['temperature'] ) ) { $temperature = (float) $assoc_args['temperature']; @@ -105,14 +147,18 @@ public function generate( $args, $assoc_args ) { $builder = $builder->using_temperature( $temperature ); } - // Apply specific model if specified - if ( isset( $assoc_args['model'] ) ) { - $model_parts = explode( ',', $assoc_args['model'], 2 ); - if ( count( $model_parts ) !== 2 ) { - WP_CLI::error( 'Model must be in format "provider,model" (e.g., "openai,gpt-4").' ); + // Apply max tokens if specified + if ( isset( $assoc_args['max-tokens'] ) ) { + $max_tokens = (int) $assoc_args['max-tokens']; + if ( $max_tokens <= 0 ) { + WP_CLI::error( 'Max tokens must be a positive integer.' ); } - // using_model_preference takes arrays as variadic parameters - $builder = $builder->using_model_preference( $model_parts ); + $builder = $builder->using_max_tokens( $max_tokens ); + } + + // Apply system instruction if specified + if ( isset( $assoc_args['system-instruction'] ) ) { + $builder = $builder->using_system_instruction( $assoc_args['system-instruction'] ); } if ( 'text' === $type ) { From 2f608acf6aaf32011af08e0190107121989450c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:43:50 +0000 Subject: [PATCH 11/27] Use spread operator instead of call_user_func_array for modern PHP - Replace call_user_func_array with spread operator syntax - Cleaner and more readable code - Better performance with modern PHP Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index f4574d4..21e1093 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -131,11 +131,8 @@ public function generate( $args, $assoc_args ) { $model_preferences[] = array( $model_parts[ $i ], $model_parts[ $i + 1 ] ); } - // Pass all preferences to using_model_preference - $builder = call_user_func_array( - array( $builder, 'using_model_preference' ), - $model_preferences - ); + // Pass all preferences to using_model_preference using spread operator + $builder = $builder->using_model_preference( ...$model_preferences ); } // Apply temperature if specified From 80ba4fc7a99a916cb6103ea608cec7ea2052889a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:45:08 +0000 Subject: [PATCH 12/27] Code cleanup: remove trailing whitespace and optimize loop - Remove trailing whitespace for coding standards compliance - Cache count() result before loop to avoid repeated function calls - Minor performance and style improvements Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 21e1093..e6a0525 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -119,7 +119,7 @@ public function generate( $args, $assoc_args ) { // Apply model preferences if specified if ( isset( $assoc_args['model'] ) ) { $model_parts = explode( ',', $assoc_args['model'] ); - + // Models should be in pairs: provider,model,provider,model,... if ( count( $model_parts ) % 2 !== 0 ) { WP_CLI::error( 'Model must be in format "provider,model" pairs (e.g., "openai,gpt-4" or "openai,gpt-4,anthropic,claude-3").' ); @@ -127,7 +127,8 @@ public function generate( $args, $assoc_args ) { // Convert flat array to array of [provider, model] pairs $model_preferences = array(); - for ( $i = 0; $i < count( $model_parts ); $i += 2 ) { + $parts_count = count( $model_parts ); + for ( $i = 0; $i < $parts_count; $i += 2 ) { $model_preferences[] = array( $model_parts[ $i ], $model_parts[ $i + 1 ] ); } From 0b5e1d29691872ac7a7a19f011ac9ca5bedf5d09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:57:50 +0000 Subject: [PATCH 13/27] Add wp ai status command to check supported capabilities - Implement status command to display table of supported AI capabilities - Check text generation and image generation support - Support multiple output formats (table, csv, json, yaml) - Add test scenario for status command - Update class documentation with status command example Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/generate.feature | 8 +++++ src/AI_Command.php | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/features/generate.feature b/features/generate.feature index bd82638..62ebb21 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -19,6 +19,14 @@ Feature: Generate AI content WordPress AI Client is not available """ + Scenario: Status command requires AI Client + When I try `wp ai status` + Then the return code should be 1 + And STDERR should contain: + """ + WordPress AI Client is not available + """ + Scenario: Generate command validates model format When I try `wp ai generate text "Test prompt" --model=invalidformat` Then the return code should be 1 diff --git a/src/AI_Command.php b/src/AI_Command.php index e6a0525..227ab90 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -11,6 +11,15 @@ * * ## EXAMPLES * + * # Check AI capabilities status + * $ wp ai status + * +------------------+-----------+ + * | Capability | Supported | + * +------------------+-----------+ + * | Text Generation | Yes | + * | Image Generation | No | + * +------------------+-----------+ + * * # Generate text from a prompt * $ wp ai generate text "Write a haiku about WordPress" * Success: Generated text: @@ -225,6 +234,68 @@ public function check( $args, $assoc_args ) { } } + /** + * Checks which AI capabilities are currently supported. + * + * Checks the environment and credentials to determine which AI operations + * are available. Displays a table showing supported capabilities. + * + * ## OPTIONS + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # Check AI status + * $ wp ai status + * +------------------+-----------+ + * | Capability | Supported | + * +------------------+-----------+ + * | Text Generation | Yes | + * | Image Generation | No | + * +------------------+-----------+ + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function status( $args, $assoc_args ) { + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); + } + + try { + // Create a basic builder to check capabilities + $builder = AI_Client::prompt( 'test' ); + + // Check each capability + $capabilities = array( + array( + 'capability' => 'Text Generation', + 'supported' => $builder->is_supported_for_text_generation() ? 'Yes' : 'No', + ), + array( + 'capability' => 'Image Generation', + 'supported' => $builder->is_supported_for_image_generation() ? 'Yes' : 'No', + ), + ); + + $format = $assoc_args['format'] ?? 'table'; + WP_CLI\Utils\format_items( $format, $capabilities, array( 'capability', 'supported' ) ); + } catch ( \Exception $e ) { + WP_CLI::error( 'Status check failed: ' . $e->getMessage() ); + } + } + /** * Generates text from the prompt builder. * From 4e2c834ecb43e37678e0ac97f118f0fe912050a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:00:28 +0000 Subject: [PATCH 14/27] Refactor: extract AI Client availability check to DRY helper method - Add ensure_ai_client_available() helper method to follow DRY principle - Update generate, check, and status methods to use helper - Add CAPABILITY_CHECK_PROMPT constant for clarity - Improve code maintainability and reduce duplication Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 227ab90..d492727 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -44,6 +44,12 @@ class AI_Command extends WP_CLI_Command { */ const MAX_IMAGE_SIZE_BYTES = 52428800; // 50 * 1024 * 1024 + /** + * Dummy prompt used for capability checking. + * The actual prompt content doesn't matter for capability detection. + */ + const CAPABILITY_CHECK_PROMPT = 'capability-check'; + /** * Maximum size for base64-encoded image data. * Base64 encoding increases size by ~33%, so 50MB binary = ~67MB base64. @@ -105,9 +111,7 @@ class AI_Command extends WP_CLI_Command { * @return void */ public function generate( $args, $assoc_args ) { - if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { - WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); - } + $this->ensure_ai_client_available(); list( $type, $prompt ) = $args; @@ -202,9 +206,7 @@ public function generate( $args, $assoc_args ) { * @return void */ public function check( $args, $assoc_args ) { - if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { - WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); - } + $this->ensure_ai_client_available(); list( $prompt ) = $args; $type = $assoc_args['type'] ?? 'text'; @@ -269,13 +271,12 @@ public function check( $args, $assoc_args ) { * @return void */ public function status( $args, $assoc_args ) { - if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { - WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); - } + $this->ensure_ai_client_available(); try { // Create a basic builder to check capabilities - $builder = AI_Client::prompt( 'test' ); + // The prompt content doesn't matter for capability detection + $builder = AI_Client::prompt( self::CAPABILITY_CHECK_PROMPT ); // Check each capability $capabilities = array( @@ -419,4 +420,15 @@ private function generate_image( $builder, $assoc_args ) { WP_CLI::line( $image_file->getDataUri() ); } } + + /** + * Ensures WordPress AI Client is available. + * + * @return void + */ + private function ensure_ai_client_available() { + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); + } + } } From abc333b9b71b381d21aa23704ee0adc1ea592f8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:02:52 +0000 Subject: [PATCH 15/27] Code quality improvements: extract constants and improve path checks - Extract FORBIDDEN_PATHS constant for better maintainability - Improve comment conciseness - Use case-sensitive check for Unix paths, case-insensitive for Windows - Prevent false positives on case-sensitive filesystems Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 48 +++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index d492727..694add1 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -50,6 +50,25 @@ class AI_Command extends WP_CLI_Command { */ const CAPABILITY_CHECK_PROMPT = 'capability-check'; + /** + * System directories that should be protected from file writes. + */ + const FORBIDDEN_PATHS = array( + // Unix/Linux system directories + '/etc', + '/bin', + '/usr/bin', + '/sbin', + '/usr/sbin', + '/boot', + '/sys', + '/proc', + // Windows system directories (case-insensitive) + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + ); + /** * Maximum size for base64-encoded image data. * Base64 encoding increases size by ~33%, so 50MB binary = ~67MB base64. @@ -274,8 +293,7 @@ public function status( $args, $assoc_args ) { $this->ensure_ai_client_available(); try { - // Create a basic builder to check capabilities - // The prompt content doesn't matter for capability detection + // Create a basic builder to check capabilities (prompt content doesn't matter for capability detection) $builder = AI_Client::prompt( self::CAPABILITY_CHECK_PROMPT ); // Check each capability @@ -362,24 +380,14 @@ private function generate_image( $builder, $assoc_args ) { $safe_output_path = $real_parent_dir . DIRECTORY_SEPARATOR . basename( $output_path ); // Prevent writing to sensitive system directories - $forbidden_paths = array( - // Unix/Linux system directories - '/etc', - '/bin', - '/usr/bin', - '/sbin', - '/usr/sbin', - '/boot', - '/sys', - '/proc', - // Windows system directories (case-insensitive) - 'C:\\Windows', - 'C:\\Program Files', - 'C:\\Program Files (x86)', - ); - foreach ( $forbidden_paths as $forbidden ) { - // Case-insensitive comparison for Windows paths - if ( 0 === stripos( $real_parent_dir, $forbidden ) ) { + foreach ( self::FORBIDDEN_PATHS as $forbidden ) { + // Use case-sensitive check for Unix paths, case-insensitive for Windows + $is_windows_path = ( 0 === strpos( $forbidden, 'C:\\' ) ); + $matches = $is_windows_path + ? ( 0 === stripos( $real_parent_dir, $forbidden ) ) + : ( 0 === strpos( $real_parent_dir, $forbidden ) ); + + if ( $matches ) { WP_CLI::error( 'Cannot write to system directory: ' . $safe_output_path ); } } From 7a816ebbc3e13e91b0c5850e2dd411c4ab3d4aad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:04:27 +0000 Subject: [PATCH 16/27] Minor refinements: improve Windows path detection and clarify comment - Use more robust Windows path detection (checks for :\\ instead of C:\\) - Handles all drive letters (D:\\, E:\\, etc.) - Clarify comment about using constant for consistency Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 694add1..1013ee8 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -293,7 +293,7 @@ public function status( $args, $assoc_args ) { $this->ensure_ai_client_available(); try { - // Create a basic builder to check capabilities (prompt content doesn't matter for capability detection) + // Create a builder to check capabilities (using constant for consistency) $builder = AI_Client::prompt( self::CAPABILITY_CHECK_PROMPT ); // Check each capability @@ -381,8 +381,8 @@ private function generate_image( $builder, $assoc_args ) { // Prevent writing to sensitive system directories foreach ( self::FORBIDDEN_PATHS as $forbidden ) { - // Use case-sensitive check for Unix paths, case-insensitive for Windows - $is_windows_path = ( 0 === strpos( $forbidden, 'C:\\' ) ); + // Use case-sensitive check for Unix paths (start with /), case-insensitive for Windows (contain :\) + $is_windows_path = ( false !== strpos( $forbidden, ':\\' ) ); $matches = $is_windows_path ? ( 0 === stripos( $real_parent_dir, $forbidden ) ) : ( 0 === strpos( $real_parent_dir, $forbidden ) ); From a0145c0f80a02b38a30e2db1f620b84271ee7368 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:05:56 +0000 Subject: [PATCH 17/27] Improve documentation: clarify capability check constant comment - Clarify that capability detection is based on configured providers - Document that prompt content doesn't affect capability detection logic - Improve code documentation for better understanding Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 1013ee8..505cb82 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -46,7 +46,10 @@ class AI_Command extends WP_CLI_Command { /** * Dummy prompt used for capability checking. - * The actual prompt content doesn't matter for capability detection. + * + * This constant provides a consistent prompt value when checking AI capabilities. + * The specific content doesn't affect capability detection, which is based on + * configured providers and their available features. */ const CAPABILITY_CHECK_PROMPT = 'capability-check'; From bf3c7b84909ea329f3c30bd3886cc55ebcbe1cc4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 16 Dec 2025 16:29:18 +0100 Subject: [PATCH 18/27] Lint fixes --- src/AI_Command.php | 7 ++++--- src/Credentials_Command.php | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 505cb82..0c0a5a2 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -46,7 +46,7 @@ class AI_Command extends WP_CLI_Command { /** * Dummy prompt used for capability checking. - * + * * This constant provides a consistent prompt value when checking AI capabilities. * The specific content doesn't affect capability detection, which is based on * configured providers and their available features. @@ -399,7 +399,7 @@ private function generate_image( $builder, $assoc_args ) { $data_uri = $image_file->getDataUri(); // Extract base64 data from data URI - $data_parts = explode( ',', $data_uri, 2 ); + $data_parts = $data_uri ? explode( ',', $data_uri, 2 ) : []; if ( count( $data_parts ) !== 2 ) { WP_CLI::error( 'Invalid image data received.' ); } @@ -413,6 +413,7 @@ private function generate_image( $builder, $assoc_args ) { } // Try strict base64 decode - this validates format + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $image_data = base64_decode( $base64_data, true ); if ( false === $image_data ) { WP_CLI::error( 'Invalid base64 image data format.' ); @@ -428,7 +429,7 @@ private function generate_image( $builder, $assoc_args ) { } else { // Output data URI WP_CLI::success( 'Image generated (data URI):' ); - WP_CLI::line( $image_file->getDataUri() ); + WP_CLI::line( (string) $image_file->getDataUri() ); } } diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index 20f1c6b..7c39d4a 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -128,7 +128,7 @@ public function get( $args, $assoc_args ) { $format = $assoc_args['format'] ?? 'json'; if ( 'json' === $format ) { - WP_CLI::line( json_encode( $data ) ); + WP_CLI::line( (string) json_encode( $data ) ); } else { // For yaml and other formats foreach ( $data as $key => $value ) { From afab24aa7ce031cb466864f1ba2e226deb8ff680 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 17 Dec 2025 23:49:21 +0100 Subject: [PATCH 19/27] Some manual fixes --- src/AI_Command.php | 44 ++++++++++++++++++++++++++++++++----- src/Credentials_Command.php | 8 +++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 0c0a5a2..4afef27 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -5,6 +5,7 @@ use WP_CLI; use WP_CLI_Command; use WordPress\AI_Client\AI_Client; +use WordPress\AiClient\Results\DTO\TokenUsage; /** * Interacts with the WordPress AI Client for text and image generation. @@ -85,7 +86,12 @@ class AI_Command extends WP_CLI_Command { * ## OPTIONS * * - * : Type of content to generate. Options: text, image + * : Type of content to generate. + * --- + * options: + * - text + * - image + * --- * * * : The prompt to send to the AI. @@ -109,7 +115,13 @@ class AI_Command extends WP_CLI_Command { * : For image generation, path to save the generated image. * * [--format=] - * : Output format for text. Options: text, json. Default: text + * : Output format for text. + * --- + * default: text + * options: + * - text + * - json + * --- * * ## EXAMPLES * @@ -333,18 +345,38 @@ private function generate_text( $builder, $assoc_args ) { WP_CLI::error( 'Text generation is not supported. Make sure AI provider credentials are configured.' ); } - $text = $builder->generate_text(); + $text = $builder->generate_text_result(); if ( 'json' === $format ) { - $json = json_encode( array( 'text' => $text ) ); + $json = json_encode( array( 'text' => $text->toText() ) ); if ( false === $json ) { WP_CLI::error( 'Failed to encode text as JSON: ' . json_last_error_msg() ); } WP_CLI::line( $json ); } else { - WP_CLI::success( 'Generated text:' ); - WP_CLI::line( $text ); + WP_CLI::line( $text->toText() ); } + + $token_usage = $text->getTokenUsage()->toArray(); + + WP_CLI::debug( + sprintf( + 'Model used: %s (%s)', + $text->getModelMetadata()->getName(), + $text->getProviderMetadata()->getName() + ), + 'ai' + ); + + WP_CLI::debug( + sprintf( + "Token usage:\nInput tokens: %s\nOutput tokens: %s\nTotal: %s", + $token_usage[ TokenUsage::KEY_PROMPT_TOKENS ], + $token_usage[ TokenUsage::KEY_COMPLETION_TOKENS ], + $token_usage[ TokenUsage::KEY_TOTAL_TOKENS ], + ), + 'ai' + ); } /** diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index 7c39d4a..e89d0b0 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -71,10 +71,10 @@ public function list_( $args, $assoc_args ) { } $items = array(); - foreach ( $credentials as $provider => $data ) { + foreach ( $credentials as $provider => $api_key ) { $items[] = array( 'provider' => $provider, - 'api_key' => $this->mask_api_key( $data['api_key'] ?? '' ), + 'api_key' => $this->mask_api_key( $api_key ?? '' ), ); } @@ -122,7 +122,7 @@ public function get( $args, $assoc_args ) { $data = array( 'provider' => $provider, - 'api_key' => $this->mask_api_key( $credentials[ $provider ]['api_key'] ?? '' ), + 'api_key' => $this->mask_api_key( $credentials[ $provider ] ?? '' ), ); $format = $assoc_args['format'] ?? 'json'; @@ -261,6 +261,6 @@ private function mask_api_key( $api_key ) { } // Show first 3 and last 4 characters - return substr( $api_key, 0, 3 ) . str_repeat( '*', $length - 7 ) . substr( $api_key, -4 ); + return substr( $api_key, 0, 3 ) . str_repeat( '*', min( 10, $length - 7 ) ) . substr( $api_key, -4 ); } } From d0f55951bbede592d100a1cafb900c3e1c6a424a Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 18 Dec 2025 00:36:55 +0100 Subject: [PATCH 20/27] Round of fixes --- features/credentials.feature | 4 +- features/generate.feature | 81 ++++++++++++++++++++++++++++++++++++ src/Credentials_Command.php | 4 -- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/features/credentials.feature b/features/credentials.feature index e8067ea..e7bb0b5 100644 --- a/features/credentials.feature +++ b/features/credentials.feature @@ -37,7 +37,7 @@ Feature: Manage AI provider credentials """ And STDOUT should contain: """ - "api_key":"sk-************-123" + "api_key":"sk-**********-123" """ Scenario: Delete provider credentials @@ -72,7 +72,7 @@ Feature: Manage AI provider credentials When I try `wp ai credentials set openai` Then STDERR should contain: """ - Error: The --api-key parameter is required. + missing --api-key parameter """ And the return code should be 1 diff --git a/features/generate.feature b/features/generate.feature index 62ebb21..fdef493 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -2,6 +2,87 @@ Feature: Generate AI content Background: Given a WP install + And a wp-content/mu-plugins/mock-provider.php file: + """ + id = $id; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return new ModelMetadata( $this->id, 'Mock Model', array(), array() ); + } + + public function providerMetadata(): ProviderMetadata { + return Mock_Provider::metadata(); + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + } + + class WP_CLI_Mock_Provider { + public static function metadata(): ProviderMetadata { + return new ProviderMetadata( 'wp-cli-mock-provider', 'WP-CLI Mock Provider', ProviderTypeEnum::cloud() ); + } + + public static function model( string $modelId, ?ModelConfig $modelConfig = null ): ModelInterface { + return new WP_CLI_Mock_Model( $modelId ); + } + + public static function availability(): ProviderAvailabilityInterface { + return new class() implements ProviderAvailabilityInterface { + public function isConfigured(): bool { + return true; + } + }; + } + + public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface { + return new class() implements ModelMetadataDirectoryInterface { + public function listModelMetadata(): array { + return array(); + } + + public function hasModelMetadata( string $modelId ): bool { + return true; + } + + public function getModelMetadata( string $modelId ): ModelMetadata { + return new ModelMetadata( $modelId, 'WP-CLI Mock Model', array(), array() ); + } + }; + } + } + + add_action( + 'init', + static function () { + AiClient::defaultRegistry()->registerProvider( WP_CLI_Mock_Provider::class ); + } + ); + """ Scenario: Check for WordPress AI Client availability When I try `wp ai check "Test prompt"` diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index e89d0b0..5f411b6 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -163,10 +163,6 @@ public function get( $args, $assoc_args ) { public function set( $args, $assoc_args ) { list( $provider ) = $args; - if ( empty( $assoc_args['api-key'] ) ) { - WP_CLI::error( 'The --api-key parameter is required.' ); - } - $api_key = $assoc_args['api-key']; $credentials = $this->get_all_credentials(); From 46c6e9426e204d26f8583baccf3ae65d257f094b Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 18 Dec 2025 17:19:59 +0100 Subject: [PATCH 21/27] Some fixes --- features/generate.feature | 94 +++++++++++++++++++++++++++++++++------ src/AI_Command.php | 34 +++++++------- 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/features/generate.feature b/features/generate.feature index fdef493..f37ffab 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -7,16 +7,28 @@ Feature: Generate AI content id, 'Mock Model', array(), array() ); + return new ModelMetadata( + $this->id, + 'WP-CLI Mock Model', + // Supported capabilities. + [ + CapabilityEnum::textGeneration(), + CapabilityEnum::imageGeneration(), + ], + // Supported options. + [ + new SupportedOption( + OptionEnum::inputModalities(), + [ + [ModalityEnum::text()] + ] + ), + new SupportedOption( + OptionEnum::outputModalities(), + [ + [ModalityEnum::text()], + [ModalityEnum::text(), ModalityEnum::image()], + ] + ), + ] + ); } public function providerMetadata(): ProviderMetadata { - return Mock_Provider::metadata(); + return WP_CLI_Mock_Provider::metadata(); } public function setConfig( ModelConfig $config ): void { @@ -40,9 +76,35 @@ Feature: Generate AI content public function getConfig(): ModelConfig { return $this->config; } + + public function generateTextResult(array $prompt): GenerativeAiResult { + // throw new RuntimeException('No candidates were generated'); + + $modelMessage = new ModelMessage([ + new MessagePart('Generated content') + ]); + $candidate = new Candidate( + $modelMessage, + FinishReasonEnum::stop(), + 42 + ); + $tokenUsage = new TokenUsage(10, 42, 52); + return new GenerativeAiResult( + 'result_123', + [ $candidate ], + $tokenUsage, + $this->providerMetadata(), + $this->metadata(), + [ 'provider' => 'wp-cli-mock-provider' ] + ); + } + + public function streamGenerateTextResult(array $prompt): Generator { + yield from []; + } } - class WP_CLI_Mock_Provider { + class WP_CLI_Mock_Provider implements ProviderInterface { public static function metadata(): ProviderMetadata { return new ProviderMetadata( 'wp-cli-mock-provider', 'WP-CLI Mock Provider', ProviderTypeEnum::cloud() ); } @@ -62,7 +124,9 @@ Feature: Generate AI content public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface { return new class() implements ModelMetadataDirectoryInterface { public function listModelMetadata(): array { - return array(); + return [ + ( new WP_CLI_Mock_Model( 'wp-cli-mock-model' ) )->metadata() + ]; } public function hasModelMetadata( string $modelId ): bool { @@ -70,16 +134,18 @@ Feature: Generate AI content } public function getModelMetadata( string $modelId ): ModelMetadata { - return new ModelMetadata( $modelId, 'WP-CLI Mock Model', array(), array() ); + return self::model()->metadata(); } }; } } - add_action( - 'init', + WP_CLI::add_hook( + 'ai_client_init', static function () { AiClient::defaultRegistry()->registerProvider( WP_CLI_Mock_Provider::class ); + + ( new API_Credentials_Manager() )->initialize(); } ); """ diff --git a/src/AI_Command.php b/src/AI_Command.php index 4afef27..5fe2d54 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -145,7 +145,7 @@ class AI_Command extends WP_CLI_Command { * @return void */ public function generate( $args, $assoc_args ) { - $this->ensure_ai_client_available(); + $this->initialize_ai_client(); list( $type, $prompt ) = $args; @@ -240,7 +240,7 @@ public function generate( $args, $assoc_args ) { * @return void */ public function check( $args, $assoc_args ) { - $this->ensure_ai_client_available(); + $this->initialize_ai_client(); list( $prompt ) = $args; $type = $assoc_args['type'] ?? 'text'; @@ -305,7 +305,7 @@ public function check( $args, $assoc_args ) { * @return void */ public function status( $args, $assoc_args ) { - $this->ensure_ai_client_available(); + $this->initialize_ai_client(); try { // Create a builder to check capabilities (using constant for consistency) @@ -361,16 +361,9 @@ private function generate_text( $builder, $assoc_args ) { WP_CLI::debug( sprintf( - 'Model used: %s (%s)', + "Summary:\nModel used: %s (%s)\nToken usage:\nInput tokens: %s\nOutput tokens: %s\nTotal: %s\n", $text->getModelMetadata()->getName(), - $text->getProviderMetadata()->getName() - ), - 'ai' - ); - - WP_CLI::debug( - sprintf( - "Token usage:\nInput tokens: %s\nOutput tokens: %s\nTotal: %s", + $text->getProviderMetadata()->getName(), $token_usage[ TokenUsage::KEY_PROMPT_TOKENS ], $token_usage[ TokenUsage::KEY_COMPLETION_TOKENS ], $token_usage[ TokenUsage::KEY_TOTAL_TOKENS ], @@ -470,9 +463,18 @@ private function generate_image( $builder, $assoc_args ) { * * @return void */ - private function ensure_ai_client_available() { - if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { - WP_CLI::error( 'WordPress AI Client is not available. Please install wordpress/wp-ai-client.' ); - } + private function initialize_ai_client() { + \WordPress\AI_Client\AI_Client::init(); + + add_filter( + 'user_has_cap', + static function ( array $allcaps ) { + $allcaps[ \WordPress\AI_Client\Capabilities\Capabilities_Manager::PROMPT_AI_CAPABILITY ] = true; + + return $allcaps; + } + ); + + WP_CLI::do_hook( 'ai_client_init' ); } } From 376fa4ec6c6bf521a65370a1910355e305005655 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 18 Dec 2025 21:06:25 +0100 Subject: [PATCH 22/27] Fix credentials command --- features/credentials.feature | 6 +++--- src/Credentials_Command.php | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/features/credentials.feature b/features/credentials.feature index e7bb0b5..107d66f 100644 --- a/features/credentials.feature +++ b/features/credentials.feature @@ -81,9 +81,9 @@ Feature: Manage AI provider credentials And I run `wp ai credentials set anthropic --api-key=sk-ant-api-456` And I run `wp ai credentials list` Then STDOUT should be a table containing rows: - | provider | api_key | - | openai | sk-*****i123 | - | anthropic | sk-********-456 | + | provider | api_key | + | openai | sk-*****i123 | + | anthropic | sk-*******-456 | Scenario: Update existing credentials When I run `wp ai credentials set openai --api-key=old-key-123` diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index 5f411b6..fb94fa8 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -166,9 +166,7 @@ public function set( $args, $assoc_args ) { $api_key = $assoc_args['api-key']; $credentials = $this->get_all_credentials(); - $credentials[ $provider ] = array( - 'api_key' => $api_key, - ); + $credentials[ $provider ] = $api_key; $this->save_all_credentials( $credentials ); From c3338ce3734d4b78697722350f95caf4d3f61459 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 18 Dec 2025 21:34:14 +0100 Subject: [PATCH 23/27] Some fixes --- features/generate.feature | 33 --------------------------------- src/AI_Command.php | 39 +++++++++++---------------------------- 2 files changed, 11 insertions(+), 61 deletions(-) diff --git a/features/generate.feature b/features/generate.feature index f37ffab..e5231a7 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -150,42 +150,10 @@ Feature: Generate AI content ); """ - Scenario: Check for WordPress AI Client availability - When I try `wp ai check "Test prompt"` - Then the return code should be 1 - And STDERR should contain: - """ - WordPress AI Client is not available - """ - - Scenario: Generate command requires AI Client - When I try `wp ai generate text "Test prompt"` - Then the return code should be 1 - And STDERR should contain: - """ - WordPress AI Client is not available - """ - - Scenario: Status command requires AI Client - When I try `wp ai status` - Then the return code should be 1 - And STDERR should contain: - """ - WordPress AI Client is not available - """ - Scenario: Generate command validates model format When I try `wp ai generate text "Test prompt" --model=invalidformat` Then the return code should be 1 - Scenario: Generate command validates temperature range - When I try `wp ai generate text "Test prompt" --temperature=3.0` - Then the return code should be 1 - And STDERR should contain: - """ - Temperature must be between 0.0 and 2.0 - """ - Scenario: Generate command validates max-tokens When I try `wp ai generate text "Test prompt" --max-tokens=-5` Then the return code should be 1 @@ -193,4 +161,3 @@ Feature: Generate AI content """ Max tokens must be a positive integer """ - diff --git a/src/AI_Command.php b/src/AI_Command.php index 5fe2d54..96d5fb0 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -103,7 +103,7 @@ class AI_Command extends WP_CLI_Command { * : Specific AI provider to use (e.g., "openai", "anthropic", "google"). * * [--temperature=] - * : Temperature for generation (0.0-2.0). Lower is more deterministic. + * : Temperature for generation, typically between 0.0 and 1.0. Lower is more deterministic. * * [--max-tokens=] * : Maximum number of tokens to generate. @@ -149,50 +149,34 @@ public function generate( $args, $assoc_args ) { list( $type, $prompt ) = $args; - $type = strtolower( $type ); - - if ( ! in_array( $type, array( 'text', 'image' ), true ) ) { - WP_CLI::error( 'Invalid type. Must be "text" or "image".' ); - } - try { $builder = AI_Client::prompt( $prompt ); - // Apply provider if specified if ( isset( $assoc_args['provider'] ) ) { $builder = $builder->using_provider( $assoc_args['provider'] ); } - // Apply model preferences if specified if ( isset( $assoc_args['model'] ) ) { - $model_parts = explode( ',', $assoc_args['model'] ); + // Models should be in pairs: provider:model,provider:model,... + // Convert to array of [provider, model] pairs. + $model_preferences = explode( ',', $assoc_args['model'] ); + foreach ( $model_preferences as $key => $value ) { + $value = explode( ':', $value ); - // Models should be in pairs: provider,model,provider,model,... - if ( count( $model_parts ) % 2 !== 0 ) { - WP_CLI::error( 'Model must be in format "provider,model" pairs (e.g., "openai,gpt-4" or "openai,gpt-4,anthropic,claude-3").' ); - } + $entries[ $key ] = $value; - // Convert flat array to array of [provider, model] pairs - $model_preferences = array(); - $parts_count = count( $model_parts ); - for ( $i = 0; $i < $parts_count; $i += 2 ) { - $model_preferences[] = array( $model_parts[ $i ], $model_parts[ $i + 1 ] ); + if ( count( $value ) !== 2 ) { + WP_CLI::error( 'Model must be in format "provider:model" pairs (e.g., "openai:gpt-4" or "openai:gpt-4,anthropic:claude-3").' ); + } } - // Pass all preferences to using_model_preference using spread operator $builder = $builder->using_model_preference( ...$model_preferences ); } - // Apply temperature if specified if ( isset( $assoc_args['temperature'] ) ) { - $temperature = (float) $assoc_args['temperature']; - if ( $temperature < 0.0 || $temperature > 2.0 ) { - WP_CLI::error( 'Temperature must be between 0.0 and 2.0.' ); - } - $builder = $builder->using_temperature( $temperature ); + $builder = $builder->using_temperature( (float) $assoc_args['temperature'] ); } - // Apply max tokens if specified if ( isset( $assoc_args['max-tokens'] ) ) { $max_tokens = (int) $assoc_args['max-tokens']; if ( $max_tokens <= 0 ) { @@ -201,7 +185,6 @@ public function generate( $args, $assoc_args ) { $builder = $builder->using_max_tokens( $max_tokens ); } - // Apply system instruction if specified if ( isset( $assoc_args['system-instruction'] ) ) { $builder = $builder->using_system_instruction( $assoc_args['system-instruction'] ); } From 2fff29671ca6e80ce6422a3cef1db2b2f0bf88f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:22:12 +0000 Subject: [PATCH 24/27] Add --top-p and --top-k parameters to generate command - Add --top-p flag for nucleus sampling (value between 0.0 and 1.0) - Add --top-k flag for top-k sampling (positive integer) - Map to using_top_p() and using_top_k() Prompt_Builder methods - Add validation for both parameters - Add example usage in documentation - Add Behat tests for parameter validation Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/generate.feature | 16 ++++++++++++++++ src/AI_Command.php | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/features/generate.feature b/features/generate.feature index e5231a7..51db7fe 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -161,3 +161,19 @@ Feature: Generate AI content """ Max tokens must be a positive integer """ + + Scenario: Generate command validates top-p range + When I try `wp ai generate text "Test prompt" --top-p=1.5` + Then the return code should be 1 + And STDERR should contain: + """ + Top-p must be between 0.0 and 1.0 + """ + + Scenario: Generate command validates top-k positive + When I try `wp ai generate text "Test prompt" --top-k=-10` + Then the return code should be 1 + And STDERR should contain: + """ + Top-k must be a positive integer + """ diff --git a/src/AI_Command.php b/src/AI_Command.php index 96d5fb0..2696609 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -105,6 +105,12 @@ class AI_Command extends WP_CLI_Command { * [--temperature=] * : Temperature for generation, typically between 0.0 and 1.0. Lower is more deterministic. * + * [--top-p=] + * : Top-p (nucleus sampling) parameter. Value between 0.0 and 1.0. + * + * [--top-k=] + * : Top-k sampling parameter. Positive integer. + * * [--max-tokens=] * : Maximum number of tokens to generate. * @@ -131,6 +137,9 @@ class AI_Command extends WP_CLI_Command { * # Generate text with specific settings * $ wp ai generate text "List 3 WordPress features" --temperature=0.1 --max-tokens=100 * + * # Generate with top-p and top-k sampling + * $ wp ai generate text "Write a story" --top-p=0.9 --top-k=40 + * * # Generate with model preferences * $ wp ai generate text "Write a haiku" --model=openai,gpt-4,anthropic,claude-3 * @@ -177,6 +186,22 @@ public function generate( $args, $assoc_args ) { $builder = $builder->using_temperature( (float) $assoc_args['temperature'] ); } + if ( isset( $assoc_args['top-p'] ) ) { + $top_p = (float) $assoc_args['top-p']; + if ( $top_p < 0.0 || $top_p > 1.0 ) { + WP_CLI::error( 'Top-p must be between 0.0 and 1.0.' ); + } + $builder = $builder->using_top_p( $top_p ); + } + + if ( isset( $assoc_args['top-k'] ) ) { + $top_k = (int) $assoc_args['top-k']; + if ( $top_k <= 0 ) { + WP_CLI::error( 'Top-k must be a positive integer.' ); + } + $builder = $builder->using_top_k( $top_k ); + } + if ( isset( $assoc_args['max-tokens'] ) ) { $max_tokens = (int) $assoc_args['max-tokens']; if ( $max_tokens <= 0 ) { From 43bf2cafb391666a7103e907083db4de0063c3f6 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 18 Dec 2025 22:39:00 +0100 Subject: [PATCH 25/27] update commands list --- composer.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 320ec9e..a3b4531 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,15 @@ }, "bundled": false, "commands": [ - "ai" + "ai", + "ai credentials", + "ai credentials get", + "ai credentials set", + "ai credentials delete", + "ai credentials list", + "ai check", + "ai generate", + "ai status" ] }, "autoload": { From 23d9bbe3fc96569a39d34009b1ac45b42e43e16d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 19 Dec 2025 10:00:16 +0100 Subject: [PATCH 26/27] PHPStan fixes --- phpstan.neon.dist | 5 ----- src/AI_Command.php | 25 +++++++++++++++---------- src/Credentials_Command.php | 20 ++++++++++---------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fec76dc..a40edaf 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,8 +8,3 @@ parameters: scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php treatPhpDocTypesAsCertain: false - ignoreErrors: - # WP-CLI command method signatures use untyped arrays - - - message: '#no value type specified in iterable type array#' - reportUnmatched: false diff --git a/src/AI_Command.php b/src/AI_Command.php index 2696609..cb06dd2 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -149,8 +149,8 @@ class AI_Command extends WP_CLI_Command { * # Generate image * $ wp ai generate image "A minimalist WordPress logo" --output=wp-logo.png * - * @param array $args Positional arguments. - * @param array $assoc_args Associative arguments. + * @param array{0: string, 1: string} $args Positional arguments. + * @param array{model: string, provider: string, temperature: float, 'top-p': float, 'top-k': int, 'max-tokens': int, 'system-instruction': string, output: string, format: string} $assoc_args Associative arguments. * @return void */ public function generate( $args, $assoc_args ) { @@ -233,7 +233,12 @@ public function generate( $args, $assoc_args ) { * : The prompt to check. * * [--type=] - * : Type to check. Options: text, image. Default: text + * : Type to check. + * --- + * options: + * - text + * - image + * --- * * ## EXAMPLES * @@ -243,8 +248,8 @@ public function generate( $args, $assoc_args ) { * # Check if image generation is supported * $ wp ai check "A sunset" --type=image * - * @param array $args Positional arguments. - * @param array $assoc_args Associative arguments. + * @param array{0: string} $args Positional arguments. + * @param array{type: string} $assoc_args Associative arguments. * @return void */ public function check( $args, $assoc_args ) { @@ -308,8 +313,8 @@ public function check( $args, $assoc_args ) { * | Image Generation | No | * +------------------+-----------+ * - * @param array $args Positional arguments. - * @param array $assoc_args Associative arguments. + * @param string[] $args Positional arguments. Unused. + * @param array{format: string} $assoc_args Associative arguments. * @return void */ public function status( $args, $assoc_args ) { @@ -342,7 +347,7 @@ public function status( $args, $assoc_args ) { * Generates text from the prompt builder. * * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. - * @param array $assoc_args Associative arguments. + * @param array{format: string} $assoc_args Associative arguments. * @return void */ private function generate_text( $builder, $assoc_args ) { @@ -383,8 +388,8 @@ private function generate_text( $builder, $assoc_args ) { /** * Generates an image from the prompt builder. * - * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. - * @param array $assoc_args Associative arguments. + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param array{output: string} $assoc_args Associative arguments. * @return void */ private function generate_image( $builder, $assoc_args ) { diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index fb94fa8..d2beea0 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -58,8 +58,8 @@ class Credentials_Command extends WP_CLI_Command { * @subcommand list * @when after_wp_load * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. + * @param string[] $args Positional arguments. Unused. + * @param array{format: string} $assoc_args Associative arguments. * @return void */ public function list_( $args, $assoc_args ) { @@ -107,8 +107,8 @@ public function list_( $args, $assoc_args ) { * * @when after_wp_load * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. + * @param array{0: string} $args Positional arguments. + * @param array{format: string} $assoc_args Associative arguments. * @return void */ public function get( $args, $assoc_args ) { @@ -156,8 +156,8 @@ public function get( $args, $assoc_args ) { * * @when after_wp_load * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. + * @param array{0: string} $args Positional arguments. + * @param array{'api-key': string} $assoc_args Associative array of associative arguments. * @return void */ public function set( $args, $assoc_args ) { @@ -189,8 +189,8 @@ public function set( $args, $assoc_args ) { * * @when after_wp_load * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. + * @param array{0: string} $args Positional arguments. + * @param array $assoc_args Associative arguments. Unused. * @return void */ public function delete( $args, $assoc_args ) { @@ -211,7 +211,7 @@ public function delete( $args, $assoc_args ) { /** * Gets all credentials from the database. * - * @return array + * @return array */ private function get_all_credentials() { $credentials = get_option( self::OPTION_NAME, array() ); @@ -226,7 +226,7 @@ private function get_all_credentials() { /** * Saves all credentials to the database. * - * @param array $credentials The credentials to save. + * @param array $credentials The credentials to save. * @return bool */ private function save_all_credentials( $credentials ) { From f5bb17aa82d7364c769cd14243e76a0bb8d8b455 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 19 Dec 2025 10:31:09 +0100 Subject: [PATCH 27/27] Simplification --- src/AI_Command.php | 130 +++++++++++++++------------------------------ 1 file changed, 42 insertions(+), 88 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index cb06dd2..c6716b7 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -29,7 +29,7 @@ * WordPress shines bright * * # Generate an image from a prompt - * $ wp ai generate image "A futuristic WordPress logo" --output=logo.png + * $ wp ai generate image "A futuristic WordPress logo" --destination-file=logo.png * Success: Image saved to logo.png * * # Check if a prompt is supported @@ -40,46 +40,6 @@ */ class AI_Command extends WP_CLI_Command { - /** - * Maximum binary image size in bytes (50MB). - */ - const MAX_IMAGE_SIZE_BYTES = 52428800; // 50 * 1024 * 1024 - - /** - * Dummy prompt used for capability checking. - * - * This constant provides a consistent prompt value when checking AI capabilities. - * The specific content doesn't affect capability detection, which is based on - * configured providers and their available features. - */ - const CAPABILITY_CHECK_PROMPT = 'capability-check'; - - /** - * System directories that should be protected from file writes. - */ - const FORBIDDEN_PATHS = array( - // Unix/Linux system directories - '/etc', - '/bin', - '/usr/bin', - '/sbin', - '/usr/sbin', - '/boot', - '/sys', - '/proc', - // Windows system directories (case-insensitive) - 'C:\\Windows', - 'C:\\Program Files', - 'C:\\Program Files (x86)', - ); - - /** - * Maximum size for base64-encoded image data. - * Base64 encoding increases size by ~33%, so 50MB binary = ~67MB base64. - * Using 70MB as safe upper bound. - */ - const MAX_IMAGE_SIZE_BASE64 = 70000000; - /** * Generates AI content. * @@ -117,9 +77,12 @@ class AI_Command extends WP_CLI_Command { * [--system-instruction=] * : System instruction to guide the AI's behavior. * - * [--output=] + * [--destination-file=] * : For image generation, path to save the generated image. * + * [--stdout] + * Output the whole image using standard output (incompatible with --destination-file=) + * * [--format=] * : Output format for text. * --- @@ -150,7 +113,7 @@ class AI_Command extends WP_CLI_Command { * $ wp ai generate image "A minimalist WordPress logo" --output=wp-logo.png * * @param array{0: string, 1: string} $args Positional arguments. - * @param array{model: string, provider: string, temperature: float, 'top-p': float, 'top-k': int, 'max-tokens': int, 'system-instruction': string, output: string, format: string} $assoc_args Associative arguments. + * @param array{model: string, provider: string, temperature: float, 'top-p': float, 'top-k': int, 'max-tokens': int, 'system-instruction': string, 'destination-file': string, stdout: bool, format: string} $assoc_args Associative arguments. * @return void */ public function generate( $args, $assoc_args ) { @@ -322,7 +285,7 @@ public function status( $args, $assoc_args ) { try { // Create a builder to check capabilities (using constant for consistency) - $builder = AI_Client::prompt( self::CAPABILITY_CHECK_PROMPT ); + $builder = AI_Client::prompt(); // Check each capability $capabilities = array( @@ -388,8 +351,8 @@ private function generate_text( $builder, $assoc_args ) { /** * Generates an image from the prompt builder. * - * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. - * @param array{output: string} $assoc_args Associative arguments. + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param array{'destination-file': string, stdout: bool} $assoc_args Associative arguments. * @return void */ private function generate_image( $builder, $assoc_args ) { @@ -398,59 +361,34 @@ private function generate_image( $builder, $assoc_args ) { WP_CLI::error( 'Image generation is not supported. Make sure AI provider credentials are configured.' ); } - $image_file = $builder->generate_image(); - - if ( isset( $assoc_args['output'] ) ) { - $output_path = $assoc_args['output']; + if ( ! empty( $assoc_args['stdout'] ) && ! empty( $assoc_args['dir'] ) ) { + WP_CLI::error( '--stdout and --destination-file cannot be used together.' ); + } - // Resolve the full real path - $parent_dir = dirname( $output_path ); + if ( isset( $assoc_args['destination-file'] ) ) { + $output_path = $assoc_args['destination-file']; + $parent_dir = dirname( $output_path ); - // Check if parent directory exists - if ( ! file_exists( $parent_dir ) || ! is_dir( $parent_dir ) ) { + if ( ! is_dir( $parent_dir ) ) { WP_CLI::error( 'Invalid output directory. Directory does not exist: ' . $parent_dir ); } + } - // Resolve the real path to prevent traversal attacks - $real_parent_dir = realpath( $parent_dir ); - if ( false === $real_parent_dir ) { - WP_CLI::error( 'Cannot resolve output directory path.' ); - } - - // Reconstruct the output path with the resolved parent directory - $safe_output_path = $real_parent_dir . DIRECTORY_SEPARATOR . basename( $output_path ); - - // Prevent writing to sensitive system directories - foreach ( self::FORBIDDEN_PATHS as $forbidden ) { - // Use case-sensitive check for Unix paths (start with /), case-insensitive for Windows (contain :\) - $is_windows_path = ( false !== strpos( $forbidden, ':\\' ) ); - $matches = $is_windows_path - ? ( 0 === stripos( $real_parent_dir, $forbidden ) ) - : ( 0 === strpos( $real_parent_dir, $forbidden ) ); + $image_file = $builder->generate_image(); - if ( $matches ) { - WP_CLI::error( 'Cannot write to system directory: ' . $safe_output_path ); - } - } + if ( isset( $assoc_args['destination-file'] ) ) { + $output_path = $assoc_args['destination-file']; + $output_path = realpath( dirname( $output_path ) ) . DIRECTORY_SEPARATOR . basename( $output_path ); - // Get the image content from data URI $data_uri = $image_file->getDataUri(); - // Extract base64 data from data URI $data_parts = $data_uri ? explode( ',', $data_uri, 2 ) : []; if ( count( $data_parts ) !== 2 ) { WP_CLI::error( 'Invalid image data received.' ); } - // Validate and decode base64 data $base64_data = $data_parts[1]; - // Check reasonable size limit - if ( strlen( $base64_data ) > self::MAX_IMAGE_SIZE_BASE64 ) { - WP_CLI::error( 'Image data exceeds maximum size limit (50MB).' ); - } - - // Try strict base64 decode - this validates format // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $image_data = base64_decode( $base64_data, true ); if ( false === $image_data ) { @@ -458,15 +396,31 @@ private function generate_image( $builder, $assoc_args ) { } // Save to file - $result = file_put_contents( $safe_output_path, $image_data ); + $result = file_put_contents( $output_path, $image_data ); if ( false === $result ) { - WP_CLI::error( 'Failed to save image to ' . $safe_output_path ); + WP_CLI::error( 'Failed to save image to ' . $output_path ); + } + + WP_CLI::success( 'Image saved to ' . $output_path ); + } elseif ( $assoc_args['stdout'] ) { + $data_uri = $image_file->getDataUri(); + + $data_parts = $data_uri ? explode( ',', $data_uri, 2 ) : []; + if ( count( $data_parts ) !== 2 ) { + WP_CLI::error( 'Invalid image data received.' ); + } + + $base64_data = $data_parts[1]; + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $image_data = base64_decode( $base64_data, true ); + if ( false === $image_data ) { + WP_CLI::error( 'Invalid base64 image data format.' ); } - WP_CLI::success( 'Image saved to ' . $safe_output_path ); + WP_CLI::log( $image_data ); } else { - // Output data URI - WP_CLI::success( 'Image generated (data URI):' ); + WP_CLI::success( 'Image generated:' ); WP_CLI::line( (string) $image_file->getDataUri() ); } }