diff --git a/Cargo.lock b/Cargo.lock index dc832a9..8dbaa96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,7 +216,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clickhousectl" -version = "0.1.16" +version = "0.1.17" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7758ade..9456b7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clickhousectl" -version = "0.1.16" +version = "0.1.17" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 5e7bcd7..c038e91 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,9 @@ Each named server has its own data directory, so servers are fully isolated from ## Authentication -Authenticate to ClickHouse Cloud using OAuth (browser-based) or API keys. +Authenticate to ClickHouse Cloud using OAuth (browser-based) or API keys. OAuth provides **read-only** access; API keys provide full **read/write** access. -### OAuth login (recommended) - -> **Note:** Cloud OAuth requires a feature flag for your ClickHouse Cloud organization. Please reach out to support to request OAuth device flow auth for `clickhousectl`. You do not need this to use API keys generated from the SQL Console. +### OAuth login (read-only) ```bash clickhousectl cloud auth login @@ -159,7 +157,9 @@ clickhousectl cloud auth login This opens your browser for authentication via the OAuth device flow. Tokens are saved to `.clickhouse/tokens.json` (project-local). -### API key/secret +> **Note:** OAuth tokens provide **read-only** access. You can list and inspect resources (organizations, services, backups, etc.) but cannot create, modify, or delete them. For write operations, use API key authentication. + +### API key/secret (required for write operations) ```bash # Non-interactive (CI-friendly) @@ -182,14 +182,16 @@ Or pass credentials directly via flags on any command: clickhousectl cloud --api-key KEY --api-secret SECRET ... ``` +Learn how to [create API keys](https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl). + ### Auth status and logout ```bash -clickhousectl cloud auth status # Show current auth state +clickhousectl cloud auth status # Show current auth state (including read-only/read-write labels) clickhousectl cloud auth logout # Clear all saved credentials (credentials.json & tokens.json) ``` -Credential resolution order: CLI flags > OAuth tokens > `.clickhouse/credentials.json` > environment variables. +Credential resolution order: CLI flags > `.clickhouse/credentials.json` > environment variables > OAuth tokens. ## Cloud diff --git a/src/cli.rs b/src/cli.rs index c5cdee9..bd7992c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -19,6 +19,9 @@ CONTEXT FOR AGENTS: 1. Local: Install and interact with versions of ClickHouse to develop locally. 2. Cloud: Manage ClickHouse Cloud infrastructure and push local work to cloud. + Authentication: OAuth (`cloud auth login`) is read-only. For write operations (create, update, + delete), use API key auth: `cloud auth login --api-key X --api-secret Y`. + You can install the ClickHouse Agent Skills with: `clickhousectl skills` @@ -43,7 +46,10 @@ CONTEXT FOR AGENTS: #[command(after_help = "\ CONTEXT FOR AGENTS: Used for managing ClickHouse Cloud infrastructure. You need to have a ClickHouse Cloud account and be authenticated. - Agents should suggest the user to sign up at https://clickhouse.cloud, and then use `clickhousectl cloud auth login` to authenticate. + OAuth login (`cloud auth login`) is read-only — it can list and inspect resources but cannot create, modify, or delete. + For write operations, authenticate with API keys: + clickhousectl cloud auth login --api-key YOUR_KEY --api-secret YOUR_SECRET + If the user doesn't have an account, suggest `clickhousectl cloud auth signup` first. Add --json to any cloud command for machine-readable output. Typical workflow: `cloud auth login` → `cloud auth status` → `cloud org list` → `cloud service list`")] Cloud(Box), diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 341e473..4e0c0d7 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -32,8 +32,10 @@ pub enum AuthCommands { /// Log in to ClickHouse Cloud #[command(after_help = "\ CONTEXT FOR AGENTS: - Defaults to OAuth device flow (opens browser). Use --api-key and --api-secret for non-interactive login. - Agents should suggest the user to use the OAuth device flow, only using flags if explicitly asked, in CI or in fully autonomous environments. + Defaults to OAuth device flow (opens browser). OAuth tokens are READ-ONLY — they can list and + inspect resources but cannot create, modify, or delete. + For write operations (create, update, delete services, etc.), use --api-key and --api-secret. + Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl Related: use `clickhousectl cloud auth status` to verify.")] Login { /// Log in by entering API key/secret interactively @@ -48,10 +50,27 @@ CONTEXT FOR AGENTS: #[arg(long)] api_secret: Option, }, - /// Log out and clear all saved credentials - Logout, + /// Log out and clear saved credentials + #[command(after_help = "\ +CONTEXT FOR AGENTS: + With no flags, clears everything. Use --oauth to keep API keys, or --api-keys to keep OAuth tokens.")] + Logout { + /// Clear only OAuth tokens (keep API keys) + #[arg(long, conflicts_with = "api_keys")] + oauth: bool, + + /// Clear only API keys (keep OAuth tokens) + #[arg(long, conflicts_with = "oauth")] + api_keys: bool, + }, /// Show current authentication status Status, + /// Open the ClickHouse Cloud sign-up page in your browser + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Opens the ClickHouse Cloud sign-up page in the user's browser. This is an interactive flow — + it requires a human to complete sign-up in the browser. Do not use in fully autonomous or CI environments.")] + Signup, } #[derive(Args)] @@ -82,8 +101,9 @@ pub enum CloudCommands { /// Manage authentication (OAuth login, API keys) #[command(after_help = "\ CONTEXT FOR AGENTS: - Use `login --api-key X --api-secret Y` for non-interactive auth. - Default `login` opens a browser (not agent-friendly). + Use `login --api-key X --api-secret Y` for full read/write access. + Default `login` opens a browser for OAuth (read-only access only — cannot create, modify, or delete resources). + Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl `logout` clears all saved credentials (OAuth tokens and API keys). Related: `clickhousectl cloud org list` to verify credentials work.")] Auth { @@ -108,6 +128,7 @@ CONTEXT FOR AGENTS: CONTEXT FOR AGENTS: Most commands need a service ID — get it from `clickhousectl cloud service list`. Org ID is auto-detected if you have only one org; otherwise pass --org-id. + Write commands (create, delete, start, stop, update, scale) require API key auth — OAuth is read-only. Use `client` to open a clickhouse-client session to a service. Related: `clickhousectl cloud org list` for org IDs.")] Service { @@ -152,6 +173,80 @@ CONTEXT FOR AGENTS: }, } +impl CloudCommands { + /// Returns true if this command performs a write/mutating operation. + /// OAuth (Bearer) auth is read-only and cannot execute write commands. + /// + /// Every variant is explicitly matched — no wildcards — so the compiler + /// will error when a new command is added, forcing the developer to + /// classify it as read or write. + pub fn is_write_command(&self) -> bool { + match self { + CloudCommands::Auth { .. } => false, + CloudCommands::Org { command } => match command { + OrgCommands::List => false, + OrgCommands::Get { .. } => false, + OrgCommands::Prometheus { .. } => false, + OrgCommands::Usage { .. } => false, + OrgCommands::Update { .. } => true, + }, + CloudCommands::Service { command } => match command { + ServiceCommands::List { .. } => false, + ServiceCommands::Get { .. } => false, + ServiceCommands::Client { .. } => false, + ServiceCommands::Prometheus { .. } => false, + ServiceCommands::Create { .. } => true, + ServiceCommands::Delete { .. } => true, + ServiceCommands::Start { .. } => true, + ServiceCommands::Stop { .. } => true, + ServiceCommands::Update { .. } => true, + ServiceCommands::Scale { .. } => true, + ServiceCommands::ResetPassword { .. } => true, + ServiceCommands::QueryEndpoint { command } => match command { + QueryEndpointCommands::Get { .. } => false, + QueryEndpointCommands::Create { .. } => true, + QueryEndpointCommands::Delete { .. } => true, + }, + ServiceCommands::PrivateEndpoint { command } => match command { + PrivateEndpointCommands::Create { .. } => true, + PrivateEndpointCommands::GetConfig { .. } => false, + }, + ServiceCommands::BackupConfig { command } => match command { + BackupConfigCommands::Get { .. } => false, + BackupConfigCommands::Update { .. } => true, + }, + }, + CloudCommands::Backup { command } => match command { + BackupCommands::List { .. } => false, + BackupCommands::Get { .. } => false, + }, + CloudCommands::Member { command } => match command { + MemberCommands::List { .. } => false, + MemberCommands::Get { .. } => false, + MemberCommands::Update { .. } => true, + MemberCommands::Remove { .. } => true, + }, + CloudCommands::Invitation { command } => match command { + InvitationCommands::List { .. } => false, + InvitationCommands::Get { .. } => false, + InvitationCommands::Create { .. } => true, + InvitationCommands::Delete { .. } => true, + }, + CloudCommands::Key { command } => match command { + KeyCommands::List { .. } => false, + KeyCommands::Get { .. } => false, + KeyCommands::Create { .. } => true, + KeyCommands::Update { .. } => true, + KeyCommands::Delete { .. } => true, + }, + CloudCommands::Activity { command } => match command { + ActivityCommands::List { .. } => false, + ActivityCommands::Get { .. } => false, + }, + } + } +} + #[derive(Subcommand)] pub enum OrgCommands { /// List organizations @@ -1405,4 +1500,100 @@ mod tests { Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")), } } + + /// Helper to assert a command parsed from CLI args is classified correctly. + fn assert_write(args: &[&str], expected: bool) { + let cli = Cli::try_parse_from(args).unwrap(); + let Commands::Cloud(cloud_args) = cli.command else { + panic!("expected cloud command"); + }; + assert_eq!( + cloud_args.command.is_write_command(), + expected, + "wrong classification for: {}", + args.join(" ") + ); + } + + #[test] + fn is_write_command_read_only_commands() { + // Org reads + assert_write(&["clickhousectl", "cloud", "org", "list"], false); + assert_write(&["clickhousectl", "cloud", "org", "get", "org-1"], false); + assert_write(&["clickhousectl", "cloud", "org", "prometheus", "org-1"], false); + assert_write(&["clickhousectl", "cloud", "org", "usage", "org-1", "--from-date", "2025-01-01", "--to-date", "2025-01-31"], false); + + // Service reads + assert_write(&["clickhousectl", "cloud", "service", "list"], false); + assert_write(&["clickhousectl", "cloud", "service", "get", "svc-1"], false); + assert_write(&["clickhousectl", "cloud", "service", "client", "--id", "svc-1"], false); + assert_write(&["clickhousectl", "cloud", "service", "prometheus", "svc-1"], false); + + // Backup reads + assert_write(&["clickhousectl", "cloud", "backup", "list", "svc-1"], false); + assert_write(&["clickhousectl", "cloud", "backup", "get", "svc-1", "bk-1"], false); + + // Backup config read + assert_write(&["clickhousectl", "cloud", "service", "backup-config", "get", "svc-1"], false); + + // Member reads + assert_write(&["clickhousectl", "cloud", "member", "list"], false); + assert_write(&["clickhousectl", "cloud", "member", "get", "usr-1"], false); + + // Invitation reads + assert_write(&["clickhousectl", "cloud", "invitation", "list"], false); + assert_write(&["clickhousectl", "cloud", "invitation", "get", "inv-1"], false); + + // Key reads + assert_write(&["clickhousectl", "cloud", "key", "list"], false); + assert_write(&["clickhousectl", "cloud", "key", "get", "key-1"], false); + + // Activity reads + assert_write(&["clickhousectl", "cloud", "activity", "list"], false); + assert_write(&["clickhousectl", "cloud", "activity", "get", "act-1"], false); + + // Query endpoint read + assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "get", "svc-1"], false); + + // Private endpoint read + assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "get-config", "svc-1"], false); + } + + #[test] + fn is_write_command_destructive_commands() { + // Org write + assert_write(&["clickhousectl", "cloud", "org", "update", "org-1", "--name", "new"], true); + + // Service writes + assert_write(&["clickhousectl", "cloud", "service", "create", "--name", "s", "--provider", "aws", "--region", "us-east-1"], true); + assert_write(&["clickhousectl", "cloud", "service", "delete", "svc-1"], true); + assert_write(&["clickhousectl", "cloud", "service", "start", "svc-1"], true); + assert_write(&["clickhousectl", "cloud", "service", "stop", "svc-1"], true); + assert_write(&["clickhousectl", "cloud", "service", "update", "svc-1", "--name", "new"], true); + assert_write(&["clickhousectl", "cloud", "service", "scale", "svc-1", "--num-replicas", "2"], true); + assert_write(&["clickhousectl", "cloud", "service", "reset-password", "svc-1"], true); + + // Backup config write + assert_write(&["clickhousectl", "cloud", "service", "backup-config", "update", "svc-1", "--backup-period-hours", "12"], true); + + // Member writes + assert_write(&["clickhousectl", "cloud", "member", "update", "usr-1", "--role-id", "r1"], true); + assert_write(&["clickhousectl", "cloud", "member", "remove", "usr-1"], true); + + // Invitation writes + assert_write(&["clickhousectl", "cloud", "invitation", "create", "--email", "a@b.com", "--role-id", "r1"], true); + assert_write(&["clickhousectl", "cloud", "invitation", "delete", "inv-1"], true); + + // Key writes + assert_write(&["clickhousectl", "cloud", "key", "create", "--name", "k"], true); + assert_write(&["clickhousectl", "cloud", "key", "update", "key-1", "--name", "new"], true); + assert_write(&["clickhousectl", "cloud", "key", "delete", "key-1"], true); + + // Query endpoint writes + assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "create", "svc-1"], true); + assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "delete", "svc-1"], true); + + // Private endpoint write + assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "create", "svc-1", "--endpoint-id", "ep-1"], true); + } } diff --git a/src/cloud/client.rs b/src/cloud/client.rs index 4eaa65d..6625543 100644 --- a/src/cloud/client.rs +++ b/src/cloud/client.rs @@ -44,8 +44,8 @@ impl CloudClient { message: format!("Failed to create HTTP client: {}", e), })?; - // Priority: CLI flags > OAuth tokens > file credentials > env vars - // If explicit API key flags are provided, use Basic auth + // Priority: CLI flags > file credentials > env vars > OAuth tokens + // API keys are project-scoped (read/write); OAuth is user-scoped (read-only). if api_key.is_some() || api_secret.is_some() { let key = api_key.map(String::from).ok_or_else(|| CloudError { message: "API key required when --api-key or --api-secret is set".into(), @@ -63,20 +63,6 @@ impl CloudClient { }); } - // Try OAuth tokens - if let Some(tokens) = crate::cloud::auth::load_tokens() - && crate::cloud::auth::is_token_valid(&tokens) - { - let base_url = url_override - .map(crate::cloud::auth::normalize_api_url) - .unwrap_or(tokens.api_url.clone()); - return Ok(Self { - client, - auth_mode: AuthMode::Bearer(format!("Bearer {}", tokens.access_token)), - base_url, - }); - } - let base_url = url_override .map(crate::cloud::auth::normalize_api_url) .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); @@ -101,8 +87,22 @@ impl CloudClient { }); } + // Fall back to OAuth tokens (read-only) + if let Some(tokens) = crate::cloud::auth::load_tokens() + && crate::cloud::auth::is_token_valid(&tokens) + { + let base_url = url_override + .map(crate::cloud::auth::normalize_api_url) + .unwrap_or(tokens.api_url.clone()); + return Ok(Self { + client, + auth_mode: AuthMode::Bearer(format!("Bearer {}", tokens.access_token)), + base_url, + }); + } + Err(CloudError { - message: "No credentials found. Run `clickhousectl cloud auth login` (OAuth), `clickhousectl cloud auth keys` (API key/secret), set CLICKHOUSE_CLOUD_API_KEY + CLICKHOUSE_CLOUD_API_SECRET, or use --api-key/--api-secret".into(), + message: "No credentials found. Run `clickhousectl cloud auth login` (OAuth, read-only), `clickhousectl cloud auth login --api-key KEY --api-secret SECRET` (read/write), set CLICKHOUSE_CLOUD_API_KEY + CLICKHOUSE_CLOUD_API_SECRET, or use --api-key/--api-secret.\n\nLearn how to create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl".into(), }) } @@ -112,12 +112,31 @@ impl CloudClient { AuthMode::Basic(format!("Basic {}", encoded)) } + /// Returns true if the client is using OAuth Bearer token authentication. + /// Bearer auth is read-only and cannot perform write operations. + pub fn is_bearer_auth(&self) -> bool { + matches!(self.auth_mode, AuthMode::Bearer(_)) + } + fn auth_header_value(&self) -> &str { match &self.auth_mode { AuthMode::Basic(v) | AuthMode::Bearer(v) => v, } } + /// If using OAuth and the response is 403, append a hint about API key auth. + fn maybe_append_oauth_hint(&self, message: &mut String, status: reqwest::StatusCode) { + if status == reqwest::StatusCode::FORBIDDEN && self.is_bearer_auth() { + message.push_str( + "\n\nHint: You are authenticated via OAuth, which provides read-only access. \ + Use API key authentication for write operations:\n \ + clickhousectl cloud auth login --api-key YOUR_KEY --api-secret YOUR_SECRET\n\n\ + Learn how to create API keys:\n \ + https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl", + ); + } + } + /// Send a request and parse the JSON response body. async fn request( &self, @@ -137,16 +156,15 @@ impl CloudClient { })?; if !status.is_success() { - if let Ok(api_resp) = serde_json::from_str::>(&body) + let mut message = if let Ok(api_resp) = serde_json::from_str::>(&body) && let Some(err) = api_resp.error { - return Err(CloudError { - message: err.message, - }); - } - return Err(CloudError { - message: format!("API error ({}): {}", status, body), - }); + err.message + } else { + format!("API error ({}): {}", status, body) + }; + self.maybe_append_oauth_hint(&mut message, status); + return Err(CloudError { message }); } let api_response: ApiResponse = serde_json::from_str(&body).map_err(|e| CloudError { @@ -171,16 +189,15 @@ impl CloudClient { let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - if let Ok(api_resp) = serde_json::from_str::>(&body) + let mut message = if let Ok(api_resp) = serde_json::from_str::>(&body) && let Some(err) = api_resp.error { - return Err(CloudError { - message: err.message, - }); - } - return Err(CloudError { - message: format!("API error ({}): {}", status, body), - }); + err.message + } else { + format!("API error ({}): {}", status, body) + }; + self.maybe_append_oauth_hint(&mut message, status); + return Err(CloudError { message }); } Ok(()) @@ -202,16 +219,15 @@ impl CloudClient { })?; if !status.is_success() { - if let Ok(api_resp) = serde_json::from_str::>(&body) + let mut message = if let Ok(api_resp) = serde_json::from_str::>(&body) && let Some(err) = api_resp.error { - return Err(CloudError { - message: err.message, - }); - } - return Err(CloudError { - message: format!("API error ({}): {}", status, body), - }); + err.message + } else { + format!("API error ({}): {}", status, body) + }; + self.maybe_append_oauth_hint(&mut message, status); + return Err(CloudError { message }); } Ok(body) @@ -865,4 +881,20 @@ mod tests { "https://api.clickhouse.cloud/v1/organizations/org-1/services/svc-1/prometheus" ); } + + #[test] + fn is_bearer_auth_returns_true_for_bearer() { + let client = CloudClient { + client: Client::builder().build().unwrap(), + auth_mode: AuthMode::Bearer("Bearer test".to_string()), + base_url: DEFAULT_BASE_URL.to_string(), + }; + assert!(client.is_bearer_auth()); + } + + #[test] + fn is_bearer_auth_returns_false_for_basic() { + let client = test_client(); + assert!(!client.is_bearer_auth()); + } } diff --git a/src/main.rs b/src/main.rs index f131f86..93903e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,35 +88,99 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { Ok(()) } } - AuthCommands::Logout => { - cloud::auth::clear_tokens(); - cloud::credentials::clear_credentials(); - println!("Logged out. All saved credentials cleared."); + AuthCommands::Signup => { + let api_url = args + .url + .as_deref() + .unwrap_or("https://api.clickhouse.cloud"); + let parsed = url::Url::parse(api_url) + .map_err(|e| Error::Cloud(format!("Invalid URL: {}", e)))?; + let host = parsed.host_str().unwrap_or("api.clickhouse.cloud"); + let base_host = host.strip_prefix("api.").unwrap_or(host); + let url = format!("https://console.{}/signUp?utm_source=clickhousectl", base_host); + println!("Opening ClickHouse Cloud sign-up page..."); + if open::that(&url).is_err() { + println!("Could not open browser. Please visit: {}", url); + } + Ok(()) + } + AuthCommands::Logout { oauth, api_keys } => { + match (oauth, api_keys) { + (true, false) => { + cloud::auth::clear_tokens(); + println!("OAuth tokens cleared. API keys unchanged."); + } + (false, true) => { + cloud::credentials::clear_credentials(); + println!("API keys cleared. OAuth tokens unchanged."); + } + _ => { + cloud::auth::clear_tokens(); + cloud::credentials::clear_credentials(); + println!("Logged out. All saved credentials cleared."); + } + } Ok(()) } AuthCommands::Status => { + use serde::Serialize; + use tabled::{Table, Tabled, settings::Style}; + + #[derive(Serialize, Tabled)] + struct AuthRow { + #[tabled(rename = "Type")] + #[serde(rename = "type")] + auth_type: String, + #[tabled(rename = "Status")] + status: String, + #[tabled(rename = "Scope")] + scope: String, + } + + let mut rows = Vec::new(); + match cloud::auth::load_tokens() { Some(tokens) if cloud::auth::is_token_valid(&tokens) => { - println!("OAuth: logged in (token valid, url: {})", tokens.api_url); + rows.push(AuthRow { + auth_type: "OAuth".into(), + status: "Active".into(), + scope: "read-only".into(), + }); } - Some(tokens) => { - println!( - "OAuth: token expired, url: {} (run `clickhousectl cloud auth login` to refresh)", - tokens.api_url - ); + Some(_) => { + rows.push(AuthRow { + auth_type: "OAuth".into(), + status: "Expired".into(), + scope: "read-only".into(), + }); } None => { - println!("OAuth: not logged in"); + rows.push(AuthRow { + auth_type: "OAuth".into(), + status: "Not configured".into(), + scope: "-".into(), + }); } } - let creds = cloud::credentials::load_credentials(); - if creds.is_some() { - println!( - "API keys: configured ({})", - cloud::credentials::credentials_path().display() - ); + + if cloud::credentials::load_credentials().is_some() { + rows.push(AuthRow { + auth_type: "API key".into(), + status: "Active".into(), + scope: "read/write".into(), + }); } else { - println!("API keys: not configured"); + rows.push(AuthRow { + auth_type: "API key".into(), + status: "Not configured".into(), + scope: "-".into(), + }); + } + + if args.json { + println!("{}", serde_json::to_string_pretty(&rows)?); + } else { + println!("{}", Table::new(rows).with(Style::rounded())); } Ok(()) } @@ -135,6 +199,23 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { ) .map_err(|e| Error::Cloud(e.to_string()))?; + // OAuth (Bearer) tokens are read-only. Block write commands early + // to avoid fail loops where agents repeatedly hit 403 errors. + if client.is_bearer_auth() && args.command.is_write_command() { + return Err(Error::Cloud( + "This command requires API key authentication. \ + OAuth (browser login) provides read-only access.\n\n\ + To authenticate with an API key:\n \ + clickhousectl cloud auth login --api-key YOUR_KEY --api-secret YOUR_SECRET\n\n\ + Or set environment variables:\n \ + export CLICKHOUSE_CLOUD_API_KEY=your-key\n \ + export CLICKHOUSE_CLOUD_API_SECRET=your-secret\n\n\ + Learn how to create API keys:\n \ + https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl" + .into(), + )); + } + let json = args.json; let result = match args.command {