Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"ai",
"ai check",
"ai generate",
"ai is-supported",
"ai status",
"connectors",
"connectors get",
Expand Down
106 changes: 92 additions & 14 deletions features/generate.feature
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Feature: Generate AI content
<?php

use WordPress\AiClient\AiClient;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\DTO\ModelMessage;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
Expand All @@ -17,6 +20,7 @@ Feature: Generate AI content
use WordPress\AiClient\Providers\Enums\ProviderTypeEnum;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\DTO\SupportedOption;
Expand All @@ -26,13 +30,12 @@ Feature: Generate AI content
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\DTO\TokenUsage;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
use WordPress\AI_Client\API_Credentials\API_Credentials_Manager;

if ( ! interface_exists( 'WordPress\AiClient\Providers\Models\Contracts\ModelInterface' ) ) {
return;
}

class WP_CLI_Mock_Model implements ModelInterface, TextGenerationModelInterface {
class WP_CLI_Mock_Model implements ModelInterface, TextGenerationModelInterface, ImageGenerationModelInterface {
private $id;
private $config;

Expand All @@ -52,19 +55,28 @@ Feature: Generate AI content
],
// Supported options.
[
new SupportedOption(
OptionEnum::inputModalities(),
[
[ModalityEnum::text()]
]
),
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::outputModalities(),
[
[ModalityEnum::text()],
[ModalityEnum::image()],
[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(),
MediaOrientationEnum::landscape(),
MediaOrientationEnum::portrait(),
]),
new SupportedOption(OptionEnum::outputMediaAspectRatio(), ['1:1', '7:4', '4:7']),
new SupportedOption(OptionEnum::customOptions()),
]
);
}
Expand All @@ -85,14 +97,14 @@ Feature: Generate AI content
// throw new RuntimeException('No candidates were generated');

$modelMessage = new ModelMessage([
new MessagePart('Generated content')
new MessagePart('This is mock-generated text')
]);
$candidate = new Candidate(
$modelMessage,
FinishReasonEnum::stop(),
42
);
$tokenUsage = new TokenUsage(10, 42, 52);
$tokenUsage = new TokenUsage( 10, 42, 52 );
return new GenerativeAiResult(
'result_123',
[ $candidate ],
Expand All @@ -106,6 +118,35 @@ Feature: Generate AI content
public function streamGenerateTextResult(array $prompt): Generator {
yield from [];
}

public function generateImageResult(array $prompt): GenerativeAiResult {
// throw new RuntimeException('No candidates were generated');

$modelMessage = new ModelMessage( [
new MessagePart(
// A base64-encoded 1x1 black PNG.
new File(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
'image/png'
)
)
] );
$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' ]
);
}

}

class WP_CLI_Mock_Provider implements ProviderInterface {
Expand Down Expand Up @@ -144,12 +185,10 @@ Feature: Generate AI content
}
}

