From 267b5f54511a425820b5df6cdccfa94e5a512c38 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:06:08 +0100 Subject: [PATCH 01/18] Enforce OAuth read-only: block write commands, enhance 403 errors, update docs OAuth device flow tokens are now treated as read-only. Write commands (create, update, delete, start, stop, scale, etc.) are blocked early in the CLI when using Bearer auth, with a clear error guiding users to API key authentication. - Add is_bearer_auth() to CloudClient to expose auth mode - Add is_write_command() to CloudCommands with exhaustive matching (no wildcards) so new commands must be classified at compile time - Pre-check gate in run_cloud() blocks writes before API calls - 403 responses with OAuth include a hint about API key auth - auth status now shows [read-only] / [read/write] labels - Updated help text, CONTEXT FOR AGENTS, and README Closes #98 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 14 ++-- src/cli.rs | 7 +- src/cloud/cli.rs | 190 +++++++++++++++++++++++++++++++++++++++++++- src/cloud/client.rs | 78 ++++++++++++------ src/main.rs | 25 +++++- 5 files changed, 277 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5e7bcd7..7c1020d 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,10 +182,12 @@ Or pass credentials directly via flags on any command: clickhousectl cloud --api-key KEY --api-secret SECRET ... ``` +Create API keys in the [ClickHouse Cloud console](https://console.clickhouse.cloud) under your organization's API Keys settings. + ### 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) ``` diff --git a/src/cli.rs b/src/cli.rs index c5cdee9..ebc4c2a 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,9 @@ 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 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..38b60e8 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. + Agents: use API key auth for any mutating operations. OAuth is only suitable for read-only exploration. Related: use `clickhousectl cloud auth status` to verify.")] Login { /// Log in by entering API key/secret interactively @@ -82,8 +84,8 @@ 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). `logout` clears all saved credentials (OAuth tokens and API keys). Related: `clickhousectl cloud org list` to verify credentials work.")] Auth { @@ -108,6 +110,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 +155,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 +1482,109 @@ mod tests { Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")), } } + + #[test] + fn is_write_command_classifies_read_commands() { + let read_commands = vec![ + CloudCommands::Org { + command: OrgCommands::List, + }, + CloudCommands::Org { + command: OrgCommands::Get { + org_id: "o".into(), + }, + }, + CloudCommands::Service { + command: ServiceCommands::List { + org_id: None, + filter: vec![], + }, + }, + CloudCommands::Service { + command: ServiceCommands::Get { + service_id: "s".into(), + org_id: None, + }, + }, + CloudCommands::Backup { + command: BackupCommands::List { + service_id: "s".into(), + org_id: None, + }, + }, + CloudCommands::Activity { + command: ActivityCommands::List { + org_id: None, + from_date: None, + to_date: None, + }, + }, + CloudCommands::Member { + command: MemberCommands::List { org_id: None }, + }, + CloudCommands::Key { + command: KeyCommands::List { org_id: None }, + }, + ]; + for cmd in &read_commands { + assert!( + !cmd.is_write_command(), + "expected read-only: {:?}", + std::mem::discriminant(cmd) + ); + } + } + + #[test] + fn is_write_command_classifies_write_commands() { + let write_commands = vec![ + CloudCommands::Org { + command: OrgCommands::Update { + org_id: "o".into(), + name: None, + remove_private_endpoint: vec![], + enable_core_dumps: None, + }, + }, + CloudCommands::Service { + command: ServiceCommands::Delete { + service_id: "s".into(), + force: false, + org_id: None, + }, + }, + CloudCommands::Service { + command: ServiceCommands::Start { + service_id: "s".into(), + org_id: None, + }, + }, + CloudCommands::Member { + command: MemberCommands::Remove { + user_id: "u".into(), + org_id: None, + }, + }, + CloudCommands::Key { + command: KeyCommands::Delete { + key_id: "k".into(), + org_id: None, + }, + }, + CloudCommands::Invitation { + command: InvitationCommands::Create { + email: "a@b.com".into(), + role_id: vec![], + org_id: None, + }, + }, + ]; + for cmd in &write_commands { + assert!( + cmd.is_write_command(), + "expected write: {:?}", + std::mem::discriminant(cmd) + ); + } + } } diff --git a/src/cloud/client.rs b/src/cloud/client.rs index 4eaa65d..f2609f9 100644 --- a/src/cloud/client.rs +++ b/src/cloud/client.rs @@ -112,12 +112,29 @@ 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", + ); + } + } + /// Send a request and parse the JSON response body. async fn request( &self, @@ -137,16 +154,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 +187,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 +217,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 +879,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..f2b59c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,7 +97,10 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { AuthCommands::Status => { match cloud::auth::load_tokens() { Some(tokens) if cloud::auth::is_token_valid(&tokens) => { - println!("OAuth: logged in (token valid, url: {})", tokens.api_url); + println!( + "OAuth: logged in (token valid, url: {}) [read-only]", + tokens.api_url + ); } Some(tokens) => { println!( @@ -112,7 +115,7 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { let creds = cloud::credentials::load_credentials(); if creds.is_some() { println!( - "API keys: configured ({})", + "API keys: configured ({}) [read/write]", cloud::credentials::credentials_path().display() ); } else { @@ -135,6 +138,24 @@ 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\ + Create API keys in the ClickHouse Cloud console:\n \ + https://console.clickhouse.cloud\n \ + Navigate to your organization > API Keys." + .into(), + )); + } + let json = args.json; let result = match args.command { From cf231333740c1554371ac9f5246619c7a70c7ece Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:32:08 +0100 Subject: [PATCH 02/18] Fix incorrect `cloud auth keys` reference in no-credentials error The command `cloud auth keys` doesn't exist. Replace with the actual `cloud auth login --api-key KEY --api-secret SECRET` syntax, and note the read-only vs read/write distinction. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloud/client.rs b/src/cloud/client.rs index f2609f9..e36e198 100644 --- a/src/cloud/client.rs +++ b/src/cloud/client.rs @@ -102,7 +102,7 @@ impl CloudClient { } 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".into(), }) } From 6acf4d3008cf2ebed5c471dae4e5cfbdb48d88a3 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:36:41 +0100 Subject: [PATCH 03/18] Exhaustive is_write_command tests covering every command variant Replace sample-based tests with exhaustive coverage of all 44 cloud command variants. Tests parse real CLI args through clap, validating both parsing and read/write classification. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 189 ++++++++++++++++++++++------------------------- 1 file changed, 90 insertions(+), 99 deletions(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 38b60e8..def40c6 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -1483,108 +1483,99 @@ mod tests { } } + /// 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_classifies_read_commands() { - let read_commands = vec![ - CloudCommands::Org { - command: OrgCommands::List, - }, - CloudCommands::Org { - command: OrgCommands::Get { - org_id: "o".into(), - }, - }, - CloudCommands::Service { - command: ServiceCommands::List { - org_id: None, - filter: vec![], - }, - }, - CloudCommands::Service { - command: ServiceCommands::Get { - service_id: "s".into(), - org_id: None, - }, - }, - CloudCommands::Backup { - command: BackupCommands::List { - service_id: "s".into(), - org_id: None, - }, - }, - CloudCommands::Activity { - command: ActivityCommands::List { - org_id: None, - from_date: None, - to_date: None, - }, - }, - CloudCommands::Member { - command: MemberCommands::List { org_id: None }, - }, - CloudCommands::Key { - command: KeyCommands::List { org_id: None }, - }, - ]; - for cmd in &read_commands { - assert!( - !cmd.is_write_command(), - "expected read-only: {:?}", - std::mem::discriminant(cmd) - ); - } + 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_classifies_write_commands() { - let write_commands = vec![ - CloudCommands::Org { - command: OrgCommands::Update { - org_id: "o".into(), - name: None, - remove_private_endpoint: vec![], - enable_core_dumps: None, - }, - }, - CloudCommands::Service { - command: ServiceCommands::Delete { - service_id: "s".into(), - force: false, - org_id: None, - }, - }, - CloudCommands::Service { - command: ServiceCommands::Start { - service_id: "s".into(), - org_id: None, - }, - }, - CloudCommands::Member { - command: MemberCommands::Remove { - user_id: "u".into(), - org_id: None, - }, - }, - CloudCommands::Key { - command: KeyCommands::Delete { - key_id: "k".into(), - org_id: None, - }, - }, - CloudCommands::Invitation { - command: InvitationCommands::Create { - email: "a@b.com".into(), - role_id: vec![], - org_id: None, - }, - }, - ]; - for cmd in &write_commands { - assert!( - cmd.is_write_command(), - "expected write: {:?}", - std::mem::discriminant(cmd) - ); - } + 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); } } From 0364298ecded79bd12eb226294f62d4b4ba40789 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:38:55 +0100 Subject: [PATCH 04/18] Link to API key creation docs in all auth error messages Add https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl to the pre-check gate error, 403 OAuth hint, no-credentials error, and README. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- src/cloud/client.rs | 6 ++++-- src/main.rs | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7c1020d..3859ed5 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Or pass credentials directly via flags on any command: clickhousectl cloud --api-key KEY --api-secret SECRET ... ``` -Create API keys in the [ClickHouse Cloud console](https://console.clickhouse.cloud) under your organization's API Keys settings. +Learn how to [create API keys](https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl). ### Auth status and logout diff --git a/src/cloud/client.rs b/src/cloud/client.rs index e36e198..ef08a1a 100644 --- a/src/cloud/client.rs +++ b/src/cloud/client.rs @@ -102,7 +102,7 @@ impl CloudClient { } Err(CloudError { - 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".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(), }) } @@ -130,7 +130,9 @@ impl CloudClient { 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", + 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", ); } } diff --git a/src/main.rs b/src/main.rs index f2b59c2..cd51a52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,9 +149,8 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { Or set environment variables:\n \ export CLICKHOUSE_CLOUD_API_KEY=your-key\n \ export CLICKHOUSE_CLOUD_API_SECRET=your-secret\n\n\ - Create API keys in the ClickHouse Cloud console:\n \ - https://console.clickhouse.cloud\n \ - Navigate to your organization > API Keys." + Learn how to create API keys:\n \ + https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl" .into(), )); } From 01f364acdfb4208fb588bdac1bc424b4f0a8eab3 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:40:57 +0100 Subject: [PATCH 05/18] Hide --url flag unconditionally Previously only hidden in release builds. This is an internal flag for testing against staging/dev environments and should never be visible to users or agents. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index def40c6..1935a58 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -70,9 +70,8 @@ pub struct CloudArgs { #[arg(long, global = true)] pub json: bool, - /// API base URL (default: auto-detect from OAuth tokens, or https://api.clickhouse.cloud) - #[cfg_attr(debug_assertions, arg(long, global = true))] - #[cfg_attr(not(debug_assertions), arg(long, global = true, hide = true))] + /// API base URL override (internal use only) + #[arg(long, global = true, hide = true)] pub url: Option, #[command(subcommand)] From 9c9ca091c9c8a41dadfd3c3cf8e17296ac0f4ec0 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:43:50 +0100 Subject: [PATCH 06/18] Add --oauth and --api-keys flags to logout for selective credential clearing `cloud auth logout` still clears everything by default. New flags: --oauth clear only OAuth tokens, keep API keys --api-keys clear only API keys, keep OAuth tokens The flags are mutually exclusive. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 12 ++++++++++-- src/main.rs | 20 ++++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 1935a58..6752b98 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -50,8 +50,16 @@ CONTEXT FOR AGENTS: #[arg(long)] api_secret: Option, }, - /// Log out and clear all saved credentials - Logout, + /// Log out and clear saved credentials + 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, } diff --git a/src/main.rs b/src/main.rs index cd51a52..ab5cb95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,10 +88,22 @@ 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::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 => { From 50f0aeb799d7c2b76eaaa3f1d5f9c6012875c9d4 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:45:05 +0100 Subject: [PATCH 07/18] Add help text to logout explaining default clears all credentials Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 6752b98..ce9251e 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -51,6 +51,14 @@ CONTEXT FOR AGENTS: api_secret: Option, }, /// Log out and clear saved credentials + #[command( + long_about = "Log out and clear saved credentials.\n\n\ + By default, clears ALL credentials (OAuth tokens and API keys).\n\ + Use --oauth or --api-keys to clear only one type.", + 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")] From c9f276196db328a9397aadd559b30a4c3cdc3e8d Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:45:58 +0100 Subject: [PATCH 08/18] Simplify logout help: use after_help only, drop long_about Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index ce9251e..3b05944 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -51,14 +51,12 @@ CONTEXT FOR AGENTS: api_secret: Option, }, /// Log out and clear saved credentials - #[command( - long_about = "Log out and clear saved credentials.\n\n\ - By default, clears ALL credentials (OAuth tokens and API keys).\n\ - Use --oauth or --api-keys to clear only one type.", - after_help = "\ + #[command(after_help = "\ +By default, clears ALL credentials (OAuth tokens and API keys). +Use --oauth or --api-keys to clear only one type. + CONTEXT FOR AGENTS: - With no flags, clears everything. Use --oauth to keep API keys, or --api-keys to keep OAuth tokens." - )] + 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")] From 25487913eb6fbd18c3dc138a66589cbc86293f38 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:47:01 +0100 Subject: [PATCH 09/18] Remove duplicate agent context from logout help Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 3b05944..1c4fe40 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -53,10 +53,7 @@ CONTEXT FOR AGENTS: /// Log out and clear saved credentials #[command(after_help = "\ By default, clears ALL credentials (OAuth tokens and API keys). -Use --oauth or --api-keys to clear only one type. - -CONTEXT FOR AGENTS: - With no flags, clears everything. Use --oauth to keep API keys, or --api-keys to keep OAuth tokens.")] +Use --oauth or --api-keys to clear only one type.")] Logout { /// Clear only OAuth tokens (keep API keys) #[arg(long, conflicts_with = "api_keys")] From 1880e6d227fea9b3b9d91f1af2d1e27724260d69 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 12:51:17 +0100 Subject: [PATCH 10/18] Use CONTEXT FOR AGENTS only for logout after_help Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 1c4fe40..4cc7c21 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -52,8 +52,8 @@ CONTEXT FOR AGENTS: }, /// Log out and clear saved credentials #[command(after_help = "\ -By default, clears ALL credentials (OAuth tokens and API keys). -Use --oauth or --api-keys to clear only one type.")] +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")] From fdcbbb4b5de4ea5386aa6dae30ae0d7c2b59ebcb Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 13:06:34 +0100 Subject: [PATCH 11/18] Add API key creation docs link to cloud auth and login help Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 4cc7c21..52ce670 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -36,6 +36,7 @@ CONTEXT FOR AGENTS: inspect resources but cannot create, modify, or delete. For write operations (create, update, delete services, etc.), use --api-key and --api-secret. Agents: use API key auth for any mutating operations. OAuth is only suitable for read-only exploration. + 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 @@ -96,6 +97,7 @@ pub enum CloudCommands { CONTEXT FOR AGENTS: 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 { From 082b334be17dc5554eea756eb97f9a016ff8791c Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 13:08:04 +0100 Subject: [PATCH 12/18] Remove redundant agent guidance from login help The OAuth read-only note already covers this. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index 52ce670..b94216b 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -35,7 +35,6 @@ CONTEXT FOR AGENTS: 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. - Agents: use API key auth for any mutating operations. OAuth is only suitable for read-only exploration. Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl Related: use `clickhousectl cloud auth status` to verify.")] Login { From 62168a7d53b6020184a082e867dc20bc3acfd474 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 13:12:06 +0100 Subject: [PATCH 13/18] Change auth precedence: API keys before OAuth New order: CLI flags > file credentials > env vars > OAuth tokens. API keys are project-scoped (read/write) and should take precedence. OAuth is user-scoped (read-only) and serves as a fallback when no API keys are configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- src/cloud/client.rs | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3859ed5..c038e91 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ clickhousectl cloud auth status # Show current auth state (including read-onl 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/cloud/client.rs b/src/cloud/client.rs index ef08a1a..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,6 +87,20 @@ 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, 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(), }) From 39460ec1aceabde0a07661545a73d0e3fd488976 Mon Sep 17 00:00:00 2001 From: Krishna Mannem Date: Wed, 1 Apr 2026 12:40:01 -0400 Subject: [PATCH 14/18] add signup command --- src/cli.rs | 1 + src/cloud/cli.rs | 2 ++ src/main.rs | 16 ++++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index ebc4c2a..bd7992c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,6 +49,7 @@ CONTEXT FOR AGENTS: 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 b94216b..be90e30 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -65,6 +65,8 @@ CONTEXT FOR AGENTS: }, /// Show current authentication status Status, + /// Open the ClickHouse Cloud sign-up page in your browser + Signup, } #[derive(Args)] diff --git a/src/main.rs b/src/main.rs index ab5cb95..323ca81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,6 +88,22 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { Ok(()) } } + 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", 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) => { From 5f56dfe505aeac1ec661151e171514e81199e6bf Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 14:07:12 +0100 Subject: [PATCH 15/18] Add UTM tracking and agent context to signup command Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 4 ++++ src/main.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index be90e30..cd50f2f 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -66,6 +66,10 @@ CONTEXT FOR AGENTS: /// 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, } diff --git a/src/main.rs b/src/main.rs index 323ca81..e14fabe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,7 +97,7 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { .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", base_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); From 0feb0e7b9a450cb40ca1b1b63b5e6adf1566d0e7 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 14:16:31 +0100 Subject: [PATCH 16/18] Use table output for auth status and bump version to 0.1.17 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 2 +- src/main.rs | 67 +++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 18 deletions(-) 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/src/main.rs b/src/main.rs index e14fabe..93903e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,31 +123,64 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { 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: {}) [read-only]", - 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 ({}) [read/write]", - 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 { + 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!("API keys: not configured"); + println!("{}", Table::new(rows).with(Style::rounded())); } Ok(()) } From d3952eea35a20576bffe1c233dd4b8edf9350ed2 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 16:34:55 +0100 Subject: [PATCH 17/18] Update Cargo.lock for 0.1.17 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 585c58603e3705f2bedfe402ee57ed504952b406 Mon Sep 17 00:00:00 2001 From: sdairs Date: Thu, 9 Apr 2026 16:40:49 +0100 Subject: [PATCH 18/18] Revert --url flag to debug-only visibility Restore cfg_attr(debug_assertions) so --url is visible in debug builds but hidden in release builds. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cloud/cli.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cloud/cli.rs b/src/cloud/cli.rs index cd50f2f..4e0c0d7 100644 --- a/src/cloud/cli.rs +++ b/src/cloud/cli.rs @@ -87,8 +87,9 @@ pub struct CloudArgs { #[arg(long, global = true)] pub json: bool, - /// API base URL override (internal use only) - #[arg(long, global = true, hide = true)] + /// API base URL (default: auto-detect from OAuth tokens, or https://api.clickhouse.cloud) + #[cfg_attr(debug_assertions, arg(long, global = true))] + #[cfg_attr(not(debug_assertions), arg(long, global = true, hide = true))] pub url: Option, #[command(subcommand)]