Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## [0.1.14] - 2026-04-27

### 🚀 Features

- *(indexes)* Workspace-wide list with filters and parallel fetch

### 🧪 Testing

- Raise coverage for indexes list and get_none_if_not_found
## [0.1.13] - 2026-04-24

### 🚀 Features
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "hotdata-cli"
version = "0.1.13"
version = "0.1.14"
edition = "2024"
repository = "https://github.com/hotdata-dev/hotdata-cli"
description = "CLI tool for Hotdata.dev"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<br>
Command line interface for <a href="https://www.hotdata.dev">Hotdata</a>.
<br><br>
<img src="https://img.shields.io/badge/version-0.1.13-blue" alt="version">
<img src="https://img.shields.io/badge/version-0.1.14-blue" alt="version">
<a href="https://github.com/hotdata-dev/hotdata-cli/actions/workflows/ci.yml"><img src="https://github.com/hotdata-dev/hotdata-cli/actions/workflows/ci.yml/badge.svg" alt="build"></a>
<a href="https://codecov.io/gh/hotdata-dev/hotdata-cli"><img src="https://codecov.io/gh/hotdata-dev/hotdata-cli/branch/main/graph/badge.svg" alt="coverage"></a>
</p>
Expand Down
2 changes: 1 addition & 1 deletion skills/hotdata/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: hotdata
description: Use this skill when the user wants to run hotdata CLI commands, query the Hotdata API, list workspaces, list connections, create connections, list tables, manage datasets, execute SQL queries, inspect query run history, search tables, manage indexes, manage sandboxes, manage workspace context and stored docs such as context:DATAMODEL via the context API (`hotdata context`), or interact with the hotdata service. Activate when the user says "run hotdata", "query hotdata", "list workspaces", "list connections", "create a connection", "list tables", "list datasets", "create a dataset", "upload a dataset", "execute a query", "search a table", "list indexes", "create an index", "list query runs", "list past queries", "query history", "list sandboxes", "create a sandbox", "run a sandbox", "workspace context", "pull context", "push context", "data model", "context:DATAMODEL", or asks you to use the hotdata CLI.
version: 0.1.13
version: 0.1.14
---