WP_CLI::add_hook(
'ai_client_init',
WP_CLI::add_wp_hook(
'init',
static function () {
AiClient::defaultRegistry()->registerProvider( WP_CLI_Mock_Provider::class );

( new API_Credentials_Manager() )->initialize();
}
);
"""
Expand Down Expand Up @@ -196,3 +235,42 @@ Feature: Generate AI content
"""
Top-k must be a positive integer
"""

@require-wp-7.0
Scenario: Generate 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 text "Test prompt"`
Then the return code should be 1
And STDERR should contain:
"""
AI features are not supported in this environment.
"""

@require-wp-7.0
Scenario: Generates text using mock provider
When I run `wp ai status`
Then STDOUT should be a table containing rows:
| capability | supported |
| Text Generation | Yes |
| Image Generation | Yes |

@require-wp-7.0
Scenario: Generates text using mock provider
When I run `wp ai generate text "Test prompt"`
Then STDOUT should be:
Comment on lines +254 to +265
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two scenarios named “Generates text using mock provider”, but they test different behavior (wp ai status vs wp ai generate text). Renaming one of them would make failures easier to interpret and keep the feature file clearer.

Copilot uses AI. Check for mistakes.
"""
This is mock-generated text
"""

@require-wp-7.0
Scenario: Generates image using mock provider
When I run `wp ai generate image "Test prompt"`
Then STDOUT should be:
"""
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=
"""
25 changes: 25 additions & 0 deletions features/is-supported.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Feature: Check if AI features are supported

Background:
Given a WP install

@less-than-wp-7.0
Scenario: Command not available on WP < 7.0
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WP < 7.0 scenario name is misleading: the command is registered, but execution fails due to the package-level WordPress version guard ("Requires WordPress 7.0 or greater."). Consider renaming this scenario to reflect the actual behavior (e.g., "Errors on WP < 7.0").

Suggested change
Scenario: Command not available on WP < 7.0
Scenario: Errors on WP < 7.0

Copilot uses AI. Check for mistakes.
When I try `wp ai is-supported`
Then the return code should be 1

@require-wp-7.0
Scenario: AI is supported by default
When I run `wp ai is-supported`
Then the return code should be 0
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “AI is supported by default” scenario only asserts the exit code. Since this PR is introducing a user-facing command, it would be good to also assert the expected success output (to prevent regressions where the command stays silent).

Suggested change
Then the return code should be 0
Then the return code should be 0
And STDOUT should contain:
"""
AI is supported
"""

Copilot uses AI. Check for mistakes.

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

When I try `wp ai is-supported`
Then the return code should be 1
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “AI is not supported when disabled via filter” scenario only checks the exit code. To validate the new behavior end-to-end, add an assertion for the expected error message on STDERR (per the PR description/example).

Suggested change
Then the return code should be 1
Then the return code should be 1
And STDERR should contain:
"""
Error: AI is not supported when disabled via filter.
"""

Copilot uses AI. Check for mistakes.
82 changes: 79 additions & 3 deletions src/AI_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace WP_CLI\AI;

use WP_CLI;
use WP_CLI\Utils;
use WP_CLI_Command;
use WordPress\AiClient\Results\DTO\TokenUsage;

Expand Down Expand Up @@ -118,10 +119,19 @@ class AI_Command extends WP_CLI_Command {
public function generate( $args, $assoc_args ) {
list( $type, $prompt ) = $args;

// @phpstan-ignore function.notFound
if ( ! wp_supports_ai() ) {
WP_CLI::error( 'AI features are not supported in this environment.' );
}

try {
// @phpstan-ignore function.notFound
$builder = wp_ai_client_prompt( $prompt );

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

if ( isset( $assoc_args['provider'] ) ) {
$builder = $builder->using_provider( $assoc_args['provider'] );
}
Expand Down Expand Up @@ -185,6 +195,32 @@ public function generate( $args, $assoc_args ) {
}
}

/**
* Checks whether AI features are supported in the current environment.
*
* Exits with code 0 if AI features are supported, or code 1 if they are not.
*
* ## EXAMPLES
*
* # Check if AI is supported
* $ wp ai is-supported
* Success: AI features are supported.
*
* @subcommand is-supported
*
* @param string[] $args Positional arguments. Unused.
* @param string[] $assoc_args Associative arguments. Unused.
* @return void
*/
public function is_supported( $args, $assoc_args ) {
// @phpstan-ignore function.notFound
if ( wp_supports_ai() ) {
WP_CLI::halt( 0 );
} else {
WP_CLI::halt( 1 );
Comment on lines +218 to +220
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_supported() currently only halts with an exit code and produces no user-facing output. This conflicts with the command’s docblock example and the PR description (which show Success:/Error: messaging). Consider printing a success message when supported and using WP_CLI::error() (or equivalent) with a clear message when unsupported, while preserving the intended exit codes.

Suggested change
WP_CLI::halt( 0 );
} else {
WP_CLI::halt( 1 );
WP_CLI::success( 'AI features are supported.' );
} else {
WP_CLI::error( 'AI features are not supported in this environment.' );

Copilot uses AI. Check for mistakes.
}
}

/**
* Checks if a prompt is supported for generation.
*
Expand Down Expand Up @@ -217,10 +253,19 @@ public function check( $args, $assoc_args ) {
list( $prompt ) = $args;
$type = $assoc_args['type'] ?? 'text';

// @phpstan-ignore function.notFound
if ( ! wp_supports_ai() ) {
WP_CLI::error( 'AI features are not supported in this environment.' );
}

try {
// @phpstan-ignore function.notFound
$builder = wp_ai_client_prompt( $prompt );

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

if ( 'text' === $type ) {
$supported = $builder->is_supported_for_text_generation();
if ( $supported ) {
Expand Down Expand Up @@ -267,7 +312,7 @@ public function check( $args, $assoc_args ) {
* # Check AI status
* $ wp ai status
* +------------------+-----------+
* | Capability | Supported |
* | capability | supported |
* +------------------+-----------+
* | Text Generation | Yes |
* | Image Generation | No |
Expand All @@ -282,6 +327,10 @@ public function status( $args, $assoc_args ) {
// @phpstan-ignore function.notFound
$builder = wp_ai_client_prompt();

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

// Check each capability
$capabilities = array(
array(
Expand All @@ -292,6 +341,26 @@ public function status( $args, $assoc_args ) {
'capability' => 'Image Generation',
'supported' => $builder->is_supported_for_image_generation() ? 'Yes' : 'No',
),
array(
'capability' => 'Text to Speech Generation',
'supported' => $builder->is_supported_for_text_to_speech_conversion() ? 'Yes' : 'No',
),
array(
'capability' => 'Video Generation',
'supported' => $builder->is_supported_for_video_generation() ? 'Yes' : 'No',
),
array(
'capability' => 'Speech Generation',
'supported' => $builder->is_supported_for_speech_generation() ? 'Yes' : 'No',
),
array(
'capability' => 'Music Generation',
'supported' => $builder->is_supported_for_music_generation() ? 'Yes' : 'No',
),
array(
'capability' => 'Embedding Generation',
'supported' => $builder->is_supported_for_embedding_generation() ? 'Yes' : 'No',
),
);

$format = $assoc_args['format'] ?? 'table';
Expand Down Expand Up @@ -321,6 +390,10 @@ private function generate_text( $builder, $assoc_args ) {
// @phpstan-ignore class.notFound
$text = $builder->generate_text_result();

if ( is_wp_error( $text ) ) {
WP_CLI::error( $text );
}

if ( 'json' === $format ) {
$json = json_encode( array( 'text' => $text->toText() ) );
if ( false === $json ) {
Expand Down Expand Up @@ -380,6 +453,10 @@ private function generate_image( $builder, $assoc_args ) {
// @phpstan-ignore class.notFound
$image_file = $builder->generate_image();

if ( is_wp_error( $image_file ) ) {
WP_CLI::error( $image_file );
}

if ( isset( $assoc_args['destination-file'] ) ) {
$output_path = $assoc_args['destination-file'];
$output_path = realpath( dirname( $output_path ) ) . DIRECTORY_SEPARATOR . basename( $output_path );
Expand All @@ -406,7 +483,7 @@ private function generate_image( $builder, $assoc_args ) {
}

WP_CLI::success( 'Image saved to ' . $output_path );
} elseif ( $assoc_args['stdout'] ) {
} elseif ( Utils\get_flag_value( $assoc_args, 'stdout', false ) ) {
$data_uri = $image_file->getDataUri();

$data_parts = $data_uri ? explode( ',', $data_uri, 2 ) : [];
Expand All @@ -424,7 +501,6 @@ private function generate_image( $builder, $assoc_args ) {

WP_CLI::log( $image_data );
} else {
WP_CLI::success( 'Image generated:' );
WP_CLI::line( (string) $image_file->getDataUri() );
}
}
Expand Down
Loading