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/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": { diff --git a/features/credentials.feature b/features/credentials.feature new file mode 100644 index 0000000..107d66f --- /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-*********6789"}] + """ + + 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: + """ + missing --api-key parameter + """ + 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-*****i123 | + | anthropic | sk-*******-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/features/generate.feature b/features/generate.feature new file mode 100644 index 0000000..51db7fe --- /dev/null +++ b/features/generate.feature @@ -0,0 +1,179 @@ +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, + '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 WP_CLI_Mock_Provider::metadata(); + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + 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 implements ProviderInterface { + 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 [ + ( new WP_CLI_Mock_Model( 'wp-cli-mock-model' ) )->metadata() + ]; + } + + public function hasModelMetadata( string $modelId ): bool { + return true; + } + + public function getModelMetadata( string $modelId ): ModelMetadata { + return self::model()->metadata(); + } + }; + } + } + + WP_CLI::add_hook( + 'ai_client_init', + static function () { + AiClient::defaultRegistry()->registerProvider( WP_CLI_Mock_Provider::class ); + + ( new API_Credentials_Manager() )->initialize(); + } + ); + """ + + 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 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 + """ + + 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/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 9000c2e..c6716b7 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -4,25 +4,444 @@ 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. + * + * ## 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: + * Open source and free + * Empowering creators + * WordPress shines bright + * + * # Generate an image from a prompt + * $ wp ai generate image "A futuristic WordPress logo" --destination-file=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. + * + * [--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 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. + * + * [--system-instruction=] + * : System instruction to guide the AI's behavior. + * + * [--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. + * --- + * default: text + * options: + * - text + * - json + * --- + * + * ## EXAMPLES + * + * # Generate text + * $ wp ai generate text "Explain WordPress in one sentence" + * + * # 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 + * + * # 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 + * + * @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 + */ + public function generate( $args, $assoc_args ) { + $this->initialize_ai_client(); + + list( $type, $prompt ) = $args; + + try { + $builder = AI_Client::prompt( $prompt ); + + if ( isset( $assoc_args['provider'] ) ) { + $builder = $builder->using_provider( $assoc_args['provider'] ); + } + + if ( isset( $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 ); + + $entries[ $key ] = $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'] ); + } + + 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 + * --- + * + * ## 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{0: string} $args Positional arguments. + * @param array{type: string} $assoc_args Associative arguments. + * @return void + */ + public function check( $args, $assoc_args ) { + $this->initialize_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() ); + } + } + + /** + * 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 * - * # Greet the world. - * $ wp hello-world - * Success: Hello World! + * # Check AI status + * $ wp ai status + * +------------------+-----------+ + * | Capability | Supported | + * +------------------+-----------+ + * | Text Generation | Yes | + * | Image Generation | No | + * +------------------+-----------+ * - * @when before_wp_load + * @param string[] $args Positional arguments. Unused. + * @param array{format: string} $assoc_args Associative arguments. + * @return void + */ + public function status( $args, $assoc_args ) { + $this->initialize_ai_client(); + + try { + // Create a builder to check capabilities (using constant for consistency) + $builder = AI_Client::prompt(); + + // 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. * - * @param array $args Indexed array of positional arguments. - * @param array $assoc_args Associative array of associative arguments. + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param array{format: string} $assoc_args Associative arguments. * @return void */ - public function __invoke( $args, $assoc_args ) { - WP_CLI::success( 'Hello World!' ); + 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_result(); + + if ( 'json' === $format ) { + $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::line( $text->toText() ); + } + + $token_usage = $text->getTokenUsage()->toArray(); + + WP_CLI::debug( + sprintf( + "Summary:\nModel used: %s (%s)\nToken usage:\nInput tokens: %s\nOutput tokens: %s\nTotal: %s\n", + $text->getModelMetadata()->getName(), + $text->getProviderMetadata()->getName(), + $token_usage[ TokenUsage::KEY_PROMPT_TOKENS ], + $token_usage[ TokenUsage::KEY_COMPLETION_TOKENS ], + $token_usage[ TokenUsage::KEY_TOTAL_TOKENS ], + ), + 'ai' + ); + } + + /** + * Generates an image from the prompt builder. + * + * @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 ) { + // 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.' ); + } + + if ( ! empty( $assoc_args['stdout'] ) && ! empty( $assoc_args['dir'] ) ) { + WP_CLI::error( '--stdout and --destination-file cannot be used together.' ); + } + + if ( isset( $assoc_args['destination-file'] ) ) { + $output_path = $assoc_args['destination-file']; + $parent_dir = dirname( $output_path ); + + if ( ! is_dir( $parent_dir ) ) { + WP_CLI::error( 'Invalid output directory. Directory does not exist: ' . $parent_dir ); + } + } + + $image_file = $builder->generate_image(); + + if ( isset( $assoc_args['destination-file'] ) ) { + $output_path = $assoc_args['destination-file']; + $output_path = realpath( dirname( $output_path ) ) . DIRECTORY_SEPARATOR . basename( $output_path ); + + $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.' ); + } + + // 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 ); + } 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::log( $image_data ); + } else { + WP_CLI::success( 'Image generated:' ); + WP_CLI::line( (string) $image_file->getDataUri() ); + } + } + + /** + * Ensures WordPress AI Client is available. + * + * @return void + */ + 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' ); } } diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php new file mode 100644 index 0000000..d2beea0 --- /dev/null +++ b/src/Credentials_Command.php @@ -0,0 +1,260 @@ +] + * : 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 string[] $args Positional arguments. Unused. + * @param array{format: string} $assoc_args 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 => $api_key ) { + $items[] = array( + 'provider' => $provider, + 'api_key' => $this->mask_api_key( $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{0: string} $args Positional arguments. + * @param array{format: string} $assoc_args 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 ] ?? '' ), + ); + + $format = $assoc_args['format'] ?? 'json'; + + if ( 'json' === $format ) { + WP_CLI::line( (string) 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{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 ) { + list( $provider ) = $args; + + $api_key = $assoc_args['api-key']; + $credentials = $this->get_all_credentials(); + + $credentials[ $provider ] = $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{0: string} $args Positional arguments. + * @param array $assoc_args Associative arguments. Unused. + * @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( '*', min( 10, $length - 7 ) ) . substr( $api_key, -4 ); + } +}