Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
52a6a9b
feat: add alt-text generation support to wp ai generate command
alaminfirdows Jun 27, 2026
e81c133
test: add comprehensive feature test coverage for alt-text generation
alaminfirdows Jun 27, 2026
d941c8c
fix: correct gherkin syntax in feature tests
alaminfirdows Jun 27, 2026
a3b68b6
test: simplify feature tests to focus on attachment validation
alaminfirdows Jun 27, 2026
895d2ee
test: add success path coverage for alt-text generation
alaminfirdows Jun 27, 2026
f88012c
test: add coverage for builder configuration options
alaminfirdows Jun 27, 2026
4feb4ad
Add missing Behat step definition for base64 image file creation
alaminfirdows Jun 27, 2026
908f86a
Fix phpcs violations in FeatureContext
alaminfirdows Jun 27, 2026
1f0b7ca
wip
alaminfirdows Jun 27, 2026
d000934
wip
alaminfirdows Jun 27, 2026
65a03e9
wip
alaminfirdows Jun 27, 2026
70df951
test: add comprehensive alt-text generation test coverage
alaminfirdows Jun 27, 2026
3999cb5
test: remove edge case tests requiring local FeatureContext
alaminfirdows Jun 27, 2026
b8d9a3e
test: keep only alt-text validation tests
alaminfirdows Jun 27, 2026
7f7b223
test: add safe alt-text validation tests for better coverage
alaminfirdows Jun 27, 2026
58d588e
test: remove alt-text param validation tests
alaminfirdows Jun 27, 2026
4aaf351
test: add comprehensive alt-text generation test coverage
alaminfirdows Jun 27, 2026
c654305
test: remove non-image attachment test
alaminfirdows Jun 27, 2026
08a65b6
test: fix FeatureContext class collision with unique namespace
alaminfirdows Jun 27, 2026
955035a
test: add behat autoload for local FeatureContext
alaminfirdows Jun 27, 2026
d6c4624
test: replace base64 step with self-contained mu-plugin setup
alaminfirdows Jun 27, 2026
ffc2868
test: use wp eval + save STDOUT to get dynamic attachment ID
alaminfirdows Jun 27, 2026
7164427
test: add image input modality to mock provider for alt-text tests
alaminfirdows Jun 27, 2026
1f6e0fa
Add missing supported options to mock model for alt-text tests
alaminfirdows Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
"ai-command.php"
]
},
"autoload-dev": {
"psr-4": {
"WP_CLI\\AI\\Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
Expand Down
140 changes: 137 additions & 3 deletions features/generate.feature
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,14 @@ Feature: Generate AI content
// Supported options.
[
new SupportedOption(OptionEnum::candidateCount()),
new SupportedOption(OptionEnum::systemInstruction()),
new SupportedOption(OptionEnum::temperature()),
new SupportedOption(OptionEnum::topP()),
new SupportedOption(OptionEnum::topK()),
new SupportedOption(OptionEnum::maxTokens()),
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(),
[
Expand All @@ -67,8 +72,6 @@ Feature: Generate AI content
[ModalityEnum::text(), ModalityEnum::image()],
]
),
new SupportedOption(OptionEnum::candidateCount()),
new SupportedOption(OptionEnum::outputMimeType(), ['image/png']),
new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline(), FileTypeEnum::remote()]),
new SupportedOption(OptionEnum::outputMediaOrientation(), [
MediaOrientationEnum::square(),
Expand Down Expand Up @@ -274,3 +277,134 @@ Feature: Generate AI content
"""
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=
"""

@less-than-wp-7.0
Scenario: Alt-text generation not available on WP < 7.0
When I try `wp ai generate alt-text 1`
Then STDERR should contain:
"""
Requires WordPress 7.0 or greater.
"""
And the return code should be 1

@require-wp-7.0
Scenario: Alt-text generation fails when AI is disabled
Given a wp-content/mu-plugins/disable-ai.php file:
"""
<?php
add_filter( 'wp_supports_ai', '__return_false' );
"""

When I try `wp ai generate alt-text 1`
Then the return code should be 1
And STDERR should contain:
"""
AI features are not supported in this environment.
"""

@require-wp-7.0
Scenario: Alt-text generation fails with invalid attachment ID
When I try `wp ai generate alt-text invalid`
Then the return code should be 1
And STDERR should contain:
"""
Invalid attachment ID.
"""

@require-wp-7.0
Scenario: Alt-text generation fails with non-existent attachment ID
When I try `wp ai generate alt-text 999`
Then the return code should be 1
And STDERR should contain:
"""
Attachment with ID 999 not found.
"""

@require-wp-7.0
Scenario: Generates alt text for image attachment
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID}`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

@require-wp-7.0
Scenario: Alt-text generation with model option
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID} --model=wp-cli-mock-provider:wp-cli-mock-model`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

@require-wp-7.0
Scenario: Alt-text generation with provider option
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID} --provider=wp-cli-mock-provider`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

@require-wp-7.0
Scenario: Alt-text generation with temperature option
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID} --temperature=0.5`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

