From 11d323bf4e8fb67777daaf176cd3b4e33671b8f9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 22 Mar 2026 23:26:39 +0000 Subject: [PATCH] Add agent config and external site CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wp datamachine agents config — read/update agent_config JSON --set='key=value' for setting keys (JSON-parsed values) --unset=key for removing keys --set='site_scope=7' for updating the site_scope column directly wp datamachine external — manage connections to other DM instances add --token=... [--verify] — register external token list — show all external connections show [--show-token] — connection details remove — delete a connection test — verify connectivity and auth against remote site --- inc/Cli/Bootstrap.php | 1 + inc/Cli/Commands/AgentsCommand.php | 162 ++++++++++++ inc/Cli/Commands/ExternalCommand.php | 356 +++++++++++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 inc/Cli/Commands/ExternalCommand.php diff --git a/inc/Cli/Bootstrap.php b/inc/Cli/Bootstrap.php index c8bb4ab5e..b3742689f 100644 --- a/inc/Cli/Bootstrap.php +++ b/inc/Cli/Bootstrap.php @@ -42,6 +42,7 @@ WP_CLI::add_command( 'datamachine processed-items', Commands\ProcessedItemsCommand::class ); WP_CLI::add_command( 'datamachine retention', Commands\RetentionCommand::class ); WP_CLI::add_command( 'datamachine test', Commands\TestCommand::class ); +WP_CLI::add_command( 'datamachine external', Commands\ExternalCommand::class ); // Aliases for AI agent compatibility (singular/plural variants). WP_CLI::add_command( 'datamachine setting', Commands\SettingsCommand::class ); diff --git a/inc/Cli/Commands/AgentsCommand.php b/inc/Cli/Commands/AgentsCommand.php index c13173c63..7aeb8021c 100644 --- a/inc/Cli/Commands/AgentsCommand.php +++ b/inc/Cli/Commands/AgentsCommand.php @@ -726,6 +726,168 @@ private function tokenRevoke( $abilities, int $agent_id, int $token_id ): void { WP_CLI::success( $result['message'] ?? 'Token revoked.' ); } + /** + * Read or update agent configuration. + * + * Without flags, displays the current config. With --set or --unset, + * modifies individual config keys. Supports dot notation for nested keys. + * + * ## OPTIONS + * + * + * : Agent slug or numeric ID. + * + * [--set=] + * : Set a config key (format: key=value). Value is JSON-parsed (arrays, objects, strings, numbers). + * For arrays, pass JSON: --set='allowed_redirect_uris=["https://example.com/*"]' + * Can be used multiple times. + * + * [--unset=] + * : Remove a config key. Can be used multiple times. + * + * [--format=] + * : Output format. + * --- + * default: json + * options: + * - json + * - table + * --- + * + * ## EXAMPLES + * + * # View agent config + * wp datamachine agents config sarai + * + * # Set allowed redirect URIs + * wp datamachine agents config sarai --set='allowed_redirect_uris=["saraichinwag.com","https://saraichinwag.com/*"]' + * + * # Set a single key + * wp datamachine agents config sarai --set='model=gpt-4o' + * + * # Remove a key + * wp datamachine agents config sarai --unset=model + * + * # Set site_scope (special: updates the agent column directly) + * wp datamachine agents config sarai --set='site_scope=7' + * wp datamachine agents config sarai --set='site_scope=null' + * + * @subcommand config + */ + public function config( array $args, array $assoc_args ): void { + $identifier = $args[0] ?? ''; + $format = $assoc_args['format'] ?? 'json'; + + if ( empty( $identifier ) ) { + WP_CLI::error( 'Agent slug or ID is required.' ); + return; + } + + // Resolve agent. + $agents_repo = new \DataMachine\Core\Database\Agents\Agents(); + $agent = is_numeric( $identifier ) + ? $agents_repo->get_agent( (int) $identifier ) + : $agents_repo->get_by_slug( sanitize_title( $identifier ) ); + + if ( ! $agent ) { + WP_CLI::error( sprintf( 'Agent "%s" not found.', $identifier ) ); + return; + } + + $agent_id = (int) $agent['agent_id']; + $config = $agent['agent_config'] ?? array(); + if ( ! is_array( $config ) ) { + $config = array(); + } + + $has_set = isset( $assoc_args['set'] ); + $has_unset = isset( $assoc_args['unset'] ); + + // Read-only mode. + if ( ! $has_set && ! $has_unset ) { + if ( empty( $config ) ) { + WP_CLI::log( 'Agent config is empty.' ); + return; + } + + if ( 'json' === $format ) { + WP_CLI::log( wp_json_encode( $config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + } else { + $items = array(); + foreach ( $config as $key => $value ) { + $items[] = array( + 'key' => $key, + 'value' => is_array( $value ) ? wp_json_encode( $value, JSON_UNESCAPED_SLASHES ) : (string) $value, + ); + } + \WP_CLI\Utils\format_items( 'table', $items, array( 'key', 'value' ) ); + } + return; + } + + $site_scope_changed = false; + + // Handle --set flags. + if ( $has_set ) { + $sets = is_array( $assoc_args['set'] ) ? $assoc_args['set'] : array( $assoc_args['set'] ); + + foreach ( $sets as $pair ) { + $eq_pos = strpos( $pair, '=' ); + if ( false === $eq_pos ) { + WP_CLI::warning( sprintf( 'Skipping invalid --set value: %s (expected key=value)', $pair ) ); + continue; + } + + $key = substr( $pair, 0, $eq_pos ); + $raw_value = substr( $pair, $eq_pos + 1 ); + + // Handle site_scope as a special case — it's a column, not agent_config. + if ( 'site_scope' === $key ) { + $scope_value = ( 'null' === strtolower( $raw_value ) || '' === $raw_value ) ? null : (int) $raw_value; + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->update( + $wpdb->base_prefix . 'datamachine_agents', + array( 'site_scope' => $scope_value ), + array( 'agent_id' => $agent_id ), + array( null === $scope_value ? null : '%d' ), + array( '%d' ) + ); + $site_scope_changed = true; + WP_CLI::log( sprintf( ' site_scope → %s', null === $scope_value ? 'NULL (network-wide)' : $scope_value ) ); + continue; + } + + // Try JSON decode first (for arrays, objects, numbers, booleans). + $decoded = json_decode( $raw_value, true ); + $value = ( null !== $decoded || 'null' === $raw_value ) ? $decoded : $raw_value; + + $config[ $key ] = $value; + $display = is_array( $value ) ? wp_json_encode( $value, JSON_UNESCAPED_SLASHES ) : (string) $value; + WP_CLI::log( sprintf( ' %s → %s', $key, $display ) ); + } + } + + // Handle --unset flags. + if ( $has_unset ) { + $unsets = is_array( $assoc_args['unset'] ) ? $assoc_args['unset'] : array( $assoc_args['unset'] ); + + foreach ( $unsets as $key ) { + if ( array_key_exists( $key, $config ) ) { + unset( $config[ $key ] ); + WP_CLI::log( sprintf( ' Removed: %s', $key ) ); + } else { + WP_CLI::warning( sprintf( ' Key not found: %s', $key ) ); + } + } + } + + // Save updated config. + $agents_repo->update_agent( $agent_id, array( 'agent_config' => $config ) ); + + WP_CLI::success( sprintf( 'Config updated for agent "%s".', $agent['agent_slug'] ) ); + } + /** * Resolve a user identifier to a WordPress user ID. * diff --git a/inc/Cli/Commands/ExternalCommand.php b/inc/Cli/Commands/ExternalCommand.php new file mode 100644 index 000000000..35e3ff2a2 --- /dev/null +++ b/inc/Cli/Commands/ExternalCommand.php @@ -0,0 +1,356 @@ + + * : Remote site domain (e.g., extrachill.com). + * + * + * : Agent slug on the remote site. + * + * [--token=] + * : Bearer token. If omitted, reads from STDIN. + * + * [--agent-id=] + * : Agent ID on the remote site (optional metadata). + * + * [--verify] + * : Test the token against the remote site before storing. + * + * ## EXAMPLES + * + * # Register with inline token + * wp datamachine external add extrachill.com sarai --token="datamachine_sarai_abc123..." + * + * # Register with token from STDIN (for piping) + * echo "datamachine_sarai_abc123..." | wp datamachine external add extrachill.com sarai + * + * # Register and verify the token works + * wp datamachine external add extrachill.com sarai --token="..." --verify + * + * @subcommand add + */ + public function add( array $args, array $assoc_args ): void { + $site = $args[0] ?? ''; + $agent_slug = $args[1] ?? ''; + + if ( empty( $site ) || empty( $agent_slug ) ) { + WP_CLI::error( 'Usage: wp datamachine external add [--token=...]' ); + return; + } + + // Clean up domain. + $site = str_replace( array( 'https://', 'http://' ), '', rtrim( $site, '/' ) ); + + // Get token from flag or STDIN. + $token = $assoc_args['token'] ?? null; + if ( null === $token ) { + $token = trim( file_get_contents( 'php://stdin' ) ); + } + + if ( empty( $token ) ) { + WP_CLI::error( 'A bearer token is required. Pass via --token= or pipe to STDIN.' ); + return; + } + + $agent_id = isset( $assoc_args['agent-id'] ) ? (int) $assoc_args['agent-id'] : 0; + + // Optionally verify the token works. + if ( isset( $assoc_args['verify'] ) ) { + WP_CLI::log( sprintf( 'Verifying token against https://%s...', $site ) ); + + $response = wp_remote_get( + sprintf( 'https://%s/wp-json/wp/v2/users/me', $site ), + array( + 'headers' => array( + 'Authorization' => 'Bearer ' . $token, + ), + 'timeout' => 15, + ) + ); + + if ( is_wp_error( $response ) ) { + WP_CLI::error( sprintf( 'Verification failed: %s', $response->get_error_message() ) ); + return; + } + + $code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( 200 !== $code ) { + WP_CLI::error( sprintf( 'Verification failed: HTTP %d — %s', $code, $body['message'] ?? 'Unknown error' ) ); + return; + } + + WP_CLI::log( sprintf( ' Authenticated as: %s (ID: %d)', $body['name'] ?? 'unknown', $body['id'] ?? 0 ) ); + } + + // Store the token. + $key = $site . '/' . $agent_slug; + $tokens = get_option( AgentAuthCallback::OPTION_KEY, array() ); + + $tokens[ $key ] = array( + 'remote_site' => $site, + 'agent_slug' => $agent_slug, + 'agent_id' => $agent_id, + 'token' => $token, + 'received_at' => gmdate( 'Y-m-d H:i:s' ), + ); + + update_option( AgentAuthCallback::OPTION_KEY, $tokens, false ); + + WP_CLI::success( sprintf( 'Stored token for %s/%s.', $site, $agent_slug ) ); + } + + /** + * List registered external site connections. + * + * ## OPTIONS + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * --- + * + * ## EXAMPLES + * + * wp datamachine external list + * wp datamachine external list --format=json + * + * @subcommand list + */ + public function list_sites( array $args, array $assoc_args ): void { + $tokens = get_option( AgentAuthCallback::OPTION_KEY, array() ); + + if ( empty( $tokens ) ) { + WP_CLI::log( 'No external sites registered.' ); + return; + } + + $format = $assoc_args['format'] ?? 'table'; + $items = array(); + + foreach ( $tokens as $key => $data ) { + $items[] = array( + 'key' => $key, + 'remote_site' => $data['remote_site'] ?? '', + 'agent_slug' => $data['agent_slug'] ?? '', + 'agent_id' => $data['agent_id'] ?? 0, + 'received_at' => $data['received_at'] ?? '', + 'has_token' => ! empty( $data['token'] ) ? 'Yes' : 'No', + ); + } + + $this->format_items( $items, array( 'key', 'remote_site', 'agent_slug', 'agent_id', 'received_at', 'has_token' ), $assoc_args, 'key' ); + } + + /** + * Remove an external site connection. + * + * ## OPTIONS + * + * + * : Connection key (site/agent_slug, e.g., "extrachill.com/sarai"). + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * wp datamachine external remove extrachill.com/sarai + * wp datamachine external remove extrachill.com/sarai --yes + * + * @subcommand remove + */ + public function remove( array $args, array $assoc_args ): void { + $key = $args[0] ?? ''; + + if ( empty( $key ) ) { + WP_CLI::error( 'Connection key is required (e.g., "extrachill.com/sarai").' ); + return; + } + + $tokens = get_option( AgentAuthCallback::OPTION_KEY, array() ); + + if ( ! isset( $tokens[ $key ] ) ) { + WP_CLI::error( sprintf( 'No connection found for "%s".', $key ) ); + return; + } + + if ( ! isset( $assoc_args['yes'] ) ) { + WP_CLI::confirm( sprintf( 'Remove external connection "%s"?', $key ) ); + } + + unset( $tokens[ $key ] ); + update_option( AgentAuthCallback::OPTION_KEY, $tokens, false ); + + WP_CLI::success( sprintf( 'Removed connection "%s".', $key ) ); + } + + /** + * Show connection details including the stored token. + * + * ## OPTIONS + * + * + * : Connection key (site/agent_slug, e.g., "extrachill.com/sarai"). + * + * [--show-token] + * : Display the bearer token value (hidden by default). + * + * ## EXAMPLES + * + * wp datamachine external show extrachill.com/sarai + * wp datamachine external show extrachill.com/sarai --show-token + * + * @subcommand show + */ + public function show( array $args, array $assoc_args ): void { + $key = $args[0] ?? ''; + + if ( empty( $key ) ) { + WP_CLI::error( 'Connection key is required.' ); + return; + } + + $tokens = get_option( AgentAuthCallback::OPTION_KEY, array() ); + + if ( ! isset( $tokens[ $key ] ) ) { + WP_CLI::error( sprintf( 'No connection found for "%s".', $key ) ); + return; + } + + $data = $tokens[ $key ]; + + WP_CLI::log( sprintf( 'Remote site: %s', $data['remote_site'] ?? '' ) ); + WP_CLI::log( sprintf( 'Agent slug: %s', $data['agent_slug'] ?? '' ) ); + WP_CLI::log( sprintf( 'Agent ID: %s', $data['agent_id'] ?? 'unknown' ) ); + WP_CLI::log( sprintf( 'Received: %s', $data['received_at'] ?? '' ) ); + + if ( isset( $assoc_args['show-token'] ) && ! empty( $data['token'] ) ) { + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Bearer token: %s', $data['token'] ) ); + } else { + $prefix = substr( $data['token'] ?? '', 0, 20 ); + WP_CLI::log( sprintf( 'Token: %s... (use --show-token to reveal)', $prefix ) ); + } + } + + /** + * Test connectivity to an external site using the stored token. + * + * ## OPTIONS + * + * + * : Connection key (site/agent_slug, e.g., "extrachill.com/sarai"). + * + * ## EXAMPLES + * + * wp datamachine external test extrachill.com/sarai + * + * @subcommand test + */ + public function test( array $args, array $assoc_args ): void { + $key = $args[0] ?? ''; + + if ( empty( $key ) ) { + WP_CLI::error( 'Connection key is required.' ); + return; + } + + $tokens = get_option( AgentAuthCallback::OPTION_KEY, array() ); + + if ( ! isset( $tokens[ $key ] ) ) { + WP_CLI::error( sprintf( 'No connection found for "%s".', $key ) ); + return; + } + + $data = $tokens[ $key ]; + $site = $data['remote_site']; + $token = $data['token']; + + WP_CLI::log( sprintf( 'Testing connection to %s...', $site ) ); + + // Test 1: Authentication. + $response = wp_remote_get( + sprintf( 'https://%s/wp-json/wp/v2/users/me', $site ), + array( + 'headers' => array( 'Authorization' => 'Bearer ' . $token ), + 'timeout' => 15, + ) + ); + + if ( is_wp_error( $response ) ) { + WP_CLI::error( sprintf( 'Connection failed: %s', $response->get_error_message() ) ); + return; + } + + $code = wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( 200 !== $code ) { + WP_CLI::error( sprintf( 'Authentication failed: HTTP %d — %s', $code, $body['message'] ?? 'Unknown error' ) ); + return; + } + + WP_CLI::log( sprintf( ' Auth: OK — %s (ID: %d)', $body['name'] ?? 'unknown', $body['id'] ?? 0 ) ); + + // Test 2: Agent memory access. + $memory_response = wp_remote_get( + sprintf( 'https://%s/wp-json/datamachine/v1/files/agent', $site ), + array( + 'headers' => array( 'Authorization' => 'Bearer ' . $token ), + 'timeout' => 15, + ) + ); + + $memory_code = wp_remote_retrieve_response_code( $memory_response ); + if ( 200 === $memory_code ) { + $files = json_decode( wp_remote_retrieve_body( $memory_response ), true ); + $count = is_array( $files ) ? count( $files ) : 0; + WP_CLI::log( sprintf( ' Memory: OK — %d file(s) accessible', $count ) ); + } else { + WP_CLI::log( sprintf( ' Memory: HTTP %d (Data Machine may not be active on this site)', $memory_code ) ); + } + + WP_CLI::success( sprintf( 'Connection to %s is working.', $site ) ); + } +}