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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ This is a Cargo workspace with two crates:
The user-facing CLI surface. Contains all logic for local commands, wraps `clickhouse-cloud-api` for cloud.

- Cloud handlers go through the `CloudClient` wrapper (`src/cloud/client.rs`), not `clickhouse_cloud_api::Client` directly. The wrapper handles credential precedence, error conversion, and response unwrapping.
- Cloud handlers always support `--json` output unless there is good reason not to.
- Cloud handlers always support `--json` output unless there is good reason not to. JSON is emitted automatically when `--json` is passed or a coding agent is detected (`is_ai_agent::detect()` via the `json_output()` helper in `main.rs`).
- `CloudError` carries a `kind: CloudErrorKind` (`Auth` for 401/403 and missing credentials, else `Generic`). It maps to `Error::AuthRequired` / `Error::Cloud` in `main.rs`, driving `gh`-style exit codes via `Error::exit_code()`: `0` success, `1` error, `2` cancelled, `4` auth required.

Use `--help` to learn the current command surface.

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,19 @@ clickhousectl cloud --json service list
clickhousectl cloud --json service get <service-id>
```

`clickhousectl` auto-detects coding-agent contexts (Claude Code, Cursor, Codex, Gemini CLI, Goose, Devin, and any tool that sets the standard `AGENT` env var) and emits JSON to stdout automatically without setting `--json`.

### Exit codes

Follow `gh` conventions:

| Code | Meaning |
| ---- | -------------------------------------------------------- |
| `0` | Success |
| `1` | Error (anything not classified below) |
| `2` | Cancelled (user aborted) |
| `4` | Auth required (no credentials, 401/403, OAuth-only writes) |

## Skills

Install the official ClickHouse Agent Skills from [ClickHouse/agent-skills](https://github.com/ClickHouse/agent-skills).
Expand Down
3 changes: 2 additions & 1 deletion crates/clickhousectl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ CONTEXT FOR AGENTS:
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.
JSON emitted automatically for known agents.
Exit codes follow gh conventions: 0 success, 1 error, 2 cancelled, 4 auth required.
Typical workflow: `cloud auth login` → `cloud auth status` → `cloud org list` → `cloud service list`")]
Cloud(Box<CloudArgs>),

Expand Down
112 changes: 84 additions & 28 deletions crates/clickhousectl/src/cloud/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,33 @@ use std::env;

const DEFAULT_BASE_URL: &str = "https://api.clickhouse.cloud/v1";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CloudErrorKind {
#[default]
Generic,
Auth,
}

#[derive(Debug)]
pub struct CloudError {
pub message: String,
pub kind: CloudErrorKind,
}

impl CloudError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
kind: CloudErrorKind::Generic,
}
}

pub fn auth(message: impl Into<String>) -> Self {
Self {
message: message.into(),
kind: CloudErrorKind::Auth,
}
}
}

impl std::fmt::Display for CloudError {
Expand Down Expand Up @@ -133,12 +157,12 @@ fn resolve_auth_with_sources(
};

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(),
})?;
let secret = api_secret.map(String::from).ok_or_else(|| CloudError {
message: "API secret required when --api-key or --api-secret is set".into(),
})?;
let key = api_key
.map(String::from)
.ok_or_else(|| CloudError::auth("API key required when --api-key or --api-secret is set"))?;
let secret = api_secret
.map(String::from)
.ok_or_else(|| CloudError::auth("API secret required when --api-key or --api-secret is set"))?;
return Ok(ResolvedAuth {
creds: ResolvedCreds::Basic { key, secret },
source: AuthSource::CliFlags,
Expand Down Expand Up @@ -181,9 +205,9 @@ fn resolve_auth_with_sources(
});
}

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 (also picked up from a `.env` file in the current directory), or use --api-key/--api-secret.\n\nLearn how to create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl".into(),
})
Err(CloudError::auth(
"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 (also picked up from a `.env` file in the current directory), or use --api-key/--api-secret.\n\nLearn how to create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl",
))
}

/// Peek which credential source would win precedence right now without
Expand Down Expand Up @@ -306,9 +330,7 @@ impl CloudClient {
let http = reqwest::Client::builder()
.user_agent(crate::user_agent::user_agent())
.build()
.map_err(|e| CloudError {
message: format!("Failed to create HTTP client: {}", e),
})?;
.map_err(|e| CloudError::new(format!("Failed to create HTTP client: {}", e)))?;

let resolved = resolve_auth(api_key, api_secret, url_override)?;
let lib_url = lib_base_url(&resolved.base_url);
Expand Down Expand Up @@ -354,14 +376,14 @@ impl CloudClient {

/// Unwrap an `ApiResponse<T>` into `T`, returning an error if the result is empty.
pub fn unwrap_response<T>(response: clickhouse_cloud_api::models::ApiResponse<T>) -> Result<T> {
response.result.ok_or_else(|| CloudError {
message: "Empty response from API".into(),
})
response
.result
.ok_or_else(|| CloudError::new("Empty response from API"))
}

/// Convert a library error into a `CloudError`, appending OAuth hints when relevant.
pub fn convert_error(&self, err: clickhouse_cloud_api::Error) -> CloudError {
let message = match &err {
match &err {
clickhouse_cloud_api::Error::Api { status, message } => {
let mut msg = message.clone();
if *status == 403 && self.is_bearer_auth() {
Expand All @@ -373,11 +395,14 @@ impl CloudClient {
https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl",
);
}
msg
if matches!(*status, 401 | 403) {
CloudError::auth(msg)
} else {
CloudError::new(msg)
}
}
other => other.to_string(),
};
CloudError { message }
other => CloudError::new(other.to_string()),
}
}

// Organization endpoints (delegated to library client)
Expand Down Expand Up @@ -1042,15 +1067,12 @@ impl CloudClient {
pub async fn get_default_org_id(&self) -> Result<String> {
let orgs = self.list_organizations().await?;
match orgs.len() {
0 => Err(CloudError {
message: "No organization found for this API key".into(),
}),
0 => Err(CloudError::new("No organization found for this API key")),
1 => Ok(orgs[0].id.to_string()),
_ => Err(CloudError {
message: "Multiple organizations found. Specify --org-id to choose one. \
Use `clickhousectl cloud org list` to see your organizations."
.into(),
}),
_ => Err(CloudError::new(
"Multiple organizations found. Specify --org-id to choose one. \
Use `clickhousectl cloud org list` to see your organizations.",
)),
}
}
}
Expand Down Expand Up @@ -1215,6 +1237,40 @@ mod tests {
assert_eq!(err.message, "Forbidden");
}

#[test]
fn convert_error_flags_401_as_auth() {
let err = test_client().convert_error(clickhouse_cloud_api::Error::Api {
status: 401,
message: "Unauthorized".into(),
});
assert_eq!(err.kind, CloudErrorKind::Auth);
}

#[test]
fn convert_error_flags_403_as_auth() {
let err = test_client().convert_error(clickhouse_cloud_api::Error::Api {
status: 403,
message: "Forbidden".into(),
});
assert_eq!(err.kind, CloudErrorKind::Auth);
}

#[test]
fn convert_error_treats_other_status_as_generic() {
let err = test_client().convert_error(clickhouse_cloud_api::Error::Api {
status: 500,
message: "Internal Server Error".into(),
});
assert_eq!(err.kind, CloudErrorKind::Generic);
}

#[test]
fn convert_error_treats_non_api_error_as_generic() {
let err =
test_client().convert_error(clickhouse_cloud_api::Error::AuthMismatch("nope".into()));
assert_eq!(err.kind, CloudErrorKind::Generic);
}

// ── Dotenv resolver tests ──────────────────────────────────────────────
//
// Precedence is exercised by feeding `resolve_auth_with_sources` a
Expand Down
3 changes: 2 additions & 1 deletion crates/clickhousectl/src/cloud/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ pub mod types;
mod types_test;

pub use client::{
AuthSource, CloudClient, dotenv_env_provenance, env_cred_presence, resolve_active_auth_source,
AuthSource, CloudClient, CloudError, CloudErrorKind, dotenv_env_provenance, env_cred_presence,
resolve_active_auth_source,
};
43 changes: 40 additions & 3 deletions crates/clickhousectl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,18 @@ pub enum Error {
#[error("{0}")]
Cloud(String),

#[error("{0}")]
AuthRequired(String),

#[error("Cancelled")]
Cancelled,
Comment thread
sdairs marked this conversation as resolved.

#[error("{0}")]
Skills(String),

#[error("Invalid server name '{0}': must not contain path separators or '..'")]
InvalidServerName(String),

#[error("--json and --foreground cannot be used together")]
JsonForegroundConflict,

#[error("Docker is not available: {0}")]
DockerNotAvailable(String),

Expand All @@ -73,3 +76,37 @@ pub enum Error {
}

pub type Result<T> = std::result::Result<T, Error>;

impl Error {
/// Process exit code following `gh` CLI conventions:
/// `0` success, `1` error, `2` cancelled, `4` auth required.
pub fn exit_code(&self) -> i32 {
match self {
Error::AuthRequired(_) => 4,
Error::Cancelled => 2,
_ => 1,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn auth_required_maps_to_4() {
assert_eq!(Error::AuthRequired("nope".into()).exit_code(), 4);
}

#[test]
fn cancelled_maps_to_2() {
assert_eq!(Error::Cancelled.exit_code(), 2);
}

#[test]
fn generic_errors_map_to_1() {
assert_eq!(Error::Cloud("boom".into()).exit_code(), 1);
assert_eq!(Error::NoVersionsInstalled.exit_code(), 1);
assert_eq!(Error::VersionNotFound("25.12".into()).exit_code(), 1);
}
}
15 changes: 2 additions & 13 deletions crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,8 @@ async fn start_server(
args: Vec<String>,
json: bool,
) -> Result<()> {
if json && foreground {
return Err(Error::JsonForegroundConflict);
}
// `--foreground` streams the server's stdout/stderr and never emits a JSON
// summary, so it simply ignores `json` rather than erroring on `--json`.

// Recover any orphaned servers so name resolution and collision checks
// see processes that lost their metadata files.
Expand Down Expand Up @@ -858,16 +857,6 @@ fn stop_all_servers_global(json: bool) -> Result<()> {
mod tests {
use super::*;

#[tokio::test]
async fn test_server_start_rejects_json_with_foreground() {
let result = start_server(None, None, None, None, true, vec![], true).await;
let err = result.unwrap_err();
assert!(
matches!(err, Error::JsonForegroundConflict),
"expected JsonForegroundConflict, got: {err}"
);
}

#[test]
fn parse_postgres_install_spec_recognizes_at_and_colon() {
assert_eq!(parse_postgres_install_spec("postgres@16"), Some("16"));
Expand Down
Loading