@require-wp-7.0
Scenario: Alt-text generation with top-p option
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID} --top-p=0.9`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

@require-wp-7.0
Scenario: Alt-text generation with top-k option
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID} --top-k=40`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

@require-wp-7.0
Scenario: Alt-text generation with max-tokens option
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID} --max-tokens=100`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

@require-wp-7.0
Scenario: Alt-text generation with custom system instruction
When I run `wp eval 'file_put_contents("/tmp/t.png",base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")); echo wp_insert_attachment(array("post_mime_type"=>"image/png","post_title"=>"Test","post_status"=>"inherit","post_content"=>""),"/tmp/t.png");'`
And save STDOUT as {ATTACHMENT_ID}
And I run `wp ai generate alt-text {ATTACHMENT_ID} --system-instruction="Describe image in one sentence"`
Then the return code should be 0
And STDOUT should contain:
"""
Alt text generated and saved for attachment
"""

147 changes: 146 additions & 1 deletion src/AI_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ class AI_Command extends WP_CLI_Command {
* options:
* - text
* - image
* - alt-text
* ---
*
* <prompt>
* : The prompt to send to the AI.
* : The prompt to send to the AI, or attachment ID for alt-text generation.
*
* [--model=<models>]
* : Comma-separated list of models in order of preference. Format: "provider,model" (e.g., "openai,gpt-4" or "openai,gpt-4,anthropic,claude-3").
Expand Down Expand Up @@ -112,6 +113,9 @@ class AI_Command extends WP_CLI_Command {
* # Generate image
* $ wp ai generate image "A minimalist WordPress logo" --output=wp-logo.png
*
* # Generate alt text for an attachment
* $ wp ai generate alt-text 123
*
* @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.
* @return void
Expand All @@ -124,6 +128,11 @@ public function generate( $args, $assoc_args ) {
WP_CLI::error( 'AI features are not supported in this environment.' );
}

if ( 'alt-text' === $type ) {
$this->generate_alt_text( $prompt, $assoc_args );
return;
}

try {
// @phpstan-ignore function.notFound
$builder = wp_ai_client_prompt( $prompt );
Expand Down Expand Up @@ -504,4 +513,140 @@ private function generate_image( $builder, $assoc_args ) {
WP_CLI::line( (string) $image_file->getDataUri() );
}
}

/**
* Generates alt text for an image attachment using AI.
*
* @param string $attachment_id The attachment ID.
* @param array{model: string, provider: string, temperature: float, 'top-p': float, 'top-k': int, 'max-tokens': int, 'system-instruction': string, format: string} $assoc_args Associative arguments.
* @return void
*/
private function generate_alt_text( $attachment_id, $assoc_args ) {
$id = (int) $attachment_id;

if ( $id <= 0 ) {
WP_CLI::error( 'Invalid attachment ID.' );
}

// Validate attachment exists and is an image.
$attachment = get_post( $id );
if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
WP_CLI::error( sprintf( 'Attachment with ID %d not found.', $id ) );
}

if ( ! wp_attachment_is_image( $id ) ) {
WP_CLI::error( sprintf( 'Attachment with ID %d is not an image.', $id ) );
}

try {
$file_path = get_attached_file( $id );
if ( ! $file_path ) {
WP_CLI::error( 'Unable to retrieve image file path.' );
}

if ( ! file_exists( $file_path ) ) {
WP_CLI::error( sprintf( 'Image file not found: %s', $file_path ) );
}

// Convert image file to data URI.
$mime_info = wp_check_filetype( $file_path );
$mime_type = $mime_info['type'];
if ( ! $mime_type ) {
WP_CLI::error( 'Unable to determine image mime type.' );
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$contents = file_get_contents( $file_path );
if ( false === $contents ) {
WP_CLI::error( 'Unable to read image file.' );
}

// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$data_uri = 'data:' . $mime_type . ';base64,' . base64_encode( $contents );

// @phpstan-ignore function.notFound
$builder = wp_ai_client_prompt( 'Generate alt text for this image.' )
->with_file( $data_uri );

if ( is_wp_error( $builder ) ) {
WP_CLI::error( $builder->get_error_message() );
}

if ( isset( $assoc_args['provider'] ) ) {
$builder = $builder->using_provider( $assoc_args['provider'] );
}

if ( isset( $assoc_args['model'] ) ) {
$model_preferences = explode( ',', $assoc_args['model'] );
foreach ( $model_preferences as $value ) {
$value = explode( ':', $value );

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").' );
}
}

$builder = $builder->using_model_preference( ...$model_preferences );
}

if ( isset( $assoc_args['temperature'] ) ) {
$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 ) {
WP_CLI::error( 'Max tokens must be a positive integer.' );
}
$builder = $builder->using_max_tokens( $max_tokens );
}

if ( isset( $assoc_args['system-instruction'] ) ) {
$builder = $builder->using_system_instruction( $assoc_args['system-instruction'] );
} else {
$builder = $builder->using_system_instruction( 'Keep the alt text under 125 characters and descriptive.' );
}

if ( ! $builder->is_supported_for_text_generation() ) {
WP_CLI::error( 'Text generation with image input is not supported. Make sure AI provider credentials are configured and support vision models.' );
}

$result = $builder->generate_text();

if ( is_wp_error( $result ) ) {
WP_CLI::error( $result->get_error_message() );
}

$alt_text = trim( $result );

// Truncate to 125 characters as per spec.
if ( strlen( $alt_text ) > 125 ) {
$alt_text = substr( $alt_text, 0, 125 );
}

// Update attachment metadata.
update_post_meta( $id, '_wp_attachment_image_alt', $alt_text );

WP_CLI::success( sprintf( 'Alt text generated and saved for attachment %d: %s', $id, $alt_text ) );

} catch ( \Exception $e ) {
WP_CLI::error( 'Alt text generation failed: ' . $e->getMessage() );
}
}
}
25 changes: 25 additions & 0 deletions tests/Context/FeatureContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace WP_CLI\AI\Tests\Context;

use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;

/**
* Local FeatureContext providing custom step definitions for alt-text tests.
*/
class FeatureContext implements Context {

/**
* Create a file with base64-encoded content.
*
* @Given a file :path with base64 content:
*/
public function given_a_file_with_base64_content( string $path, PyStringNode $content ): void {
$decoded = base64_decode( trim( $content->getRaw() ) );

Check warning on line 19 in tests/Context/FeatureContext.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

base64_decode() can be used to obfuscate code which is strongly discouraged. Please verify that the function is used for benign reasons.
if ( false === $decoded ) {
throw new \RuntimeException( "Failed to decode base64 content for file: $path" );
}
file_put_contents( $path, $decoded );
}
}
Loading