From 2e800e19e521b001ef4e4e6c2010a0a2461d55ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:56:42 +0000 Subject: [PATCH 1/6] Initial plan From 2b55506e095a53d0f072ecfc4b565748c6bcc535 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:04:38 +0000 Subject: [PATCH 2/6] Add --image option to wp ai generate text for vision input Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/generate.feature | 49 +++++++++++++- src/AI_Command.php | 131 +++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/features/generate.feature b/features/generate.feature index 8bbb1ae..c0bc5be 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -58,7 +58,11 @@ Feature: Generate AI content new SupportedOption(OptionEnum::candidateCount()), new SupportedOption(OptionEnum::outputMimeType(), ['image/png']), new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::inputModalities(), [ + [ModalityEnum::text()], + [ModalityEnum::image()], + [ModalityEnum::text(), ModalityEnum::image()], + ]), new SupportedOption( OptionEnum::outputModalities(), [ @@ -274,3 +278,46 @@ Feature: Generate AI content """ data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII= """ + + @require-wp-7.0 + Scenario: Generates text with a local image file + Given a test-image.png file: + """ + fake-png-data + """ + + When I run `wp ai generate text "Describe this image" --image=test-image.png` + Then STDOUT should be: + """ + This is mock-generated text + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-existent local file + When I try `wp ai generate text "Describe this" --image=/tmp/nonexistent-image-12345.png` + Then the return code should be 1 + And STDERR should contain: + """ + Image file not found + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-existent attachment + When I try `wp ai generate text "Describe this" --image=99999` + Then the return code should be 1 + And STDERR should contain: + """ + Attachment with ID 99999 not found + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-image attachment + When I run `wp post create --post_title="Test Post" --post_type=post --post_status=publish --porcelain` + Then save STDOUT as {POST_ID} + + When I try `wp ai generate text "Describe this" --image={POST_ID}` + Then the return code should be 1 + And STDERR should contain: + """ + not found + """ diff --git a/src/AI_Command.php b/src/AI_Command.php index dca65a9..fa404f0 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -77,6 +77,9 @@ class AI_Command extends WP_CLI_Command { * [--system-instruction=] * : System instruction to guide the AI's behavior. * + * [--image=] + * : An image to use as input for text generation. Can be a local file path, a URL, or a WordPress attachment ID. + * * [--destination-file=] * : For image generation, path to save the generated image. * @@ -109,11 +112,20 @@ class AI_Command extends WP_CLI_Command { * # Generate with system instruction * $ wp ai generate text "Explain AI" --system-instruction="Explain as if to a 5-year-old" * + * # Generate text from a prompt with an image + * $ wp ai generate text "Describe this image" --image=photo.jpg + * + * # Generate alt text for an attachment + * $ wp ai generate text "Generate alt text for this image" --image=42 + * + * # Generate text with an image URL + * $ wp ai generate text "What is in this image?" --image=https://example.com/photo.jpg + * * # Generate image * $ wp ai generate image "A minimalist WordPress logo" --destination-file=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, 'destination-file': string, stdout: bool, 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, image: string, 'destination-file': string, stdout: bool, format: string} $assoc_args Associative arguments. * @return void */ public function generate( $args, $assoc_args ) { @@ -186,6 +198,9 @@ public function generate( $args, $assoc_args ) { } if ( 'text' === $type ) { + if ( isset( $assoc_args['image'] ) ) { + $builder = $this->with_image_input( $builder, $assoc_args['image'] ); + } $this->generate_text( $builder, $assoc_args ); } elseif ( 'image' === $type ) { $this->generate_image( $builder, $assoc_args ); @@ -504,4 +519,118 @@ private function generate_image( $builder, $assoc_args ) { WP_CLI::line( (string) $image_file->getDataUri() ); } } + + /** + * Adds image input to the prompt builder. + * + * Accepts a local file path, a URL (http/https), a data URI, or a WordPress attachment ID. + * + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param string $image_input Local file path, URL, data URI, or attachment ID. + * @return mixed The updated prompt builder. + * + * @phpstan-ignore class.notFound + */ + private function with_image_input( $builder, $image_input ) { + // Attachment ID (numeric string). + if ( ctype_digit( (string) $image_input ) ) { + return $this->with_attachment_input( $builder, (int) $image_input ); + } + + // Data URI or remote URL — pass directly to with_file(). + if ( + 0 === strpos( $image_input, 'data:' ) || + 0 === strpos( $image_input, 'http://' ) || + 0 === strpos( $image_input, 'https://' ) + ) { + // @phpstan-ignore class.notFound + return $builder->with_file( $image_input ); + } + + // Local file path. + return $this->with_local_file_input( $builder, $image_input ); + } + + /** + * Adds a WordPress attachment as image input to the prompt builder. + * + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param int $attachment_id The attachment ID. + * @return mixed The updated prompt builder. + * + * @phpstan-ignore class.notFound + */ + private function with_attachment_input( $builder, $attachment_id ) { + $attachment = get_post( $attachment_id ); + + if ( ! $attachment || 'attachment' !== $attachment->post_type ) { + WP_CLI::error( "Attachment with ID {$attachment_id} not found." ); + } + + if ( ! wp_attachment_is_image( $attachment_id ) ) { + WP_CLI::error( "Attachment {$attachment_id} is not an image." ); + } + + $file_path = get_attached_file( $attachment_id ); + + if ( $file_path && file_exists( $file_path ) ) { + $mime_type = get_post_mime_type( $attachment_id ); + if ( ! $mime_type ) { + WP_CLI::error( "Could not determine MIME type for attachment {$attachment_id}." ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $file_path ); + if ( false === $contents ) { + WP_CLI::error( "Could not read image file for attachment {$attachment_id}." ); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $data_uri = 'data:' . $mime_type . ';base64,' . base64_encode( $contents ); + + // @phpstan-ignore class.notFound + return $builder->with_file( $data_uri ); + } + + // Fall back to the attachment URL. + $image_src = wp_get_attachment_image_src( $attachment_id, 'full' ); + if ( $image_src && ! empty( $image_src[0] ) ) { + // @phpstan-ignore class.notFound + return $builder->with_file( $image_src[0] ); + } + + WP_CLI::error( "Could not retrieve image for attachment {$attachment_id}." ); + } + + /** + * Adds a local image file as input to the prompt builder. + * + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param string $file_path The local file path. + * @return mixed The updated prompt builder. + * + * @phpstan-ignore class.notFound + */ + private function with_local_file_input( $builder, $file_path ) { + if ( ! file_exists( $file_path ) ) { + WP_CLI::error( "Image file not found: {$file_path}" ); + } + + $mime_type = wp_check_filetype( $file_path )['type']; + if ( ! $mime_type ) { + WP_CLI::error( "Could not determine MIME type for: {$file_path}" ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $file_path ); + if ( false === $contents ) { + WP_CLI::error( "Could not read image file: {$file_path}" ); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $data_uri = 'data:' . $mime_type . ';base64,' . base64_encode( $contents ); + + // @phpstan-ignore class.notFound + return $builder->with_file( $data_uri ); + } } From fb5178f8fedea6f2e1a528d5f2e7b528e979db94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:06:19 +0000 Subject: [PATCH 3/6] Add image MIME type validation and improve test scenarios Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/generate.feature | 16 +++++++++++++++- src/AI_Command.php | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/features/generate.feature b/features/generate.feature index c0bc5be..c143d94 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -311,7 +311,21 @@ Feature: Generate AI content """ @require-wp-7.0 - Scenario: Generate text with image input fails for non-image attachment + Scenario: Generate text with image input fails for non-image local file + Given a test-document.txt file: + """ + hello world + """ + + When I try `wp ai generate text "Describe this" --image=test-document.txt` + Then the return code should be 1 + And STDERR should contain: + """ + File does not appear to be an image + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-attachment post When I run `wp post create --post_title="Test Post" --post_type=post --post_status=publish --porcelain` Then save STDOUT as {POST_ID} diff --git a/src/AI_Command.php b/src/AI_Command.php index fa404f0..58a5588 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -617,8 +617,8 @@ private function with_local_file_input( $builder, $file_path ) { } $mime_type = wp_check_filetype( $file_path )['type']; - if ( ! $mime_type ) { - WP_CLI::error( "Could not determine MIME type for: {$file_path}" ); + if ( ! $mime_type || 0 !== strpos( $mime_type, 'image/' ) ) { + WP_CLI::error( "File does not appear to be an image: {$file_path}" ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents From 4e8395f66f1d91b7024a224656a4b432ba293cc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:07:23 +0000 Subject: [PATCH 4/6] Use regex for attachment ID detection and improve clarity Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index 58a5588..5f9e24b 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -532,8 +532,8 @@ private function generate_image( $builder, $assoc_args ) { * @phpstan-ignore class.notFound */ private function with_image_input( $builder, $image_input ) { - // Attachment ID (numeric string). - if ( ctype_digit( (string) $image_input ) ) { + // Attachment ID (positive integer string). + if ( preg_match( '/^\d+$/', (string) $image_input ) ) { return $this->with_attachment_input( $builder, (int) $image_input ); } @@ -616,7 +616,8 @@ private function with_local_file_input( $builder, $file_path ) { WP_CLI::error( "Image file not found: {$file_path}" ); } - $mime_type = wp_check_filetype( $file_path )['type']; + $filetype = wp_check_filetype( $file_path ); + $mime_type = $filetype['type']; if ( ! $mime_type || 0 !== strpos( $mime_type, 'image/' ) ) { WP_CLI::error( "File does not appear to be an image: {$file_path}" ); } From 4a4933b3ec3b0aa426c0f42353a51dd281888ef4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:25:07 +0000 Subject: [PATCH 5/6] Address PR review feedback for image input handling Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/generate.feature | 24 +++++++++++++++++++++++- src/AI_Command.php | 15 +++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/features/generate.feature b/features/generate.feature index c143d94..235e9cf 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -279,6 +279,19 @@ Feature: Generate AI content data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII= """ + @require-wp-7.0 + Scenario: Generates image with a local image file + Given a test-image.png file: + """ + fake-png-data + """ + + When I run `wp ai generate image "Edit this image" --image=test-image.png` + Then STDOUT should be: + """ + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII= + """ + @require-wp-7.0 Scenario: Generates text with a local image file Given a test-image.png file: @@ -333,5 +346,14 @@ Feature: Generate AI content Then the return code should be 1 And STDERR should contain: """ - not found + is not an attachment + """ + + @require-wp-7.0 + Scenario: Generate text with image input treats 0 as a file path + When I try `wp ai generate text "Describe this image" --image=0` + Then the return code should be 1 + And STDERR should contain: + """ + Image file not found: 0 """ diff --git a/src/AI_Command.php b/src/AI_Command.php index 5f9e24b..f991d75 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -78,7 +78,7 @@ class AI_Command extends WP_CLI_Command { * : System instruction to guide the AI's behavior. * * [--image=] - * : An image to use as input for text generation. Can be a local file path, a URL, or a WordPress attachment ID. + * : An image to use as input for text or image generation. Can be a local file path, a URL, a data URI, or a WordPress attachment ID. * * [--destination-file=] * : For image generation, path to save the generated image. @@ -125,7 +125,7 @@ class AI_Command extends WP_CLI_Command { * $ wp ai generate image "A minimalist WordPress logo" --destination-file=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, image: string, 'destination-file': string, stdout: bool, 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, image?: string, 'destination-file': string, stdout: bool, format: string} $assoc_args Associative arguments. * @return void */ public function generate( $args, $assoc_args ) { @@ -203,6 +203,9 @@ public function generate( $args, $assoc_args ) { } $this->generate_text( $builder, $assoc_args ); } elseif ( 'image' === $type ) { + if ( isset( $assoc_args['image'] ) ) { + $builder = $this->with_image_input( $builder, $assoc_args['image'] ); + } $this->generate_image( $builder, $assoc_args ); } } catch ( \Exception $e ) { @@ -533,7 +536,7 @@ private function generate_image( $builder, $assoc_args ) { */ private function with_image_input( $builder, $image_input ) { // Attachment ID (positive integer string). - if ( preg_match( '/^\d+$/', (string) $image_input ) ) { + if ( ctype_digit( (string) $image_input ) && (int) $image_input > 0 ) { return $this->with_attachment_input( $builder, (int) $image_input ); } @@ -563,10 +566,14 @@ private function with_image_input( $builder, $image_input ) { private function with_attachment_input( $builder, $attachment_id ) { $attachment = get_post( $attachment_id ); - if ( ! $attachment || 'attachment' !== $attachment->post_type ) { + if ( ! $attachment ) { WP_CLI::error( "Attachment with ID {$attachment_id} not found." ); } + if ( 'attachment' !== $attachment->post_type ) { + WP_CLI::error( "Post with ID {$attachment_id} is not an attachment." ); + } + if ( ! wp_attachment_is_image( $attachment_id ) ) { WP_CLI::error( "Attachment {$attachment_id} is not an image." ); } From 9b9dd4d23333b50e9f5131acf70853cd3c9f481d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:26:02 +0000 Subject: [PATCH 6/6] Polish image input digit check readability Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/AI_Command.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AI_Command.php b/src/AI_Command.php index f991d75..dbb1a7c 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -536,7 +536,7 @@ private function generate_image( $builder, $assoc_args ) { */ private function with_image_input( $builder, $image_input ) { // Attachment ID (positive integer string). - if ( ctype_digit( (string) $image_input ) && (int) $image_input > 0 ) { + if ( ctype_digit( $image_input ) && (int) $image_input > 0 ) { return $this->with_attachment_input( $builder, (int) $image_input ); }