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
12 changes: 6 additions & 6 deletions features/credentials.feature
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,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-*********6789"}]
[{"provider":"openai","api_key":"••••••••••••6789"}]
"""

@require-wp-7.0
Expand All @@ -51,7 +51,7 @@ Feature: Manage AI provider credentials
"""
And STDOUT should contain:
"""
"api_key":"sk-**********-123"
"api_key":"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022-123"
"""

@require-wp-7.0
Expand Down Expand Up @@ -80,7 +80,7 @@ Feature: Manage AI provider credentials
When I try `wp ai credentials get nonexistent`
Then STDERR should contain:
"""
Error: Credentials for provider "nonexistent" not found.
Error: Provider "nonexistent" is not a supported AI connector.
"""
And the return code should be 1

Expand All @@ -100,8 +100,8 @@ Feature: Manage AI provider credentials
And I run `wp ai credentials list`
Then STDOUT should be a table containing rows:
| provider | api_key |
| openai | sk-*****i123 |
| anthropic | sk-*******-456 |
| openai | ••••••••i123 |
| anthropic | ••••••••••-456 |

@require-wp-7.0
Scenario: Update existing credentials
Expand All @@ -120,5 +120,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":"\u2022\u2022\u2022\u2022\u2022\u2022\u2022-456"
"""
109 changes: 62 additions & 47 deletions src/Credentials_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@
*/
class Credentials_Command extends WP_CLI_Command {

/**
* The option name where credentials are stored.
*/
const OPTION_NAME = 'wp_ai_client_provider_credentials';

/**
* Lists all stored AI provider credentials.
*
Expand All @@ -52,7 +47,7 @@ class Credentials_Command extends WP_CLI_Command {
* +----------+----------+
* | provider | api_key |
* +----------+----------+
* | openai | sk-***** |
* | openai | ••••••• |
* +----------+----------+
*
* @subcommand list
Expand All @@ -74,7 +69,7 @@ public function list_( $args, $assoc_args ) {
foreach ( $credentials as $provider => $api_key ) {
$items[] = array(
'provider' => $provider,
'api_key' => $this->mask_api_key( $api_key ?? '' ),
'api_key' => $api_key,
);
}

Expand Down Expand Up @@ -103,7 +98,7 @@ public function list_( $args, $assoc_args ) {
*
* # Get OpenAI credentials
* $ wp ai credentials get openai
* {"provider":"openai","api_key":"sk-*****"}
* {"provider":"openai","api_key":"••••••••••••6789"}
*
* @when after_wp_load
*
Expand All @@ -114,15 +109,17 @@ public function list_( $args, $assoc_args ) {
public function get( $args, $assoc_args ) {
list( $provider ) = $args;

$credentials = $this->get_all_credentials();
$option_name = $this->get_connector_setting_name( $provider );
$raw_key = get_option( $option_name, '' );
$api_key = is_string( $raw_key ) ? $raw_key : '';

if ( ! isset( $credentials[ $provider ] ) ) {
if ( '' === $api_key ) {
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' => $api_key,
);

$format = $assoc_args['format'] ?? 'json';
Expand Down Expand Up @@ -164,11 +161,11 @@ public function set( $args, $assoc_args ) {
list( $provider ) = $args;

$api_key = $assoc_args['api-key'];
$credentials = $this->get_all_credentials();

$credentials[ $provider ] = $api_key;
$option_name = $this->get_connector_setting_name( $provider );

$this->save_all_credentials( $credentials );
// Remove any sanitize callback to bypass provider-side validation (e.g., live API checks).
remove_all_filters( "sanitize_option_{$option_name}" );
update_option( $option_name, $api_key, false );

WP_CLI::success( sprintf( 'Credentials for provider "%s" have been saved.', $provider ) );
}
Expand Down Expand Up @@ -196,69 +193,87 @@ public function set( $args, $assoc_args ) {
public function delete( $args, $assoc_args ) {
list( $provider ) = $args;

$credentials = $this->get_all_credentials();
$option_name = $this->get_connector_setting_name( $provider );
$raw_key = get_option( $option_name, '' );
$api_key = is_string( $raw_key ) ? $raw_key : '';

if ( ! isset( $credentials[ $provider ] ) ) {
if ( '' === $api_key ) {
WP_CLI::error( sprintf( 'Credentials for provider "%s" not found.', $provider ) );
}

unset( $credentials[ $provider ] );
$this->save_all_credentials( $credentials );
delete_option( $option_name );

WP_CLI::success( sprintf( 'Credentials for provider "%s" have been deleted.', $provider ) );
}

/**
* Gets all credentials from the database.
* Gets the option name for a provider's API key from the connector registry.
*
* @return array<string, string>
* @param string $provider The connector/provider ID.
* @return string The option name.
*/
private function get_all_credentials() {
$credentials = get_option( self::OPTION_NAME, array() );
private function get_connector_setting_name( string $provider ): string {
if ( ! function_exists( '_wp_connectors_get_connector_settings' ) ) {
WP_CLI::error( 'Requires WordPress 7.0 or greater.' );
}

if ( ! is_array( $credentials ) ) {
return array();
$settings = _wp_connectors_get_connector_settings();

if ( ! isset( $settings[ $provider ] ) ) {
WP_CLI::error( sprintf( 'Provider "%s" is not a supported AI connector.', $provider ) );
}

/**
* @var array<string, string> $credentials
*/
$setting_name = $this->get_api_key_setting_name( $settings[ $provider ]['authentication'] ?? [] );

return $credentials;
if ( null === $setting_name ) {
WP_CLI::error( sprintf( 'Provider "%s" does not support API key authentication.', $provider ) );
}

return $setting_name;
}

/**
* Saves all credentials to the database.
* Returns the option/setting name if the given authentication config is an API key type, or null otherwise.
*
* @param array<string, string> $credentials The credentials to save.
* @return bool
* @param mixed $auth Authentication config from the connector registry.
* @return string|null
*/
private function save_all_credentials( $credentials ) {
if ( empty( $credentials ) ) {
return delete_option( self::OPTION_NAME );
private function get_api_key_setting_name( $auth ): ?string {
if ( ! is_array( $auth ) || ! isset( $auth['method'] ) || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
return null;
}

return update_option( self::OPTION_NAME, $credentials, false );
return (string) $auth['setting_name'];
}

/**
* Masks an API key for display purposes.
* Gets all credentials from the database.
*
* @param string $api_key The API key to mask.
* @return string
* @return array<string, string>
*/
private function mask_api_key( $api_key ) {
if ( empty( $api_key ) ) {
return '';
private function get_all_credentials() {
if ( ! function_exists( '_wp_connectors_get_connector_settings' ) ) {
return array();
}

$length = strlen( $api_key );
$credentials = array();

if ( $length <= 8 ) {
return str_repeat( '*', $length );
foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) {
$setting_name = $this->get_api_key_setting_name( $connector_data['authentication'] ?? [] );

if ( null === $setting_name ) {
continue;
}

$raw = get_option( $setting_name, '' );
$value = is_string( $raw ) ? $raw : '';
if ( '' !== $value ) {
$credentials[ $connector_id ] = $value;
}
}

// Show first 3 and last 4 characters
return substr( $api_key, 0, 3 ) . str_repeat( '*', min( 10, $length - 7 ) ) . substr( $api_key, -4 );
ksort( $credentials );

return $credentials;
}
}