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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
## [0.1.14] - 2026-04-28

### 🚀 Features

- *(auth)* Add CLI auth session support (JWT access tokens, refresh, PKCE login)
- *(indexes)* Workspace-wide list with filters and parallel fetch

### 💼 Other

- *(codecov)* Treat patch coverage as informational

### 🧪 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
9 changes: 9 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# https://docs.codecov.com/docs/codecovyaml
coverage:
status:
patch:
default:
# Patch % only measures lines touched in the PR. Release/version
# bumps, rustfmt, and refactors often edit large surface areas
# without new tests; do not fail CI on patch coverage alone.
informational: true
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
8 changes: 4 additions & 4 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,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
35 changes: 21 additions & 14 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,8 @@ fn generate_code_challenge(verifier: &str) -> String {
}

fn parse_query_params(url: &str) -> HashMap<String, String> {
url.splitn(2, '?')
.nth(1)
url.split_once('?')
.map(|(_, q)| q)
.unwrap_or("")
.split('&')
.filter_map(|pair| {
Expand All @@ -372,6 +372,25 @@ fn parse_query_params(url: &str) -> HashMap<String, String> {
.collect()
}

fn print_row(label: &str, value: &str) {
stdout()
.execute(SetForegroundColor(Color::DarkGrey))
.unwrap()
.execute(Print(format!(
"{:<16}",
if label.is_empty() {
String::new()
} else {
format!("{label}:")
}
)))
.unwrap()
.execute(ResetColor)
.unwrap()
.execute(Print(format!("{value}\n")))
.unwrap();
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -699,15 +718,3 @@ mod tests {
assert!(result.unwrap_err().contains("no authorization code"));
}
}

fn print_row(label: &str, value: &str) {
stdout()
.execute(SetForegroundColor(Color::DarkGrey))
.unwrap()
.execute(Print(format!("{:<16}", if label.is_empty() { String::new() } else { format!("{label}:") })))
.unwrap()
.execute(ResetColor)
.unwrap()
.execute(Print(format!("{value}\n")))
.unwrap();
}
1 change: 0 additions & 1 deletion src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,6 @@ pub enum DatasetsCommands {
},
}


#[derive(Subcommand)]
pub enum WorkspaceCommands {
/// List all workspaces
Expand Down
16 changes: 2 additions & 14 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,9 @@ pub struct WorkspaceEntry {
pub name: String,
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Default, Serialize)]
pub struct AppUrl(pub(crate) Option<String>);

impl Default for AppUrl {
fn default() -> Self {
AppUrl(None)
}
}

impl Deref for AppUrl {
type Target = str;

Expand Down Expand Up @@ -67,15 +61,9 @@ pub enum ApiKeySource {
Flag,
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Default, Serialize)]
pub struct ApiUrl(pub(crate) Option<String>);

impl Default for ApiUrl {
fn default() -> Self {
ApiUrl(None)
}
}

impl Deref for ApiUrl {
type Target = str;

Expand Down
54 changes: 37 additions & 17 deletions src/connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ impl Serialize for HealthStatus {
fn fetch_health(api: &ApiClient, connection_id: &str, show_spinner: bool) -> HealthStatus {
let spinner = show_spinner.then(|| crate::util::spinner("Checking connection health..."));
let (status, body) = api.get_raw(&format!("/connections/{connection_id}/health"));
if let Some(s) = spinner { s.finish_and_clear(); }
if let Some(s) = spinner {
s.finish_and_clear();
}

if !status.is_success() {
return HealthStatus::Unavailable(crate::util::api_error(body));
Expand Down Expand Up @@ -89,14 +91,19 @@ pub fn types_list(workspace_id: &str, format: &str) {
let body: ListConnectionTypesResponse = api.get("/connection-types");

match format {
"json" => println!("{}", serde_json::to_string_pretty(&body.connection_types).unwrap()),
"json" => println!(
"{}",
serde_json::to_string_pretty(&body.connection_types).unwrap()
),
"yaml" => print!("{}", serde_yaml::to_string(&body.connection_types).unwrap()),
"table" => {
if body.connection_types.is_empty() {
use crossterm::style::Stylize;
eprintln!("{}", "No connection types found.".dark_grey());
} else {
let rows: Vec<Vec<String>> = body.connection_types.iter()
let rows: Vec<Vec<String>> = body
.connection_types
.iter()
.map(|ct| vec![ct.name.clone(), ct.label.clone()])
.collect();
crate::table::print(&["NAME", "LABEL"], &rows);
Expand Down Expand Up @@ -156,7 +163,9 @@ pub fn get(workspace_id: &str, connection_id: &str, format: &str) {

let spinner = is_table.then(|| crate::util::spinner("Fetching connection..."));
let detail: ConnectionDetail = api.get(&format!("/connections/{connection_id}"));
if let Some(s) = spinner { s.finish_and_clear(); }
if let Some(s) = spinner {
s.finish_and_clear();
}

let health = fetch_health(&api, connection_id, is_table);

Expand Down Expand Up @@ -189,7 +198,12 @@ pub fn get(workspace_id: &str, connection_id: &str, format: &str) {
println!("{}{}", label("id:"), detail.id.dark_cyan());
println!("{}{}", label("name:"), detail.name.white());
println!("{}{}", label("source_type:"), detail.source_type.green());
println!("{}{}", label("tables:"), format!("{} synced / {} total", detail.synced_table_count.to_string().cyan(), detail.table_count.to_string().cyan()));
println!(
"{}{} synced / {} total",
label("tables:"),
detail.synced_table_count.to_string().cyan(),
detail.table_count.to_string().cyan(),
);
println!("{}{}", label("health:"), format_health(&health));
}
_ => unreachable!(),
Expand All @@ -206,13 +220,7 @@ struct CreateResponse {
discovery_error: Option<String>,
}

pub fn create(
workspace_id: &str,
name: &str,
source_type: &str,
config: &str,
format: &str,
) {
pub fn create(workspace_id: &str, name: &str, source_type: &str, config: &str, format: &str) {
let config_value: serde_json::Value = match serde_json::from_str(config) {
Ok(v) => v,
Err(e) => {
Expand All @@ -232,7 +240,9 @@ pub fn create(

let spinner = is_table.then(|| crate::util::spinner("Creating connection..."));
let (status, resp_body) = api.post_raw("/connections", &body);
if let Some(s) = &spinner { s.finish_and_clear(); }
if let Some(s) = &spinner {
s.finish_and_clear();
}

if !status.is_success() {
use crossterm::style::Stylize;
Expand Down Expand Up @@ -284,8 +294,13 @@ pub fn create(
println!("tables_discovered: {}", result.tables_discovered);
let status_colored = match result.discovery_status.as_str() {
"success" => result.discovery_status.green().to_string(),
"failed" => result.discovery_error.as_deref().unwrap_or("failed").red().to_string(),
_ => result.discovery_status.yellow().to_string(),
"failed" => result
.discovery_error
.as_deref()
.unwrap_or("failed")
.red()
.to_string(),
_ => result.discovery_status.yellow().to_string(),
};
println!("discovery_status: {status_colored}");
println!("health: {}", format_health(&health));
Expand All @@ -304,7 +319,10 @@ pub fn list(workspace_id: &str, format: &str) {

match format {
"json" => {
println!("{}", serde_json::to_string_pretty(&body.connections).unwrap());
println!(
"{}",
serde_json::to_string_pretty(&body.connections).unwrap()
);
}
"yaml" => {
print!("{}", serde_yaml::to_string(&body.connections).unwrap());
Expand All @@ -314,7 +332,9 @@ pub fn list(workspace_id: &str, format: &str) {
use crossterm::style::Stylize;
eprintln!("{}", "No connections found.".dark_grey());
} else {
let rows: Vec<Vec<String>> = body.connections.iter()
let rows: Vec<Vec<String>> = body
.connections
.iter()
.map(|c| vec![c.id.clone(), c.name.clone(), c.source_type.clone()])
.collect();
crate::table::print(&["ID", "NAME", "SOURCE TYPE"], &rows);
Expand Down
Loading
Loading