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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,36 @@ 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`. Protocol-oriented commands retain their natural output: Prometheus commands emit text, `cloud service query` uses a ClickHouse format such as `JSONEachRow`, and Postgres runtime configuration is JSON already.

Local runtime failures also use structured output when `local --json` is set or a coding agent is detected. The CLI writes exactly one error object to stderr and preserves the documented exit code:

```json
{
"error": {
"code": "server_not_found",
"message": "Server 'default' not found",
"command": "clickhousectl local server list"
}
}
```

`error.code` and `error.message` are always present. `error.command` is an optional safe recovery command. Messages are built from allowlisted fields and never serialize raw I/O errors, paths, credentials, SQL, container logs, Docker diagnostics, or arbitrary fallback details. Human local errors retain the concise `Error: ...` format. Clap usage errors, Cloud errors, and child-process output are not wrapped in this local schema.

The schema and meanings of existing codes are stable. New optional fields or codes may be added compatibly; unclassified local failures use the bounded `local_error` fallback.

| Code | Meaning |
| ---- | ------- |
| `server_not_found` | The selected local server does not exist |
| `server_not_running` | The selected local server exists but is stopped |
| `server_running` | The operation requires a stopped server |
| `invalid_version` | The version selector is invalid |
| `version_unavailable` | The requested or configured version is unavailable |
| `port_in_use` | A requested port is occupied or no managed port is available |
| `startup_exit` | A managed server exited before it became ready |
| `startup_timeout` | A managed server did not become ready before its deadline |
| `download_failed` | An artifact or image download failed |
| `io_error` | A local filesystem, metadata, or serialization operation failed |
| `local_error` | A redacted fallback for other local runtime failures |

### Exit codes

Usage errors and cancelled actions use distinct exit codes.
Expand Down
62 changes: 62 additions & 0 deletions crates/clickhousectl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,47 @@ impl fmt::Display for NetworkFailure {

impl std::error::Error for NetworkFailure {}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortKind {
Http,
Tcp,
Postgres,
}

impl PortKind {
fn human_guidance(self) -> &'static str {
match self {
Self::Postgres => "; choose another --port or omit --port to auto-select a free port",
Self::Http | Self::Tcp => "",
}
}
}

impl fmt::Display for PortKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Http => "HTTP",
Self::Tcp => "TCP",
Self::Postgres => "Postgres",
})
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartupKind {
ClickHouse,
Postgres,
}

impl fmt::Display for StartupKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::ClickHouse => "ClickHouse",
Self::Postgres => "Postgres",
})
}
}

#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum Error {
Expand Down Expand Up @@ -219,6 +260,27 @@ pub enum Error {
#[error("Failed to execute ClickHouse: {0}")]
Exec(String),

#[error("{kind} port {port} is already in use{}", kind.human_guidance())]
PortInUse { kind: PortKind, port: u16 },

#[error("Could not find a free {0} port")]
PortUnavailable(PortKind),

#[error("{details}")]
StartupExit {
kind: StartupKind,
name: String,
details: String,
},

#[error("{details}")]
StartupTimeout {
kind: StartupKind,
name: String,
seconds: u64,
details: String,
},

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

Expand Down
12 changes: 9 additions & 3 deletions crates/clickhousectl/src/local/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,17 @@ fn docker_guidance(platform: HostPlatform) -> &'static str {
enum PullProgressMode {
Interactive,
Collapsed,
Silent,
}

fn pull_progress_mode(
stdout_is_terminal: bool,
stderr_is_terminal: bool,
structured_output: bool,
) -> PullProgressMode {
if stdout_is_terminal && stderr_is_terminal && !structured_output {
if structured_output {
PullProgressMode::Silent
} else if stdout_is_terminal && stderr_is_terminal {
PullProgressMode::Interactive
} else {
PullProgressMode::Collapsed
Expand All @@ -239,6 +242,7 @@ impl PullReporter {
let _ = write!(output, "Pulling {}...", self.image);
let _ = output.flush();
}
PullProgressMode::Silent => {}
}
}

Expand Down Expand Up @@ -275,6 +279,7 @@ impl PullReporter {
PullProgressMode::Collapsed => {
let _ = writeln!(output, " done");
}
PullProgressMode::Silent => {}
}
}

Expand All @@ -286,6 +291,7 @@ impl PullReporter {
PullProgressMode::Collapsed => {
let _ = writeln!(output, " failed");
}
PullProgressMode::Silent => {}
}
}
}
Expand Down Expand Up @@ -313,7 +319,7 @@ pub async fn pull_image(docker: &Docker, tag: &str, structured_output: bool) ->
Ok(info) => info,
Err(error) => {
reporter.fail(&mut stderr.lock());
return Err(Error::DockerError(error.to_string()));
return Err(Error::Download(error.to_string()));
}
};
reporter.event(&info, &mut stderr.lock());
Expand Down Expand Up @@ -1282,7 +1288,7 @@ mod tests {
);
assert_eq!(
pull_progress_mode(true, true, true),
PullProgressMode::Collapsed
PullProgressMode::Silent
);
}

Expand Down
23 changes: 15 additions & 8 deletions crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ async fn install(version: InstallVersionArg, force: bool, json: bool) -> Result<
};
let platform = version_manager::platform::Platform::detect()?;

let version = version_manager::install::install_local_first(&spec, &platform, force).await?;
let version =
version_manager::install::install_local_first(&spec, &platform, force, json).await?;

// If this is the first installed version, set it as default
let set_as_default = version_manager::get_default_version().is_err();
Expand Down Expand Up @@ -133,7 +134,9 @@ fn list_installed(json: bool) -> Result<()> {
}

async fn list_available(json: bool) -> Result<()> {
eprintln!("Checking available versions on builds.clickhouse.com...");
if !json {
eprintln!("Checking available versions on builds.clickhouse.com...");
}
let versions = version_manager::list_available_versions_from_builds().await?;

let installed = version_manager::list_installed_versions().unwrap_or_default();
Expand Down Expand Up @@ -165,7 +168,8 @@ async fn use_version(
) -> Result<()> {
let platform = version_manager::platform::Platform::detect()?;

let version = version_manager::install::ensure_installed_local_first(&spec, &platform).await?;
let version =
version_manager::install::ensure_installed_local_first(&spec, &platform, json).await?;

version_manager::set_default_version(&version)?;

Expand Down Expand Up @@ -400,7 +404,7 @@ async fn start_server(
let version = if let Some(spec) = version_spec {
let spec = spec.into_spec();
let platform = version_manager::platform::Platform::detect()?;
version_manager::install::ensure_installed_local_first(&spec, &platform).await?
version_manager::install::ensure_installed_local_first(&spec, &platform, json).await?
} else {
match version_manager::get_default_version() {
Ok(v) => v,
Expand All @@ -416,8 +420,11 @@ async fn start_server(
// Says "using", not "installing": on repeat starts the build is
// usually already installed and nothing is downloaded. The install
// path prints its own Resolving/Downloading/up-to-date messages.
eprintln!("No version specified and no default set; using latest");
version_manager::install::ensure_installed_local_first(&spec, &platform).await?
if !json {
eprintln!("No version specified and no default set; using latest");
}
version_manager::install::ensure_installed_local_first(&spec, &platform, json)
.await?
}
// A default pointing at a removed binary stays an error.
Err(e) => return Err(e),
Expand All @@ -441,7 +448,7 @@ async fn start_server(

// Show running server count
let running = server::advisory_running_server_count_locked(&metadata_lock);
if running > 0 {
if !json && running > 0 {
eprintln!(
"Note: {} server{} already running (use `clickhousectl local server list` to see them)",
running,
Expand All @@ -450,7 +457,7 @@ async fn start_server(
}

let (http_port, tcp_port, auto_assigned) = server::resolve_ports(http_port, tcp_port)?;
if auto_assigned {
if !json && auto_assigned {
eprintln!(
"Note: default ports in use, auto-assigned HTTP:{} TCP:{}",
http_port, tcp_port
Expand Down
Loading