# Hotdata CLI Skill
Expand Down
70 changes: 46 additions & 24 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ impl ApiClient {
let api_key = match &profile_config.api_key {
Some(key) if key != "PLACEHOLDER" => key.clone(),
_ => {
eprintln!("error: not authenticated. Run 'hotdata auth login' (or 'hotdata auth') to log in.");
eprintln!(
"error: not authenticated. Run 'hotdata auth login' (or 'hotdata auth') to log in."
);
std::process::exit(1);
}
};
Expand Down Expand Up @@ -62,7 +64,7 @@ impl ApiClient {

fn debug_headers(&self) -> Vec<(&str, String)> {
let masked = if self.api_key.len() > 4 {
format!("Bearer ...{}", &self.api_key[self.api_key.len()-4..])
format!("Bearer ...{}", &self.api_key[self.api_key.len() - 4..])
} else {
"Bearer ***".to_string()
};
Expand All @@ -80,7 +82,8 @@ impl ApiClient {

fn log_request(&self, method: &str, url: &str, body: Option<&serde_json::Value>) {
let headers = self.debug_headers();
let header_refs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
let header_refs: Vec<(&str, &str)> =
headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
util::debug_request(method, url, &header_refs, body);
}

Expand All @@ -89,16 +92,27 @@ impl ApiClient {
/// instead of whatever cryptic body the primary endpoint returned.
fn fail_response(&self, status: reqwest::StatusCode, body: String) -> ! {
let auth_status = if status.is_client_error() {
config::load("default").ok().map(|pc| auth::check_status(&pc))
config::load("default")
.ok()
.map(|pc| auth::check_status(&pc))
} else {
None
};
eprintln!("{}", format_fail_message(status, &body, auth_status.as_ref()).red());
eprintln!(
"{}",
format_fail_message(status, &body, auth_status.as_ref()).red()
);
std::process::exit(1);
}

fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::blocking::RequestBuilder {
let mut req = self.client.request(method, url)
fn build_request(
&self,
method: reqwest::Method,
url: &str,
) -> reqwest::blocking::RequestBuilder {
let mut req = self
.client
.request(method, url)
.header("Authorization", format!("Bearer {}", self.api_key));
if let Some(ref ws) = self.workspace_id {
req = req.header("X-Workspace-Id", ws);
Expand All @@ -113,14 +127,23 @@ impl ApiClient {

/// GET request with query parameters, returns parsed response.
/// Parameters with `None` values are omitted.
pub fn get_with_params<T: DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> T {
let filtered: Vec<(&str, &String)> = params.iter()
pub fn get_with_params<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, Option<String>)],
) -> T {
let filtered: Vec<(&str, &String)> = params
.iter()
.filter_map(|(k, v)| v.as_ref().map(|val| (*k, val)))
.collect();
let url = format!("{}{path}", self.api_url);
self.log_request("GET", &url, None);

let resp = match self.build_request(reqwest::Method::GET, &url).query(&filtered).send() {
let resp = match self
.build_request(reqwest::Method::GET, &url)
.query(&filtered)
.send()
{
Ok(r) => r,
Err(e) => {
eprintln!("error connecting to API: {e}");
Expand Down Expand Up @@ -205,7 +228,8 @@ impl ApiClient {
let url = format!("{}{path}", self.api_url);
self.log_request("POST", &url, Some(body));

let resp = match self.build_request(reqwest::Method::POST, &url)
let resp = match self
.build_request(reqwest::Method::POST, &url)
.json(body)
.send()
{
Expand Down Expand Up @@ -253,7 +277,8 @@ impl ApiClient {
let url = format!("{}{path}", self.api_url);
self.log_request("POST", &url, Some(body));

let resp = match self.build_request(reqwest::Method::POST, &url)
let resp = match self
.build_request(reqwest::Method::POST, &url)
.json(body)
.send()
{
Expand All @@ -272,7 +297,8 @@ impl ApiClient {
let url = format!("{}{path}", self.api_url);
self.log_request("PATCH", &url, Some(body));

let resp = match self.build_request(reqwest::Method::PATCH, &url)
let resp = match self
.build_request(reqwest::Method::PATCH, &url)
.json(body)
.send()
{
Expand Down Expand Up @@ -308,7 +334,8 @@ impl ApiClient {
let url = format!("{}{path}", self.api_url);
self.log_request("POST", &url, None);

let mut req = self.build_request(reqwest::Method::POST, &url)
let mut req = self
.build_request(reqwest::Method::POST, &url)
.header("Content-Type", content_type);

if let Some(len) = content_length {
Expand All @@ -325,7 +352,6 @@ impl ApiClient {

util::debug_response(resp)
}

}

/// Decide what error text to print for a failed response. Pulled out as a pure
Expand All @@ -336,10 +362,10 @@ fn format_fail_message(
body: &str,
auth_status: Option<&auth::AuthStatus>,
) -> String {
if status.is_client_error() {
if let Some(auth::AuthStatus::Invalid(_)) = auth_status {
return "error: API key is invalid. Run 'hotdata auth login' (or 'hotdata auth') to re-authenticate.".to_string();
}
if status.is_client_error()
&& let Some(auth::AuthStatus::Invalid(_)) = auth_status
{
return "error: API key is invalid. Run 'hotdata auth login' (or 'hotdata auth') to re-authenticate.".to_string();
}
util::api_error(body.to_string())
}
Expand Down Expand Up @@ -465,11 +491,7 @@ mod tests {
fn format_fail_message_4xx_no_probe_result_falls_through() {
// Caller couldn't load config (None) — still surface the upstream error.
let body = "plain body";
let msg = format_fail_message(
reqwest::StatusCode::NOT_FOUND,
body,
None,
);
let msg = format_fail_message(reqwest::StatusCode::NOT_FOUND, body, None);
assert!(!msg.contains("API key is invalid"));
assert_eq!(msg, "plain body");
}
Expand Down
Loading
Loading