diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..41827ef --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,265 @@ +use crate::commands::auth::AuthCommands; +use crate::commands::connections::ConnectionsCommands; +use crate::commands::context::ContextCommands; +use crate::commands::databases::DatabasesCommands; +use crate::commands::embedding_providers::EmbeddingProvidersCommands; +use crate::commands::indexes::IndexesCommands; +use crate::commands::jobs::JobsCommands; +use crate::commands::queries::QueriesCommands; +use crate::commands::query::QueryCommands; +use crate::commands::results::ResultsCommands; +use crate::commands::skill::SkillCommands; +use crate::commands::tables::TablesCommands; +use crate::commands::workspace::WorkspaceCommands; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Commands { + /// Authenticate or manage auth settings + Auth { + #[command(subcommand)] + command: Option, + }, + + /// Execute a SQL query, or check status of a running query + Query { + /// SQL query string (omit when using a subcommand) + sql: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w')] + workspace_id: Option, + + /// Run query against a specific managed database (overrides the current database set via `databases set`) + #[arg(long, short = 'd')] + database: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] + output: String, + + #[command(subcommand)] + command: Option, + }, + + /// Manage workspaces + Workspaces { + #[command(subcommand)] + command: WorkspaceCommands, + }, + + /// Manage workspace connections + Connections { + /// Connection ID to show details + id: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + /// Output format (used with connection ID) + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + + #[command(subcommand)] + command: Option, + }, + + /// Managed databases you create and populate with tables (parquet uploads) + Databases { + /// Database id or name (omit to use a subcommand) + name_or_id: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + + #[command(subcommand)] + command: Option, + }, + + /// Manage tables in a workspace + Tables { + #[command(subcommand)] + command: TablesCommands, + }, + + /// Manage the hotdata agent skill + Skills { + #[command(subcommand)] + command: SkillCommands, + }, + + /// Retrieve a stored query result by ID, or list recent results + Results { + /// Result ID (omit to use a subcommand) + result_id: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] + output: String, + + #[command(subcommand)] + command: Option, + }, + + /// Manage background jobs + Jobs { + /// Job ID (omit to use a subcommand) + id: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + /// Output format (used with job ID) + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + + #[command(subcommand)] + command: Option, + }, + + /// Manage indexes on a table + Indexes { + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + #[command(subcommand)] + command: IndexesCommands, + }, + + /// Manage embedding providers (OpenAI, local, etc.) used by vector indexes + #[command(name = "embedding-providers")] + EmbeddingProviders { + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + #[command(subcommand)] + command: EmbeddingProvidersCommands, + }, + + /// Full-text or vector search across a table column + Search { + /// Search query text — required for both --type bm25 and --type vector + query: String, + + /// Search type (`bm25` or `vector`). Inferred automatically when the table has exactly + /// one search index — required only when multiple indexes exist. + /// + /// `vector` runs server-side `vector_distance(col, 'text')` — the server resolves the + /// embedding column, model, and metric from the index metadata. + /// + /// `bm25` runs server-side `bm25_search(table, col, 'text')` and requires a BM25 index + /// on the column. + #[arg(long, value_parser = ["vector", "bm25"])] + r#type: Option, + + /// Table to search (`connection.table` or `connection.schema.table`). + /// Schema defaults to `public` when omitted. + #[arg(long)] + table: String, + + /// Column to search. Inferred automatically when the table has exactly one search index + /// of the resolved type — required only when multiple indexed columns exist. + /// For `--type vector`, name the source text column — the server resolves the embedding + /// column from the index metadata. + #[arg(long)] + column: Option, + + /// Columns to display (comma-separated, defaults to all) + #[arg(long)] + select: Option, + + /// Maximum number of results + #[arg(long, default_value = "10")] + limit: u32, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w')] + workspace_id: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] + output: String, + }, + + /// Inspect query run history + Queries { + /// Query run ID to show details + id: Option, + + /// Output format (used with query run ID) + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + + #[command(subcommand)] + command: Option, + }, + + /// Sync database context with local Markdown (`./.md` in the current directory) + Context { + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + /// Database ID (defaults to active database set via 'hotdata databases set') + #[arg(long, short = 'd', global = true)] + database_id: Option, + + #[command(subcommand)] + command: ContextCommands, + }, + + /// Show workspace usage: queries, bytes scanned, and stored bytes + Usage { + /// Only count usage since this RFC 3339 timestamp (e.g. 2026-06-01T00:00:00Z); defaults to the current billing window + #[arg(long)] + since: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w', global = true)] + workspace_id: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Generate shell completions + Completions { + /// Shell to generate completions for + #[arg(value_enum)] + shell: ShellChoice, + }, + + /// Upgrade the hotdata CLI to the latest release + Upgrade, +} + +#[derive(Clone, clap::ValueEnum)] +pub enum ShellChoice { + Bash, + Zsh, + Fish, +} + +impl From for clap_complete::Shell { + fn from(s: ShellChoice) -> Self { + match s { + ShellChoice::Bash => clap_complete::Shell::Bash, + ShellChoice::Zsh => clap_complete::Shell::Zsh, + ShellChoice::Fish => clap_complete::Shell::Fish, + } + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..dc9d125 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,5 @@ +pub mod credentials; +pub mod database_session; +pub mod jwt; +pub mod raw_http; +pub mod sdk; diff --git a/src/client/credentials.rs b/src/client/credentials.rs new file mode 100644 index 0000000..a32dbee --- /dev/null +++ b/src/client/credentials.rs @@ -0,0 +1,326 @@ +//! Credential inspection: validate the active profile's auth state and read +//! claims (workspace scope, token source) from a minted api-key JWT. +//! +//! This is the infrastructure half of auth — consumed by the SDK seam and by +//! `main`'s workspace resolution. The interactive login/register/status UI +//! lives in [`crate::commands::auth`], which depends on this module (never the +//! reverse). + +use crate::config::{self, ApiKeySource}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + +#[derive(Debug, PartialEq)] +pub enum AuthStatus { + Authenticated, + NotConfigured, + Invalid(u16), + ConnectionError(String), +} + +pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus { + // Same precedence as the SDK seam: user-scoped CLI session / api_key + // fallback. + let api_key_fallback = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); + + // PKCE-origin sessions don't write an api_key, so absence of a key + // alone isn't "not configured" — only true if there's also no + // cached JWT session to validate. + if api_key_fallback.is_none() && crate::client::jwt::load_session().is_none() { + return AuthStatus::NotConfigured; + } + + let access_token = + match crate::client::jwt::ensure_access_token(profile_config, api_key_fallback) { + Ok(t) => t, + Err(_) => return AuthStatus::Invalid(401), + }; + + let url = format!("{}/workspaces", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + let req = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")); + match crate::util::send_debug(&client, req, None) { + Ok((status, _)) if status.is_success() => AuthStatus::Authenticated, + Ok((status, _)) => AuthStatus::Invalid(status.as_u16()), + Err(e) => AuthStatus::ConnectionError(e.to_string()), + } +} + +/// Workspace public-ids the active api-key credential (`--api-key` / +/// `HOTDATA_API_KEY`) is scoped to, read from its minted JWT's `workspaces` +/// claim. A database API token carries exactly one. Empty when there's no api +/// key, it can't be exchanged, or the claim is absent (an unrestricted token). +pub(crate) fn api_key_workspace_ids(profile_config: &config::ProfileConfig) -> Vec { + let Some(key) = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER") + else { + return Vec::new(); + }; + let Ok(token) = crate::client::jwt::ensure_access_token(profile_config, Some(key)) else { + return Vec::new(); + }; + jwt_array_claim(&token, "workspaces") +} + +/// When the active credential is a user-supplied api key (`--api-key` / +/// `HOTDATA_API_KEY`), exchange it for a JWT and return that JWT's `source` +/// claim (e.g. `database_api_token`). This lets `auth status` double as a +/// validator: a successful mint proves the key is accepted, and the source +/// confirms which kind of token it is. Returns `None` for CLI-session +/// credentials or if the key can't be exchanged. +pub(crate) fn api_key_jwt_source(profile_config: &config::ProfileConfig) -> Option { + if !matches!( + profile_config.api_key_source, + ApiKeySource::Flag | ApiKeySource::Env + ) { + return None; + } + let key = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER")?; + let token = crate::client::jwt::ensure_access_token(profile_config, Some(key)).ok()?; + jwt_string_claim(&token, "source") +} + +/// Decode a JWT payload (no signature verification) and return the named +/// string claim. Mirrors the decoder in `database_session` — the server +/// validates signatures on receipt, so the CLI only peeks at claims. +fn jwt_string_claim(token: &str, claim: &str) -> Option { + let payload = token.split('.').nth(1)?; + let bytes = URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + value.get(claim).and_then(|v| v.as_str()).map(String::from) +} + +/// Decode a JWT payload (no signature verification) and return the named claim +/// as a list of strings. Empty when the token is unparseable or the claim is +/// absent / not a string array (e.g. the `workspaces` scope claim). +fn jwt_array_claim(token: &str, claim: &str) -> Vec { + token + .split('.') + .nth(1) + .and_then(|payload| URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value.get(claim).and_then(|c| c.as_array()).map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use config::{ApiUrl, AppUrl, ProfileConfig, test_helpers::with_temp_config_dir}; + + fn mock_profile(url: &str, api_key: Option<&str>) -> ProfileConfig { + ProfileConfig { + api_key: api_key.map(String::from), + api_url: ApiUrl(Some(url.to_string())), + // Point app_url at the same server so any oauth path (e.g. + // ensure_access_token minting from an api_key) hits the + // mock instead of the real production app. + app_url: AppUrl(Some(url.to_string())), + ..Default::default() + } + } + + /// Persist a fully-valid session so check_status can short-circuit + /// the JWT mint/refresh path and go straight to the /workspaces + /// probe — mirrors the on-disk state immediately after a PKCE login. + fn save_test_session(token: &str) { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + crate::client::jwt::save_session(&crate::client::jwt::Session { + access_token: token.to_string(), + access_expires_at: now + 3600, + refresh_token: "r".into(), + refresh_expires_at: now + 86400, + source: "pkce".into(), + }) + .unwrap(); + } + + // --- jwt_string_claim / jwt_array_claim tests --- + + #[test] + fn jwt_string_claim_extracts_source() { + let payload = URL_SAFE_NO_PAD.encode(br#"{"source":"database_api_token","exp":123}"#); + let token = format!("header.{payload}.sig"); + assert_eq!( + jwt_string_claim(&token, "source").as_deref(), + Some("database_api_token") + ); + // Missing claim, non-string claim, and malformed tokens yield None. + assert_eq!(jwt_string_claim(&token, "nope"), None); + assert_eq!(jwt_string_claim(&token, "exp"), None); + assert_eq!(jwt_string_claim("not-a-jwt", "source"), None); + } + + #[test] + fn jwt_array_claim_extracts_workspaces() { + let payload = URL_SAFE_NO_PAD.encode(br#"{"workspaces":["work_a","work_b"]}"#); + let token = format!("header.{payload}.sig"); + assert_eq!( + jwt_array_claim(&token, "workspaces"), + vec!["work_a", "work_b"] + ); + // Missing claim / malformed tokens yield an empty list. + assert!(jwt_array_claim(&token, "nope").is_empty()); + assert!(jwt_array_claim("not-a-jwt", "workspaces").is_empty()); + } + + #[test] + fn api_key_workspace_ids_decodes_the_tokens_workspace_claim() { + // A database API token is authorized for exactly one workspace, carried + // in its minted JWT's `workspaces` claim — that's what scopes requests. + let (_tmp, _guard) = with_temp_config_dir(); + let mut server = mockito::Server::new(); + let payload = URL_SAFE_NO_PAD + .encode(br#"{"workspaces":["workbound"],"source":"database_api_token"}"#); + let jwt = format!("header.{payload}.sig"); + let mint = server + .mock("POST", "/o/token/") + .match_body(mockito::Matcher::UrlEncoded( + "grant_type".into(), + "api_token".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"access_token":"{jwt}","expires_in":300,"refresh_token":"r"}}"# + )) + .create(); + + let profile = mock_profile(&server.url(), Some("hd_dbtoken")); + let ids = api_key_workspace_ids(&profile); + mint.assert(); + assert_eq!(ids, vec!["workbound".to_string()]); + } + + // --- check_status tests --- + + #[test] + fn status_not_configured_when_no_key_no_session() { + let (_tmp, _guard) = with_temp_config_dir(); + let profile = mock_profile("http://localhost", None); + assert_eq!(check_status(&profile), AuthStatus::NotConfigured); + } + + #[test] + fn status_not_configured_when_placeholder_no_session() { + let (_tmp, _guard) = with_temp_config_dir(); + let profile = mock_profile("http://localhost", Some("PLACEHOLDER")); + assert_eq!(check_status(&profile), AuthStatus::NotConfigured); + } + + #[test] + fn status_authenticated_with_valid_session() { + let (_tmp, _guard) = with_temp_config_dir(); + save_test_session("valid-jwt"); + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/workspaces") + .match_header("Authorization", "Bearer valid-jwt") + .with_status(200) + .with_body(r#"{"workspaces":[]}"#) + .create(); + + let profile = mock_profile(&server.url(), None); + assert_eq!(check_status(&profile), AuthStatus::Authenticated); + mock.assert(); + } + + #[test] + fn status_authenticated_via_api_token_fallback_when_no_session() { + // Realistic upgrade path: user has an api_key in config but no + // session.json yet. ensure_access_token must mint a JWT from + // the api_key, then check_status probes /workspaces with it. + let (_tmp, _guard) = with_temp_config_dir(); + let mut server = mockito::Server::new(); + let mint_mock = server + .mock("POST", "/o/token/") + .match_body(mockito::Matcher::UrlEncoded( + "grant_type".into(), + "api_token".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"access_token":"minted-jwt","expires_in":300,"refresh_token":"r"}"#) + .create(); + let probe_mock = server + .mock("GET", "/workspaces") + .match_header("Authorization", "Bearer minted-jwt") + .with_status(200) + .with_body(r#"{"workspaces":[]}"#) + .create(); + + let profile = mock_profile(&server.url(), Some("hd_xyz")); + assert_eq!(check_status(&profile), AuthStatus::Authenticated); + mint_mock.assert(); + probe_mock.assert(); + } + + #[test] + fn status_invalid_when_session_revoked_server_side() { + let (_tmp, _guard) = with_temp_config_dir(); + save_test_session("revoked-jwt"); + let mut server = mockito::Server::new(); + let mock = server.mock("GET", "/workspaces").with_status(401).create(); + + let profile = mock_profile(&server.url(), None); + assert_eq!(check_status(&profile), AuthStatus::Invalid(401)); + mock.assert(); + } + + #[test] + fn status_invalid_with_forbidden() { + let (_tmp, _guard) = with_temp_config_dir(); + save_test_session("jwt"); + let mut server = mockito::Server::new(); + let mock = server.mock("GET", "/workspaces").with_status(403).create(); + + let profile = mock_profile(&server.url(), None); + assert_eq!(check_status(&profile), AuthStatus::Invalid(403)); + mock.assert(); + } + + #[test] + fn status_invalid_when_api_token_rejected_no_session() { + // No session, and the api_key fallback is rejected by the mint + // endpoint — collapse to Invalid(401) so `auth status` shows + // the user they need to re-auth. + let (_tmp, _guard) = with_temp_config_dir(); + let mut server = mockito::Server::new(); + let mock = server.mock("POST", "/o/token/").with_status(401).create(); + + let profile = mock_profile(&server.url(), Some("hd_revoked")); + assert_eq!(check_status(&profile), AuthStatus::Invalid(401)); + mock.assert(); + } + + #[test] + fn status_connection_error_during_probe() { + let (_tmp, _guard) = with_temp_config_dir(); + save_test_session("jwt"); + let profile = mock_profile("http://127.0.0.1:1", None); + match check_status(&profile) { + AuthStatus::ConnectionError(_) => {} + other => panic!("expected ConnectionError, got {:?}", other), + } + } +} diff --git a/src/database_session.rs b/src/client/database_session.rs similarity index 99% rename from src/database_session.rs rename to src/client/database_session.rs index 01557d4..b1f6fe6 100644 --- a/src/database_session.rs +++ b/src/client/database_session.rs @@ -184,7 +184,7 @@ pub fn database_token_in_use() -> Option<(String, Option)> { } /// In-child equivalent of a parent-side `ensure_access_token`: operates -/// on env vars only. Used by [`crate::sdk::Api`] when the parent +/// on env vars only. Used by [`crate::client::sdk::Api`] when the parent /// `databases run` already passed in `HOTDATA_DATABASE_TOKEN` and /// `HOTDATA_DATABASE_REFRESH_TOKEN`. The new tokens are *not* persisted /// to disk — the child may not have write access to the parent's diff --git a/src/jwt.rs b/src/client/jwt.rs similarity index 99% rename from src/jwt.rs rename to src/client/jwt.rs index 99e1a22..4db5a74 100644 --- a/src/jwt.rs +++ b/src/client/jwt.rs @@ -411,8 +411,10 @@ impl CliTokenProvider { /// string describing why no token could be obtained. fn resolve_blocking(mode: &AuthMode) -> Result { match mode { - AuthMode::DatabaseEnv { api_url } => crate::database_session::refresh_from_env(api_url) - .ok_or_else(|| "HOTDATA_DATABASE_TOKEN is empty".to_string()), + AuthMode::DatabaseEnv { api_url } => { + crate::client::database_session::refresh_from_env(api_url) + .ok_or_else(|| "HOTDATA_DATABASE_TOKEN is empty".to_string()) + } AuthMode::Session { profile, api_key_fallback, @@ -1137,7 +1139,7 @@ mod tests { /// Resolve a provider's bearer on the shared wrapper runtime. fn bearer(provider: &CliTokenProvider) -> Result { - crate::sdk::rt().block_on(provider.bearer_value()) + crate::client::sdk::rt().block_on(provider.bearer_value()) } fn session_provider(profile: &ProfileConfig, api_key: Option<&str>) -> CliTokenProvider { diff --git a/src/raw_http.rs b/src/client/raw_http.rs similarity index 100% rename from src/raw_http.rs rename to src/client/raw_http.rs diff --git a/src/sdk.rs b/src/client/sdk.rs similarity index 98% rename from src/sdk.rs rename to src/client/sdk.rs index 47b65e4..69e88a1 100644 --- a/src/sdk.rs +++ b/src/client/sdk.rs @@ -18,8 +18,8 @@ //! # Auth //! //! Construction reproduces the old `ApiClient::new` 4-level auth-source -//! precedence by choosing the [`AuthMode`](crate::jwt::AuthMode) the installed -//! [`CliTokenProvider`](crate::jwt::CliTokenProvider) will serve. The provider +//! precedence by choosing the [`AuthMode`](crate::client::jwt::AuthMode) the installed +//! [`CliTokenProvider`](crate::client::jwt::CliTokenProvider) will serve. The provider //! returns a ready CLI-minted JWT (`client_id=hotdata-cli`, `/o/token/`), which //! the SDK passes through unchanged; the CLI keeps full ownership of //! session.json and the refresh table. @@ -33,9 +33,9 @@ use hotdata::apis::configuration::{ApiKey, Configuration}; use hotdata::apis::{Error, ResponseContent}; use hotdata::{UploadError, UploadOptions, UploadProgress}; -use crate::auth; +use crate::client::credentials; +use crate::client::jwt::{AuthMode, CliTokenProvider}; use crate::config; -use crate::jwt::{AuthMode, CliTokenProvider}; use crate::util; /// Process-global multi-thread runtime shared by all [`Api`] clones. @@ -259,7 +259,7 @@ impl ApiError { let auth_status = if status.is_client_error() { config::load("default") .ok() - .map(|pc| auth::check_status(&pc)) + .map(|pc| credentials::check_status(&pc)) } else { None }; @@ -459,7 +459,7 @@ impl Api { // the right hint), then hand the CliTokenProvider the matching mode to // re-resolve on every request. let mode = if std::env::var("HOTDATA_DATABASE_TOKEN").is_ok() { - if crate::database_session::refresh_from_env(&api_url).is_none() { + if crate::client::database_session::refresh_from_env(&api_url).is_none() { eprintln!( "{}", crossterm::style::Stylize::red("error: HOTDATA_DATABASE_TOKEN is empty") @@ -476,9 +476,10 @@ impl Api { .filter(|k| !k.is_empty() && *k != "PLACEHOLDER") .map(String::from); - if let Err(e) = - crate::jwt::ensure_access_token(&profile_config, api_key_fallback.as_deref()) - { + if let Err(e) = crate::client::jwt::ensure_access_token( + &profile_config, + api_key_fallback.as_deref(), + ) { use crossterm::style::Stylize; eprintln!("{}", format!("error: {e}").red()); eprintln!( @@ -835,10 +836,10 @@ impl Api { pub fn format_fail_message( status: reqwest::StatusCode, body: &str, - auth_status: Option<&auth::AuthStatus>, + auth_status: Option<&credentials::AuthStatus>, ) -> String { if status.is_client_error() - && let Some(auth::AuthStatus::Invalid(_)) = auth_status + && let Some(credentials::AuthStatus::Invalid(_)) = auth_status { return "error: API key is invalid. Run 'hotdata auth login' to re-authenticate." .to_string(); @@ -861,7 +862,7 @@ pub fn format_fail_message( #[cfg(test)] mod tests { use super::*; - use auth::AuthStatus; + use credentials::AuthStatus; #[test] fn api_error_message_formats_status_and_transport() { diff --git a/src/command.rs b/src/command.rs deleted file mode 100644 index 938feb3..0000000 --- a/src/command.rs +++ /dev/null @@ -1,938 +0,0 @@ -use clap::Subcommand; - -#[derive(Subcommand)] -pub enum Commands { - /// Authenticate or manage auth settings - Auth { - #[command(subcommand)] - command: Option, - }, - - /// Execute a SQL query, or check status of a running query - Query { - /// SQL query string (omit when using a subcommand) - sql: Option, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w')] - workspace_id: Option, - - /// Run query against a specific managed database (overrides the current database set via `databases set`) - #[arg(long, short = 'd')] - database: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] - output: String, - - #[command(subcommand)] - command: Option, - }, - - /// Manage workspaces - Workspaces { - #[command(subcommand)] - command: WorkspaceCommands, - }, - - /// Manage workspace connections - Connections { - /// Connection ID to show details - id: Option, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - /// Output format (used with connection ID) - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - - #[command(subcommand)] - command: Option, - }, - - /// Managed databases you create and populate with tables (parquet uploads) - Databases { - /// Database id or name (omit to use a subcommand) - name_or_id: Option, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - - #[command(subcommand)] - command: Option, - }, - - /// Manage tables in a workspace - Tables { - #[command(subcommand)] - command: TablesCommands, - }, - - /// Manage the hotdata agent skill - Skills { - #[command(subcommand)] - command: SkillCommands, - }, - - /// Retrieve a stored query result by ID, or list recent results - Results { - /// Result ID (omit to use a subcommand) - result_id: Option, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] - output: String, - - #[command(subcommand)] - command: Option, - }, - - /// Manage background jobs - Jobs { - /// Job ID (omit to use a subcommand) - id: Option, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - /// Output format (used with job ID) - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - - #[command(subcommand)] - command: Option, - }, - - /// Manage indexes on a table - Indexes { - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - #[command(subcommand)] - command: IndexesCommands, - }, - - /// Manage embedding providers (OpenAI, local, etc.) used by vector indexes - #[command(name = "embedding-providers")] - EmbeddingProviders { - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - #[command(subcommand)] - command: EmbeddingProvidersCommands, - }, - - /// Full-text or vector search across a table column - Search { - /// Search query text — required for both --type bm25 and --type vector - query: String, - - /// Search type (`bm25` or `vector`). Inferred automatically when the table has exactly - /// one search index — required only when multiple indexes exist. - /// - /// `vector` runs server-side `vector_distance(col, 'text')` — the server resolves the - /// embedding column, model, and metric from the index metadata. - /// - /// `bm25` runs server-side `bm25_search(table, col, 'text')` and requires a BM25 index - /// on the column. - #[arg(long, value_parser = ["vector", "bm25"])] - r#type: Option, - - /// Table to search (`connection.table` or `connection.schema.table`). - /// Schema defaults to `public` when omitted. - #[arg(long)] - table: String, - - /// Column to search. Inferred automatically when the table has exactly one search index - /// of the resolved type — required only when multiple indexed columns exist. - /// For `--type vector`, name the source text column — the server resolves the embedding - /// column from the index metadata. - #[arg(long)] - column: Option, - - /// Columns to display (comma-separated, defaults to all) - #[arg(long)] - select: Option, - - /// Maximum number of results - #[arg(long, default_value = "10")] - limit: u32, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w')] - workspace_id: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "csv"])] - output: String, - }, - - /// Inspect query run history - Queries { - /// Query run ID to show details - id: Option, - - /// Output format (used with query run ID) - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - - #[command(subcommand)] - command: Option, - }, - - /// Sync database context with local Markdown (`./.md` in the current directory) - Context { - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - /// Database ID (defaults to active database set via 'hotdata databases set') - #[arg(long, short = 'd', global = true)] - database_id: Option, - - #[command(subcommand)] - command: ContextCommands, - }, - - /// Show workspace usage: queries, bytes scanned, and stored bytes - Usage { - /// Only count usage since this RFC 3339 timestamp (e.g. 2026-06-01T00:00:00Z); defaults to the current billing window - #[arg(long)] - since: Option, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Generate shell completions - Completions { - /// Shell to generate completions for - #[arg(value_enum)] - shell: ShellChoice, - }, - - /// Upgrade the hotdata CLI to the latest release - Upgrade, -} - -#[derive(Clone, clap::ValueEnum)] -pub enum ShellChoice { - Bash, - Zsh, - Fish, -} - -impl From for clap_complete::Shell { - fn from(s: ShellChoice) -> Self { - match s { - ShellChoice::Bash => clap_complete::Shell::Bash, - ShellChoice::Zsh => clap_complete::Shell::Zsh, - ShellChoice::Fish => clap_complete::Shell::Fish, - } - } -} - -#[derive(Subcommand)] -pub enum QueryCommands { - /// Check the status of a running query and retrieve results. - /// Exit codes: 0 = succeeded, 1 = failed, 2 = still running (poll again), - /// 3 = succeeded but the result is an incomplete/truncated preview - Status { - /// Query run ID - id: String, - }, -} - -#[derive(Subcommand)] -pub enum AuthCommands { - /// Log in via browser - Login, - - /// Create a new account via browser (defaults to GitHub OAuth) - Register { - /// Sign up with email and password instead of GitHub - #[arg(long)] - email: bool, - }, - - /// Remove authentication for a profile - Logout, - - /// Show authentication status - Status, -} - -#[derive(Subcommand)] -pub enum IndexesCommands { - /// List indexes (defaults to the whole workspace; narrow with filters) - List { - /// Filter by connection ID - #[arg(long, short = 'c')] - connection_id: Option, - - /// Filter by schema name - #[arg(long)] - schema: Option, - - /// Filter by table name - #[arg(long)] - table: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Create an index on a table. - Create { - /// SQL catalog alias of the target database (e.g. `--catalog airbnb`) - #[arg(long)] - catalog: Option, - - /// Schema name (default: public) - #[arg(long, default_value = "public")] - schema: String, - - /// Table name to index - #[arg(long)] - table: Option, - - /// Column(s) to index (comma-separated) - #[arg(long)] - column: Option, - - /// Index name (derived from table, columns, and type if omitted) - #[arg(long)] - name: Option, - - /// Index type — required (no default; choose deliberately) - #[arg(long, value_parser = ["sorted", "bm25", "vector"])] - r#type: String, - - /// Distance metric for vector indexes - #[arg(long, value_parser = ["l2", "cosine", "dot"])] - metric: Option, - - /// Create as a background job - #[arg(long)] - r#async: bool, - - /// Embedding provider ID — when set on a vector index over a text column, - /// embeddings are generated automatically. Defaults to first system provider if omitted. - #[arg(long = "embedding-provider-id")] - embedding_provider_id: Option, - - /// Override embedding output dimensions (vector indexes with auto-embedding only) - #[arg(long)] - dimensions: Option, - - /// Custom name for the generated embedding column (defaults to `{column}_embedding`) - #[arg(long = "output-column")] - output_column: Option, - - /// Human-readable description of the embedding (e.g. "product titles") - #[arg(long)] - description: Option, - }, - - /// Delete an index from a table - /// - /// Pass connection scope: --connection-id + --schema + --table. - Delete { - /// Connection ID (use with --schema and --table) - #[arg(long, short = 'c', requires_all = ["schema", "table"])] - connection_id: Option, - - /// Schema name (requires --connection-id) - #[arg(long, requires = "connection_id")] - schema: Option, - - /// Table name (requires --connection-id) - #[arg(long, requires = "connection_id")] - table: Option, - - /// Index name - #[arg(long)] - name: String, - }, -} - -#[derive(Subcommand)] -pub enum JobsCommands { - /// List background jobs (shows active jobs by default) - List { - /// Filter by job type - #[arg(long, value_parser = ["data_refresh_table", "data_refresh_connection", "create_index", "managed_load"])] - job_type: Option, - - /// Filter by status - #[arg(long, value_parser = ["pending", "running", "succeeded", "partially_succeeded", "failed"])] - status: Option, - - /// Show all jobs, not just active ones - #[arg(long)] - all: bool, - - /// Maximum number of results (default: 50) - #[arg(long)] - limit: Option, - - /// Pagination offset - #[arg(long)] - offset: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, -} - -#[derive(Subcommand)] -pub enum WorkspaceCommands { - /// List all workspaces - List { - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Set the default workspace - Set { - /// Workspace ID to set as default (omit for interactive selection) - workspace_id: Option, - }, -} - -#[derive(Subcommand)] -pub enum ConnectionsCreateCommands { - /// List available connection types, or get details for a specific type - List { - /// Connection type name (e.g. postgres, mysql); omit to list all - name: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, -} - -#[derive(Subcommand)] -pub enum DatabasesCommands { - /// List managed databases in the workspace - List { - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Show details for a specific managed database - Show { - /// Database name or ID - name_or_id: String, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Create a new managed database - Create { - /// Human-readable display name for the database (e.g. "Sales reporting"). - #[arg(long)] - name: Option, - - /// SQL catalog alias used in queries: SELECT … FROM .schema.table. - /// Must be [a-z_][a-z0-9_]*, globally unique. - #[arg(long)] - catalog: Option, - - /// Default schema for bare `--table` entries (default: public). - /// Use dot notation in `--table` to target a different schema directly, - /// e.g. `--table raw.raw_orders` always goes into the "raw" schema. - #[arg(long, default_value = "public")] - schema: String, - - /// Table to declare up front (repeatable). Accepts bare names or - /// `schema.table` dot notation to span multiple schemas in one command: - /// --table orders --table raw.raw_orders --table raw.raw_customers - #[arg(long = "table")] - tables: Vec, - - /// When the database expires. Accepts a relative duration (e.g. 24h, 7d, 90m) - /// or an RFC 3339 timestamp. Omitting means no expiry. - #[arg(long)] - expires_at: Option, - - /// Attach a connection as a queryable catalog on the new database (repeatable). - /// Accepts a connection name or id, optionally `connection=alias` to set the - /// SQL alias it answers to: `--attach github --attach salesdb=sales`. - #[arg(long = "attach")] - attach: Vec, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Attach a connection as a queryable catalog on a managed database. - /// - /// A `query` runs inside one managed database; attaching a connection makes - /// its live tables visible in that database's scope, so you can join across - /// sources in a single query without exporting data. Reachable in SQL as - /// `..`, or `..
` when - /// `--alias` is omitted. - Attach { - /// Connection name or id to attach (e.g. `github`) - connection: String, - - /// Database id, catalog, or name to attach into (defaults to the current database) - #[arg(long, short = 'd')] - database: Option, - - /// Alias the catalog answers to in SQL. Defaults to the connection's name. - #[arg(long)] - alias: Option, - }, - - /// Detach a previously attached connection catalog from a managed database. - Detach { - /// Connection name or id to detach - connection: String, - - /// Database id, catalog, or name to detach from (defaults to the current database) - #[arg(long, short = 'd')] - database: Option, - }, - - /// Set the current database (used by default when no database is specified) - Set { - /// Database id - id: String, - }, - - /// Clear the current database - Unset, - - /// Delete a managed database and its tables - Delete { - /// Database name or connection ID - name_or_id: String, - }, - - /// Load a parquet file into a managed database table - Load { - /// SQL catalog alias of the target database (e.g. `--catalog airbnb`) - #[arg(long)] - catalog: String, - - /// Schema to load into (default: public) - #[arg(long, default_value = "public")] - schema: String, - - /// Table name to load into - #[arg(long)] - table: String, - - /// Path to a local parquet file to upload and load - #[arg(long, conflicts_with_all = ["upload_id", "url"])] - file: Option, - - /// URL of a remote parquet file to download and load - #[arg(long, conflicts_with_all = ["file", "upload_id"])] - url: Option, - - /// Use a previously staged upload ID from `POST /v1/uploads` instead of uploading - #[arg(long, conflicts_with_all = ["file", "url"])] - upload_id: Option, - }, - - /// Manage tables inside a managed database - Tables { - /// Database id or name — shorthand for `tables list` when no subcommand is given - database: Option, - - #[command(subcommand)] - command: Option, - }, - - /// Run a command with a database-scoped token. Creates a new database unless --database is given. - Run { - /// Existing database id to scope the token to (omit to auto-create a database) - #[arg(long)] - database: Option, - - /// Name for the auto-created database (only used when --database is omitted) - #[arg(long)] - name: Option, - - /// Schema for tables declared in the auto-created database (default: public) - #[arg(long, default_value = "public")] - schema: String, - - /// Table to declare in the auto-created database (repeatable) - #[arg(long = "table")] - tables: Vec, - - /// When the auto-created database expires. Accepts a relative duration - /// (e.g. 24h, 7d, 90m) or an RFC 3339 timestamp. Defaults to 24h when omitted. - #[arg(long)] - expires_at: Option, - - /// Command to execute (everything after `--`) - #[arg(trailing_var_arg = true, required = true)] - cmd: Vec, - }, -} - -#[derive(Subcommand)] -pub enum DatabaseTablesCommands { - /// List tables in a managed database - List { - /// Database id or name (defaults to current database) - #[arg(long)] - database: Option, - - /// Filter by schema name - #[arg(long)] - schema: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Load a parquet file into a table (creates or replaces the table) - Load { - /// Database id or name (defaults to current database) - #[arg(long)] - database: Option, - - /// Table name - table: String, - - /// Schema name (default: public) - #[arg(long, default_value = "public")] - schema: String, - - /// Path to a local parquet file to upload and load - #[arg(long, conflicts_with_all = ["upload_id", "url"])] - file: Option, - - /// URL of a remote parquet file to download and load - #[arg(long, conflicts_with_all = ["file", "upload_id"])] - url: Option, - - /// Use a previously staged upload ID from `POST /v1/uploads` instead of uploading - #[arg(long, conflicts_with_all = ["file", "url"])] - upload_id: Option, - }, - - /// Delete a table from a managed database - Delete { - /// Database id or name (defaults to current database) - #[arg(long)] - database: Option, - - /// Table name - table: String, - - /// Schema name (default: public) - #[arg(long, default_value = "public")] - schema: String, - }, -} - -#[derive(Subcommand)] -pub enum ConnectionsCommands { - /// Interactively create a new connection - New, - - /// List all connections for a workspace - List { - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Create a new connection, or list/inspect available connection types - Create { - #[command(subcommand)] - command: Option, - - /// Connection name - #[arg(long)] - name: Option, - - /// Connection source type (e.g. postgres, mysql, snowflake) - #[arg(long = "type")] - source_type: Option, - - /// Connection config as a JSON object - #[arg(long)] - config: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Refresh a connection's schema or data - Refresh { - /// Connection ID - connection_id: String, - - /// Refresh data instead of schema metadata - #[arg(long)] - data: bool, - - /// Narrow refresh to a specific schema (requires --table for data refresh) - #[arg(long)] - schema: Option, - - /// Narrow refresh to a specific table (requires --schema) - #[arg(long)] - table: Option, - - /// Submit as a background job (only valid with --data) - #[arg(long)] - r#async: bool, - - /// Include uncached tables in connection-wide data refresh (only with --data, no --table) - #[arg(long = "include-uncached")] - include_uncached: bool, - }, -} - -#[derive(Subcommand)] -pub enum SkillCommands { - /// Install or update the hotdata skill into agent directories - Install { - /// Install into the current project directory instead of globally - #[arg(long)] - project: bool, - }, - /// Show the installation status of the hotdata skill - Status, - /// List installed skills and their versions (alias for status) - List, -} - -#[derive(Subcommand)] -pub enum ResultsCommands { - /// List stored query results - List { - /// Maximum number of results (default: 100, max: 1000) - #[arg(long)] - limit: Option, - - /// Pagination offset - #[arg(long)] - offset: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, -} - -#[derive(Subcommand)] -pub enum QueriesCommands { - /// List query runs - List { - /// Maximum number of results - #[arg(long, default_value_t = 20)] - limit: u32, - - /// Pagination cursor from a previous response - #[arg(long)] - cursor: Option, - - /// Filter by status (comma-separated, e.g. running,failed) - #[arg(long)] - status: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, -} - -#[derive(Subcommand)] -pub enum ContextCommands { - /// List named contexts in the workspace - List { - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - - /// Only include names starting with this prefix (case-sensitive) - #[arg(long)] - prefix: Option, - }, - - /// Print context content to stdout - Show { - /// Context name (same rules as a SQL table identifier; local file is .md). A trailing `.md` is ignored (e.g. `USER.md` → `USER`). - name: String, - }, - - /// Download context from the database to ./.md - Pull { - /// Context name (trailing `.md` ignored, e.g. `USER.md` → `USER`) - name: String, - - /// Overwrite ./.md if it already exists - #[arg(long)] - force: bool, - - /// Print the target path and size only; do not write a file - #[arg(long)] - dry_run: bool, - }, - - /// Upload ./.md to the database as named context - Push { - /// Context name (trailing `.md` ignored, e.g. `USER.md` → `USER`; reads `./USER.md`) - name: String, - - /// Print what would be sent; do not POST - #[arg(long)] - dry_run: bool, - }, -} - -#[derive(Subcommand)] -pub enum TablesCommands { - /// List all tables in a workspace - List { - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w')] - workspace_id: Option, - - /// Filter by connection ID (also enables column output) - #[arg(long, short = 'c')] - connection_id: Option, - - /// Filter by schema name (supports % wildcards) - #[arg(long)] - schema: Option, - - /// Filter by table name (supports % wildcards) - #[arg(long)] - table: Option, - - /// Maximum number of results to return - #[arg(long)] - limit: Option, - - /// Pagination cursor from a previous response - #[arg(long)] - cursor: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, -} - -#[derive(Subcommand)] -pub enum EmbeddingProvidersCommands { - /// List embedding providers - List { - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Show details for a specific embedding provider - Get { - /// Provider ID - id: String, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Create a new embedding provider - Create { - /// Provider name (must be unique within the workspace) - #[arg(long)] - name: String, - - /// Provider type ("local" or "service") - #[arg(long, value_parser = ["local", "service"])] - provider_type: String, - - /// Provider-specific config as a JSON string (model, base_url, dimensions, etc.) - #[arg(long)] - config: Option, - - /// The provider's own API key (e.g. an OpenAI sk-... key). Auto-creates a - /// managed secret. Mutually exclusive with --secret-name. Named - /// `--provider-api-key` to pair with `--provider-type` and to avoid colliding - /// with the global `--api-key` (Hotdata auth) flag. - #[arg(long = "provider-api-key", conflicts_with = "secret_name")] - provider_api_key: Option, - - /// Reference an existing secret by name (for service providers) - #[arg(long)] - secret_name: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Update an embedding provider's name, config, or secret - Update { - /// Provider ID - id: String, - - /// New name - #[arg(long)] - name: Option, - - /// New config as a JSON string - #[arg(long)] - config: Option, - - /// New provider API key (replaces or creates the managed secret). - /// See `embedding-providers create --provider-api-key` for naming rationale. - #[arg(long = "provider-api-key", conflicts_with = "secret_name")] - provider_api_key: Option, - - /// New secret name to reference - #[arg(long)] - secret_name: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Delete an embedding provider - Delete { - /// Provider ID - id: String, - }, -} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..6e3183e --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,15 @@ +pub mod auth; +pub mod connections; +pub mod context; +pub mod databases; +pub mod embedding_providers; +pub mod indexes; +pub mod jobs; +pub mod queries; +pub mod query; +pub mod results; +pub mod skill; +pub mod tables; +pub mod update; +pub mod usage; +pub mod workspace; diff --git a/src/auth.rs b/src/commands/auth.rs similarity index 69% rename from src/auth.rs rename to src/commands/auth.rs index c201249..16c77d6 100644 --- a/src/auth.rs +++ b/src/commands/auth.rs @@ -1,3 +1,4 @@ +use crate::client::credentials::{AuthStatus, api_key_jwt_source, check_status}; use crate::config::{self, ApiKeySource}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use crossterm::ExecutableCommand; @@ -8,8 +9,28 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::io::stdout; +/// Subcommands for `hotdata auth`. +#[derive(clap::Subcommand)] +pub enum AuthCommands { + /// Log in via browser + Login, + + /// Create a new account via browser (defaults to GitHub OAuth) + Register { + /// Sign up with email and password instead of GitHub + #[arg(long)] + email: bool, + }, + + /// Remove authentication for a profile + Logout, + + /// Show authentication status + Status, +} + pub fn logout(profile: &str) { - crate::jwt::clear_session(); + crate::client::jwt::clear_session(); if let Err(e) = config::clear_workspaces(profile) { eprintln!("error: {e}"); std::process::exit(1); @@ -17,46 +38,6 @@ pub fn logout(profile: &str) { println!("{}", "Logged out.".green()); } -#[derive(Debug, PartialEq)] -pub enum AuthStatus { - Authenticated, - NotConfigured, - Invalid(u16), - ConnectionError(String), -} - -pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus { - // Same precedence as the SDK seam: user-scoped CLI session / api_key - // fallback. - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - - // PKCE-origin sessions don't write an api_key, so absence of a key - // alone isn't "not configured" — only true if there's also no - // cached JWT session to validate. - if api_key_fallback.is_none() && crate::jwt::load_session().is_none() { - return AuthStatus::NotConfigured; - } - - let access_token = match crate::jwt::ensure_access_token(profile_config, api_key_fallback) { - Ok(t) => t, - Err(_) => return AuthStatus::Invalid(401), - }; - - let url = format!("{}/workspaces", profile_config.api_url); - let client = reqwest::blocking::Client::new(); - let req = client - .get(&url) - .header("Authorization", format!("Bearer {access_token}")); - match crate::util::send_debug(&client, req, None) { - Ok((status, _)) if status.is_success() => AuthStatus::Authenticated, - Ok((status, _)) => AuthStatus::Invalid(status.as_u16()), - Err(e) => AuthStatus::ConnectionError(e.to_string()), - } -} - pub fn status(profile: &str) { let profile_config = match config::load(profile) { Ok(c) => c, @@ -81,9 +62,8 @@ pub fn status(profile: &str) { .api_key .as_deref() .map(crate::util::mask_credential), - ApiKeySource::Config => { - crate::jwt::load_session().map(|s| crate::util::mask_credential(&s.refresh_token)) - } + ApiKeySource::Config => crate::client::jwt::load_session() + .map(|s| crate::util::mask_credential(&s.refresh_token)), }; let method_label = method_label.to_string(); let method_suffix = match credential_tail { @@ -279,7 +259,7 @@ fn run_browser_auth( success_title: &str, success_body: &str, build_url: impl Fn(&str, &str, &str, u16) -> String, - exchange: impl Fn(&str, &str, u16) -> Result, + exchange: impl Fn(&str, &str, u16) -> Result, ) { let app_url = profile_config.app_url.to_string(); let code_verifier = generate_code_verifier(); @@ -319,7 +299,7 @@ fn run_browser_auth( match exchange(&code, &code_verifier, port) { Ok(session) => { - if let Err(e) = crate::jwt::save_session(&session) { + if let Err(e) = crate::client::jwt::save_session(&session) { eprintln!("warning: could not save session: {e}"); } stdout() @@ -403,7 +383,12 @@ pub fn login() { }, |code, code_verifier, port| { let redirect_uri = format!("http://127.0.0.1:{port}/"); - crate::jwt::mint_from_pkce_code(&profile_config, code, code_verifier, &redirect_uri) + crate::client::jwt::mint_from_pkce_code( + &profile_config, + code, + code_verifier, + &redirect_uri, + ) }, ); } @@ -438,7 +423,7 @@ pub fn register(use_email: bool) { ) }, |code, code_verifier, _port| { - crate::jwt::exchange_cli_register_code(&profile_config, code, code_verifier) + crate::client::jwt::exchange_cli_register_code(&profile_config, code, code_verifier) }, ); } @@ -491,7 +476,8 @@ fn api_key_authorized_workspaces( else { return Vec::new(); }; - let Ok(access_token) = crate::jwt::ensure_access_token(profile_config, Some(key)) else { + let Ok(access_token) = crate::client::jwt::ensure_access_token(profile_config, Some(key)) + else { return Vec::new(); }; let url = format!("{}/workspaces", profile_config.api_url); @@ -543,75 +529,6 @@ fn parse_query_params(url: &str) -> HashMap { .collect() } -/// Decode a JWT payload (no signature verification) and return the named -/// string claim. Mirrors the decoder in `database_session` — the server -/// validates signatures on receipt, so the CLI only peeks at claims. -fn jwt_string_claim(token: &str, claim: &str) -> Option { - let payload = token.split('.').nth(1)?; - let bytes = URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()?; - let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - value.get(claim).and_then(|v| v.as_str()).map(String::from) -} - -/// Decode a JWT payload (no signature verification) and return the named claim -/// as a list of strings. Empty when the token is unparseable or the claim is -/// absent / not a string array (e.g. the `workspaces` scope claim). -fn jwt_array_claim(token: &str, claim: &str) -> Vec { - token - .split('.') - .nth(1) - .and_then(|payload| URL_SAFE_NO_PAD.decode(payload.as_bytes()).ok()) - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| { - value.get(claim).and_then(|c| c.as_array()).map(|items| { - items - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - }) - .unwrap_or_default() -} - -/// Workspace public-ids the active api-key credential (`--api-key` / -/// `HOTDATA_API_KEY`) is scoped to, read from its minted JWT's `workspaces` -/// claim. A database API token carries exactly one. Empty when there's no api -/// key, it can't be exchanged, or the claim is absent (an unrestricted token). -pub(crate) fn api_key_workspace_ids(profile_config: &config::ProfileConfig) -> Vec { - let Some(key) = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER") - else { - return Vec::new(); - }; - let Ok(token) = crate::jwt::ensure_access_token(profile_config, Some(key)) else { - return Vec::new(); - }; - jwt_array_claim(&token, "workspaces") -} - -/// When the active credential is a user-supplied api key (`--api-key` / -/// `HOTDATA_API_KEY`), exchange it for a JWT and return that JWT's `source` -/// claim (e.g. `database_api_token`). This lets `auth status` double as a -/// validator: a successful mint proves the key is accepted, and the source -/// confirms which kind of token it is. Returns `None` for CLI-session -/// credentials or if the key can't be exchanged. -pub(crate) fn api_key_jwt_source(profile_config: &config::ProfileConfig) -> Option { - if !matches!( - profile_config.api_key_source, - ApiKeySource::Flag | ApiKeySource::Env - ) { - return None; - } - let key = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER")?; - let token = crate::jwt::ensure_access_token(profile_config, Some(key)).ok()?; - jwt_string_claim(&token, "source") -} - fn print_row(label: &str, value: &str) { stdout() .execute(SetForegroundColor(Color::DarkGrey)) @@ -657,7 +574,7 @@ mod tests { .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - crate::jwt::save_session(&crate::jwt::Session { + crate::client::jwt::save_session(&crate::client::jwt::Session { access_token: token.to_string(), access_expires_at: now + 3600, refresh_token: "r".into(), @@ -667,63 +584,6 @@ mod tests { .unwrap(); } - // --- jwt_string_claim tests --- - - #[test] - fn jwt_string_claim_extracts_source() { - let payload = URL_SAFE_NO_PAD.encode(br#"{"source":"database_api_token","exp":123}"#); - let token = format!("header.{payload}.sig"); - assert_eq!( - jwt_string_claim(&token, "source").as_deref(), - Some("database_api_token") - ); - // Missing claim, non-string claim, and malformed tokens yield None. - assert_eq!(jwt_string_claim(&token, "nope"), None); - assert_eq!(jwt_string_claim(&token, "exp"), None); - assert_eq!(jwt_string_claim("not-a-jwt", "source"), None); - } - - #[test] - fn jwt_array_claim_extracts_workspaces() { - let payload = URL_SAFE_NO_PAD.encode(br#"{"workspaces":["work_a","work_b"]}"#); - let token = format!("header.{payload}.sig"); - assert_eq!( - jwt_array_claim(&token, "workspaces"), - vec!["work_a", "work_b"] - ); - // Missing claim / malformed tokens yield an empty list. - assert!(jwt_array_claim(&token, "nope").is_empty()); - assert!(jwt_array_claim("not-a-jwt", "workspaces").is_empty()); - } - - #[test] - fn api_key_workspace_ids_decodes_the_tokens_workspace_claim() { - // A database API token is authorized for exactly one workspace, carried - // in its minted JWT's `workspaces` claim — that's what scopes requests. - let (_tmp, _guard) = with_temp_config_dir(); - let mut server = mockito::Server::new(); - let payload = URL_SAFE_NO_PAD - .encode(br#"{"workspaces":["workbound"],"source":"database_api_token"}"#); - let jwt = format!("header.{payload}.sig"); - let mint = server - .mock("POST", "/o/token/") - .match_body(mockito::Matcher::UrlEncoded( - "grant_type".into(), - "api_token".into(), - )) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(format!( - r#"{{"access_token":"{jwt}","expires_in":300,"refresh_token":"r"}}"# - )) - .create(); - - let profile = mock_profile(&server.url(), Some("hd_dbtoken")); - let ids = api_key_workspace_ids(&profile); - mint.assert(); - assert_eq!(ids, vec!["workbound".to_string()]); - } - // --- api_key_authorized_workspaces tests --- #[test] @@ -759,118 +619,6 @@ mod tests { assert_eq!(result[0].name, "Bound WS"); } - // --- check_status tests --- - - #[test] - fn status_not_configured_when_no_key_no_session() { - let (_tmp, _guard) = with_temp_config_dir(); - let profile = mock_profile("http://localhost", None); - assert_eq!(check_status(&profile), AuthStatus::NotConfigured); - } - - #[test] - fn status_not_configured_when_placeholder_no_session() { - let (_tmp, _guard) = with_temp_config_dir(); - let profile = mock_profile("http://localhost", Some("PLACEHOLDER")); - assert_eq!(check_status(&profile), AuthStatus::NotConfigured); - } - - #[test] - fn status_authenticated_with_valid_session() { - let (_tmp, _guard) = with_temp_config_dir(); - save_test_session("valid-jwt"); - let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/workspaces") - .match_header("Authorization", "Bearer valid-jwt") - .with_status(200) - .with_body(r#"{"workspaces":[]}"#) - .create(); - - let profile = mock_profile(&server.url(), None); - assert_eq!(check_status(&profile), AuthStatus::Authenticated); - mock.assert(); - } - - #[test] - fn status_authenticated_via_api_token_fallback_when_no_session() { - // Realistic upgrade path: user has an api_key in config but no - // session.json yet. ensure_access_token must mint a JWT from - // the api_key, then check_status probes /workspaces with it. - let (_tmp, _guard) = with_temp_config_dir(); - let mut server = mockito::Server::new(); - let mint_mock = server - .mock("POST", "/o/token/") - .match_body(mockito::Matcher::UrlEncoded( - "grant_type".into(), - "api_token".into(), - )) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"access_token":"minted-jwt","expires_in":300,"refresh_token":"r"}"#) - .create(); - let probe_mock = server - .mock("GET", "/workspaces") - .match_header("Authorization", "Bearer minted-jwt") - .with_status(200) - .with_body(r#"{"workspaces":[]}"#) - .create(); - - let profile = mock_profile(&server.url(), Some("hd_xyz")); - assert_eq!(check_status(&profile), AuthStatus::Authenticated); - mint_mock.assert(); - probe_mock.assert(); - } - - #[test] - fn status_invalid_when_session_revoked_server_side() { - let (_tmp, _guard) = with_temp_config_dir(); - save_test_session("revoked-jwt"); - let mut server = mockito::Server::new(); - let mock = server.mock("GET", "/workspaces").with_status(401).create(); - - let profile = mock_profile(&server.url(), None); - assert_eq!(check_status(&profile), AuthStatus::Invalid(401)); - mock.assert(); - } - - #[test] - fn status_invalid_with_forbidden() { - let (_tmp, _guard) = with_temp_config_dir(); - save_test_session("jwt"); - let mut server = mockito::Server::new(); - let mock = server.mock("GET", "/workspaces").with_status(403).create(); - - let profile = mock_profile(&server.url(), None); - assert_eq!(check_status(&profile), AuthStatus::Invalid(403)); - mock.assert(); - } - - #[test] - fn status_invalid_when_api_token_rejected_no_session() { - // No session, and the api_key fallback is rejected by the mint - // endpoint — collapse to Invalid(401) so `auth status` shows - // the user they need to re-auth. - let (_tmp, _guard) = with_temp_config_dir(); - let mut server = mockito::Server::new(); - let mock = server.mock("POST", "/o/token/").with_status(401).create(); - - let profile = mock_profile(&server.url(), Some("hd_revoked")); - assert_eq!(check_status(&profile), AuthStatus::Invalid(401)); - mock.assert(); - } - - #[test] - fn status_connection_error_during_probe() { - let (_tmp, _guard) = with_temp_config_dir(); - save_test_session("jwt"); - let profile = mock_profile("http://127.0.0.1:1", None); - match check_status(&profile) { - AuthStatus::ConnectionError(_) => {} - other => panic!("expected ConnectionError, got {:?}", other), - } - } - // --- is_already_signed_in tests --- #[test] diff --git a/src/connections.rs b/src/commands/connections.rs similarity index 88% rename from src/connections.rs rename to src/commands/connections.rs index 240c607..aad8559 100644 --- a/src/connections.rs +++ b/src/commands/connections.rs @@ -1,6 +1,85 @@ -use crate::sdk::{Api, ApiError, block, block_with_wakeup, none_if_404}; +use crate::client::sdk::{Api, ApiError, block, block_with_wakeup, none_if_404}; use serde::{Deserialize, Serialize}; +/// Interactive `connections new` wizard. +pub mod interactive; + +/// Subcommands for `hotdata connections`. +#[derive(clap::Subcommand)] +pub enum ConnectionsCommands { + /// Interactively create a new connection + New, + + /// List all connections for a workspace + List { + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Create a new connection, or list/inspect available connection types + Create { + #[command(subcommand)] + command: Option, + + /// Connection name + #[arg(long)] + name: Option, + + /// Connection source type (e.g. postgres, mysql, snowflake) + #[arg(long = "type")] + source_type: Option, + + /// Connection config as a JSON object + #[arg(long)] + config: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Refresh a connection's schema or data + Refresh { + /// Connection ID + connection_id: String, + + /// Refresh data instead of schema metadata + #[arg(long)] + data: bool, + + /// Narrow refresh to a specific schema (requires --table for data refresh) + #[arg(long)] + schema: Option, + + /// Narrow refresh to a specific table (requires --schema) + #[arg(long)] + table: Option, + + /// Submit as a background job (only valid with --data) + #[arg(long)] + r#async: bool, + + /// Include uncached tables in connection-wide data refresh (only with --data, no --table) + #[arg(long = "include-uncached")] + include_uncached: bool, + }, +} + +/// Subcommands for `hotdata connections create`. +#[derive(clap::Subcommand)] +pub enum ConnectionsCreateCommands { + /// List available connection types, or get details for a specific type + List { + /// Connection type name (e.g. postgres, mysql); omit to list all + name: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, +} + #[derive(Deserialize, Serialize)] struct HealthResponse { #[allow(dead_code)] @@ -132,7 +211,7 @@ pub fn types_list(workspace_id: &str, format: &str) { .iter() .map(|ct| vec![ct.name.clone(), ct.label.clone()]) .collect(); - crate::table::print(&["NAME", "LABEL"], &rows); + crate::output::table::print(&["NAME", "LABEL"], &rows); } } _ => unreachable!(), @@ -226,8 +305,9 @@ pub fn try_resolve_connection_id(api: &Api, name_or_id: &str) -> Result Result unreachable!(), @@ -607,7 +687,7 @@ pub fn refresh( #[cfg(test)] mod tests { use super::*; - use crate::sdk::Api; + use crate::client::sdk::Api; /// A bare connection list with one entry named `good`. fn one_connection_body() -> &'static str { diff --git a/src/connections_new.rs b/src/commands/connections/interactive.rs similarity index 99% rename from src/connections_new.rs rename to src/commands/connections/interactive.rs index feb1d05..b3a46e1 100644 --- a/src/connections_new.rs +++ b/src/commands/connections/interactive.rs @@ -2,7 +2,7 @@ use inquire::validator::Validation; use inquire::{Confirm, Password, Select, Text}; use serde_json::{Map, Number, Value}; -use crate::sdk::{Api, ApiError, block, block_with_wakeup}; +use crate::client::sdk::{Api, ApiError, block, block_with_wakeup}; // ── SDK helpers ───────────────────────────────────────────────────────────── diff --git a/src/context.rs b/src/commands/context.rs similarity index 87% rename from src/context.rs rename to src/commands/context.rs index 768b8ff..2956a9a 100644 --- a/src/context.rs +++ b/src/commands/context.rs @@ -1,6 +1,6 @@ //! Database context: `/v1/databases/{id}/context` sync with `./{NAME}.md` in the current directory. -use crate::sdk::{Api, ApiError}; +use crate::client::sdk::{Api, ApiError}; use crossterm::style::Stylize; use hotdata::models::{DatabaseContextEntry, UpsertDatabaseContextRequest}; use std::collections::HashSet; @@ -9,6 +9,51 @@ use std::io::Write; use std::path::PathBuf; use std::sync::LazyLock; +/// Subcommands for `hotdata context`. +#[derive(clap::Subcommand)] +pub enum ContextCommands { + /// List named contexts in the workspace + List { + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + + /// Only include names starting with this prefix (case-sensitive) + #[arg(long)] + prefix: Option, + }, + + /// Print context content to stdout + Show { + /// Context name (same rules as a SQL table identifier; local file is .md). A trailing `.md` is ignored (e.g. `USER.md` → `USER`). + name: String, + }, + + /// Download context from the database to ./.md + Pull { + /// Context name (trailing `.md` ignored, e.g. `USER.md` → `USER`) + name: String, + + /// Overwrite ./.md if it already exists + #[arg(long)] + force: bool, + + /// Print the target path and size only; do not write a file + #[arg(long)] + dry_run: bool, + }, + + /// Upload ./.md to the database as named context + Push { + /// Context name (trailing `.md` ignored, e.g. `USER.md` → `USER`; reads `./USER.md`) + name: String, + + /// Print what would be sent; do not POST + #[arg(long)] + dry_run: bool, + }, +} + /// Matches runtimedb `MAX_TABLE_NAME_LENGTH` / `validate_table_name` rules for context keys. pub const MAX_CONTEXT_NAME_LEN: usize = 128; @@ -121,8 +166,8 @@ fn local_md_path(name: &str) -> PathBuf { /// Fetch a named context document. Returns `Ok(None)` on 404 (not found); /// exits the process on any other error, matching the old behavior. fn fetch_context(api: &Api, database_id: &str, name: &str) -> Option { - let result = crate::sdk::block(api.client().database_context().get(database_id, name)); - match crate::sdk::none_if_404(result) { + let result = crate::client::sdk::block(api.client().database_context().get(database_id, name)); + match crate::client::sdk::none_if_404(result) { Ok(Some(resp)) => Some(*resp.context), Ok(None) => None, Err(e) => e.exit(), @@ -131,7 +176,7 @@ fn fetch_context(api: &Api, database_id: &str, name: &str) -> Option, format: &str) { let api = Api::new(Some(workspace_id)); - let body = crate::sdk::block_with_wakeup( + let body = crate::client::sdk::block_with_wakeup( &api, "Loading context…", api.client().database_context().list(database_id), @@ -160,7 +205,7 @@ pub fn list(workspace_id: &str, database_id: &str, prefix: Option<&str>, format: ] }) .collect(); - crate::table::print(&["NAME", "UPDATED", "CHARS"], &table_rows); + crate::output::table::print(&["NAME", "UPDATED", "CHARS"], &table_rows); } } _ => unreachable!(), @@ -299,7 +344,7 @@ pub fn push(workspace_id: &str, database_id: &str, name: &str, dry_run: bool) { let api = Api::new(Some(workspace_id)); let request = UpsertDatabaseContextRequest::new(content, name.clone()); - let resp = match crate::sdk::block_with_wakeup( + let resp = match crate::client::sdk::block_with_wakeup( &api, "Pushing context…", api.client().database_context().upsert(database_id, request), diff --git a/src/databases.rs b/src/commands/databases.rs similarity index 89% rename from src/databases.rs rename to src/commands/databases.rs index bd521f1..b0f2754 100644 --- a/src/databases.rs +++ b/src/commands/databases.rs @@ -1,8 +1,236 @@ -use crate::sdk::{Api, ApiError, block, block_with_wakeup, none_if_404}; +use crate::client::sdk::{Api, ApiError, block, block_with_wakeup, none_if_404}; use indicatif::{ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use std::path::Path; +/// Subcommands for `hotdata databases`. +#[derive(clap::Subcommand)] +pub enum DatabasesCommands { + /// List managed databases in the workspace + List { + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Show details for a specific managed database + Show { + /// Database name or ID + name_or_id: String, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Create a new managed database + Create { + /// Human-readable display name for the database (e.g. "Sales reporting"). + #[arg(long)] + name: Option, + + /// SQL catalog alias used in queries: SELECT … FROM .schema.table. + /// Must be [a-z_][a-z0-9_]*, globally unique. + #[arg(long)] + catalog: Option, + + /// Default schema for bare `--table` entries (default: public). + /// Use dot notation in `--table` to target a different schema directly, + /// e.g. `--table raw.raw_orders` always goes into the "raw" schema. + #[arg(long, default_value = "public")] + schema: String, + + /// Table to declare up front (repeatable). Accepts bare names or + /// `schema.table` dot notation to span multiple schemas in one command: + /// --table orders --table raw.raw_orders --table raw.raw_customers + #[arg(long = "table")] + tables: Vec, + + /// When the database expires. Accepts a relative duration (e.g. 24h, 7d, 90m) + /// or an RFC 3339 timestamp. Omitting means no expiry. + #[arg(long)] + expires_at: Option, + + /// Attach a connection as a queryable catalog on the new database (repeatable). + /// Accepts a connection name or id, optionally `connection=alias` to set the + /// SQL alias it answers to: `--attach github --attach salesdb=sales`. + #[arg(long = "attach")] + attach: Vec, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Attach a connection as a queryable catalog on a managed database. + /// + /// A `query` runs inside one managed database; attaching a connection makes + /// its live tables visible in that database's scope, so you can join across + /// sources in a single query without exporting data. Reachable in SQL as + /// `..
`, or `..
` when + /// `--alias` is omitted. + Attach { + /// Connection name or id to attach (e.g. `github`) + connection: String, + + /// Database id, catalog, or name to attach into (defaults to the current database) + #[arg(long, short = 'd')] + database: Option, + + /// Alias the catalog answers to in SQL. Defaults to the connection's name. + #[arg(long)] + alias: Option, + }, + + /// Detach a previously attached connection catalog from a managed database. + Detach { + /// Connection name or id to detach + connection: String, + + /// Database id, catalog, or name to detach from (defaults to the current database) + #[arg(long, short = 'd')] + database: Option, + }, + + /// Set the current database (used by default when no database is specified) + Set { + /// Database id + id: String, + }, + + /// Clear the current database + Unset, + + /// Delete a managed database and its tables + Delete { + /// Database name or connection ID + name_or_id: String, + }, + + /// Load a parquet file into a managed database table + Load { + /// SQL catalog alias of the target database (e.g. `--catalog airbnb`) + #[arg(long)] + catalog: String, + + /// Schema to load into (default: public) + #[arg(long, default_value = "public")] + schema: String, + + /// Table name to load into + #[arg(long)] + table: String, + + /// Path to a local parquet file to upload and load + #[arg(long, conflicts_with_all = ["upload_id", "url"])] + file: Option, + + /// URL of a remote parquet file to download and load + #[arg(long, conflicts_with_all = ["file", "upload_id"])] + url: Option, + + /// Use a previously staged upload ID from `POST /v1/uploads` instead of uploading + #[arg(long, conflicts_with_all = ["file", "url"])] + upload_id: Option, + }, + + /// Manage tables inside a managed database + Tables { + /// Database id or name — shorthand for `tables list` when no subcommand is given + database: Option, + + #[command(subcommand)] + command: Option, + }, + + /// Run a command with a database-scoped token. Creates a new database unless --database is given. + Run { + /// Existing database id to scope the token to (omit to auto-create a database) + #[arg(long)] + database: Option, + + /// Name for the auto-created database (only used when --database is omitted) + #[arg(long)] + name: Option, + + /// Schema for tables declared in the auto-created database (default: public) + #[arg(long, default_value = "public")] + schema: String, + + /// Table to declare in the auto-created database (repeatable) + #[arg(long = "table")] + tables: Vec, + + /// When the auto-created database expires. Accepts a relative duration + /// (e.g. 24h, 7d, 90m) or an RFC 3339 timestamp. Defaults to 24h when omitted. + #[arg(long)] + expires_at: Option, + + /// Command to execute (everything after `--`) + #[arg(trailing_var_arg = true, required = true)] + cmd: Vec, + }, +} + +/// Subcommands for `hotdata databases tables`. +#[derive(clap::Subcommand)] +pub enum DatabaseTablesCommands { + /// List tables in a managed database + List { + /// Database id or name (defaults to current database) + #[arg(long)] + database: Option, + + /// Filter by schema name + #[arg(long)] + schema: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Load a parquet file into a table (creates or replaces the table) + Load { + /// Database id or name (defaults to current database) + #[arg(long)] + database: Option, + + /// Table name + table: String, + + /// Schema name (default: public) + #[arg(long, default_value = "public")] + schema: String, + + /// Path to a local parquet file to upload and load + #[arg(long, conflicts_with_all = ["upload_id", "url"])] + file: Option, + + /// URL of a remote parquet file to download and load + #[arg(long, conflicts_with_all = ["file", "upload_id"])] + url: Option, + + /// Use a previously staged upload ID from `POST /v1/uploads` instead of uploading + #[arg(long, conflicts_with_all = ["file", "url"])] + upload_id: Option, + }, + + /// Delete a table from a managed database + Delete { + /// Database id or name (defaults to current database) + #[arg(long)] + database: Option, + + /// Table name + table: String, + + /// Schema name (default: public) + #[arg(long, default_value = "public")] + schema: String, + }, +} + const DEFAULT_SCHEMA: &str = "public"; /// CLI output shape for `databases list` rows. A curated, stably-ordered view @@ -512,7 +740,7 @@ fn collect_tables(api: &Api, connection_id: &str, schema: Option<&str>) -> Vec = None; loop { - let resp = crate::sdk::block(api.client().information_schema().get( + let resp = crate::client::sdk::block(api.client().information_schema().get( Some(connection_id), schema, None, @@ -572,7 +800,7 @@ pub fn list(workspace_id: &str, format: &str) { ] }) .collect(); - crate::table::print(&["", "ID", "NAME"], &rows); + crate::output::table::print(&["", "ID", "NAME"], &rows); } } _ => unreachable!(), @@ -653,7 +881,7 @@ pub fn attach(workspace_id: &str, connection: &str, database: Option<&str>, alia // exits with the resolver's message, and an API failure goes through // `ApiError::exit`, which upgrades a masked 401/403 into the re-auth hint. // (The non-fatal `create --attach` loop uses `attach_connection` instead.) - let connection_id = crate::connections::resolve_connection_id(&api, connection); + let connection_id = crate::commands::connections::resolve_connection_id(&api, connection); send_attach(&api, &db.id, connection_id, alias).unwrap_or_else(|e| e.exit()); match alias { @@ -699,7 +927,7 @@ pub fn detach(workspace_id: &str, connection: &str, database: Option<&str>) { .iter() .find(|a| a.alias.as_deref() == Some(connection)) .map(|a| a.connection_id.clone()) - .unwrap_or_else(|| crate::connections::resolve_connection_id(&api, connection)); + .unwrap_or_else(|| crate::commands::connections::resolve_connection_id(&api, connection)); block( api.client() @@ -746,7 +974,7 @@ fn mint_database_token(api: &Api, database_id: &str) -> DatabaseTokenResponse { // The old typed `api.post` routed non-success through `fail_response`, // which upgrades a masked 401/403/404 into the re-auth hint. Reproduce // that via the seam's auth-aware exit. - crate::sdk::ApiError::Status { + crate::client::sdk::ApiError::Status { status, body: resp_body, } @@ -795,7 +1023,7 @@ pub fn run( .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let session = crate::database_session::DatabaseSession { + let session = crate::client::database_session::DatabaseSession { access_token: db_jwt.clone(), refresh_token: db_refresh.clone(), database_id: db_id.clone(), @@ -803,7 +1031,7 @@ pub fn run( access_expires_at: now + resp.expires_in, refresh_expires_at: now + resp.refresh_expires_in, }; - if let Err(e) = crate::database_session::save(&session) { + if let Err(e) = crate::client::database_session::save(&session) { eprintln!("warning: could not persist database session: {e}"); } @@ -874,7 +1102,7 @@ fn attach_connection( connection: &str, alias: Option<&str>, ) -> Result { - let connection_id = crate::connections::try_resolve_connection_id(api, connection)?; + let connection_id = crate::commands::connections::try_resolve_connection_id(api, connection)?; send_attach(api, database_id, connection_id.clone(), alias).map_err(|e| e.message())?; Ok(connection_id) } @@ -1005,7 +1233,7 @@ pub fn set(workspace_id: &str, id: &str) { // allow-list), so skip the check for it and save the id directly. let is_database_api_token = crate::config::load("default") .ok() - .and_then(|profile| crate::auth::api_key_jwt_source(&profile)) + .and_then(|profile| crate::client::credentials::api_key_jwt_source(&profile)) .as_deref() == Some("database_api_token"); if !is_database_api_token { @@ -1097,7 +1325,7 @@ pub fn tables_list(workspace_id: &str, database: Option<&str>, schema: Option<&s ] }) .collect(); - crate::table::print(&["TABLE", "SYNCED", "LAST_SYNC"], &table_rows); + crate::output::table::print(&["TABLE", "SYNCED", "LAST_SYNC"], &table_rows); } } _ => unreachable!(), @@ -1119,7 +1347,8 @@ pub fn tables_load( // connection-scoped managed endpoints (all outside its allow-list). Route it // through the database-scoped endpoints, addressed by database id. if let Ok(profile) = crate::config::load("default") - && crate::auth::api_key_jwt_source(&profile).as_deref() == Some("database_api_token") + && crate::client::credentials::api_key_jwt_source(&profile).as_deref() + == Some("database_api_token") { tables_load_database_scoped(workspace_id, database, table, schema, file, url, upload_id); return; diff --git a/src/embedding_providers.rs b/src/commands/embedding_providers.rs similarity index 75% rename from src/embedding_providers.rs rename to src/commands/embedding_providers.rs index c5525d7..1eaf5a7 100644 --- a/src/embedding_providers.rs +++ b/src/commands/embedding_providers.rs @@ -1,9 +1,93 @@ -use crate::sdk::Api; +use crate::client::sdk::Api; use hotdata::models::{ CreateEmbeddingProviderRequest, EmbeddingProviderResponse, UpdateEmbeddingProviderRequest, }; use serde::{Deserialize, Serialize}; +/// Subcommands for `hotdata embedding-providers`. +#[derive(clap::Subcommand)] +pub enum EmbeddingProvidersCommands { + /// List embedding providers + List { + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Show details for a specific embedding provider + Get { + /// Provider ID + id: String, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Create a new embedding provider + Create { + /// Provider name (must be unique within the workspace) + #[arg(long)] + name: String, + + /// Provider type ("local" or "service") + #[arg(long, value_parser = ["local", "service"])] + provider_type: String, + + /// Provider-specific config as a JSON string (model, base_url, dimensions, etc.) + #[arg(long)] + config: Option, + + /// The provider's own API key (e.g. an OpenAI sk-... key). Auto-creates a + /// managed secret. Mutually exclusive with --secret-name. Named + /// `--provider-api-key` to pair with `--provider-type` and to avoid colliding + /// with the global `--api-key` (Hotdata auth) flag. + #[arg(long = "provider-api-key", conflicts_with = "secret_name")] + provider_api_key: Option, + + /// Reference an existing secret by name (for service providers) + #[arg(long)] + secret_name: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Update an embedding provider's name, config, or secret + Update { + /// Provider ID + id: String, + + /// New name + #[arg(long)] + name: Option, + + /// New config as a JSON string + #[arg(long)] + config: Option, + + /// New provider API key (replaces or creates the managed secret). + /// See `embedding-providers create --provider-api-key` for naming rationale. + #[arg(long = "provider-api-key", conflicts_with = "secret_name")] + provider_api_key: Option, + + /// New secret name to reference + #[arg(long)] + secret_name: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Delete an embedding provider + Delete { + /// Provider ID + id: String, + }, +} + #[derive(Deserialize, Serialize)] struct Provider { id: String, @@ -44,7 +128,7 @@ fn parse_config(raw: Option<&str>) -> Option { pub fn list(workspace_id: &str, format: &str) { let api = Api::new(Some(workspace_id)); - let providers: Vec = crate::sdk::block_with_wakeup( + let providers: Vec = crate::client::sdk::block_with_wakeup( &api, "Loading embedding providers…", api.client().embedding_providers().list(), @@ -76,7 +160,7 @@ pub fn list(workspace_id: &str, format: &str) { ] }) .collect(); - crate::table::print(&["ID", "NAME", "TYPE", "SOURCE", "SECRET"], &rows); + crate::output::table::print(&["ID", "NAME", "TYPE", "SOURCE", "SECRET"], &rows); } _ => unreachable!(), } @@ -84,7 +168,7 @@ pub fn list(workspace_id: &str, format: &str) { pub fn get(workspace_id: &str, id: &str, format: &str) { let api = Api::new(Some(workspace_id)); - let p: Provider = crate::sdk::block_with_wakeup( + let p: Provider = crate::client::sdk::block_with_wakeup( &api, "Loading embedding provider…", api.client().embedding_providers().get(id), @@ -136,7 +220,7 @@ pub fn create( req.secret_name = Some(Some(s.to_string())); } - let resp = crate::sdk::block_with_wakeup( + let resp = crate::client::sdk::block_with_wakeup( &api, "Creating embedding provider…", api.client().embedding_providers().create(req), @@ -192,7 +276,7 @@ pub fn update( req.secret_name = Some(Some(s.to_string())); } - let resp = crate::sdk::block_with_wakeup( + let resp = crate::client::sdk::block_with_wakeup( &api, "Updating embedding provider…", api.client().embedding_providers().update(id, req), @@ -218,7 +302,7 @@ pub fn update( pub fn delete(workspace_id: &str, id: &str) { use crossterm::style::Stylize; let api = Api::new(Some(workspace_id)); - crate::sdk::block_with_wakeup( + crate::client::sdk::block_with_wakeup( &api, "Deleting embedding provider…", api.client().embedding_providers().delete(id), diff --git a/src/indexes.rs b/src/commands/indexes.rs similarity index 93% rename from src/indexes.rs rename to src/commands/indexes.rs index d9dfa51..48e77ef 100644 --- a/src/indexes.rs +++ b/src/commands/indexes.rs @@ -1,10 +1,106 @@ -use crate::databases; -use crate::sdk::{Api, block, block_with_wakeup, none_if_404}; +use crate::client::sdk::{Api, block, block_with_wakeup, none_if_404}; +use crate::commands::databases; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::ops::ControlFlow; +/// Subcommands for `hotdata indexes`. +#[derive(clap::Subcommand)] +pub enum IndexesCommands { + /// List indexes (defaults to the whole workspace; narrow with filters) + List { + /// Filter by connection ID + #[arg(long, short = 'c')] + connection_id: Option, + + /// Filter by schema name + #[arg(long)] + schema: Option, + + /// Filter by table name + #[arg(long)] + table: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Create an index on a table. + Create { + /// SQL catalog alias of the target database (e.g. `--catalog airbnb`) + #[arg(long)] + catalog: Option, + + /// Schema name (default: public) + #[arg(long, default_value = "public")] + schema: String, + + /// Table name to index + #[arg(long)] + table: Option, + + /// Column(s) to index (comma-separated) + #[arg(long)] + column: Option, + + /// Index name (derived from table, columns, and type if omitted) + #[arg(long)] + name: Option, + + /// Index type — required (no default; choose deliberately) + #[arg(long, value_parser = ["sorted", "bm25", "vector"])] + r#type: String, + + /// Distance metric for vector indexes + #[arg(long, value_parser = ["l2", "cosine", "dot"])] + metric: Option, + + /// Create as a background job + #[arg(long)] + r#async: bool, + + /// Embedding provider ID — when set on a vector index over a text column, + /// embeddings are generated automatically. Defaults to first system provider if omitted. + #[arg(long = "embedding-provider-id")] + embedding_provider_id: Option, + + /// Override embedding output dimensions (vector indexes with auto-embedding only) + #[arg(long)] + dimensions: Option, + + /// Custom name for the generated embedding column (defaults to `{column}_embedding`) + #[arg(long = "output-column")] + output_column: Option, + + /// Human-readable description of the embedding (e.g. "product titles") + #[arg(long)] + description: Option, + }, + + /// Delete an index from a table + /// + /// Pass connection scope: --connection-id + --schema + --table. + Delete { + /// Connection ID (use with --schema and --table) + #[arg(long, short = 'c', requires_all = ["schema", "table"])] + connection_id: Option, + + /// Schema name (requires --connection-id) + #[arg(long, requires = "connection_id")] + schema: Option, + + /// Table name (requires --connection-id) + #[arg(long, requires = "connection_id")] + table: Option, + + /// Index name + #[arg(long)] + name: String, + }, +} + #[derive(Deserialize, Serialize)] struct Index { index_name: String, @@ -370,7 +466,7 @@ pub fn infer_for_search( let api = Api::new(Some(workspace_id)); // Resolve connection name → ID (falls back to managed database catalog lookup) - let connection_id = crate::connections::resolve_connection_id(&api, connection_name); + let connection_id = crate::commands::connections::resolve_connection_id(&api, connection_name); // Fetch indexes for this table let indexes = list_one_table(&api, &connection_id, schema, table); @@ -434,7 +530,7 @@ pub fn list( ] }) .collect(); - crate::table::print( + crate::output::table::print( &[ "TABLE", "NAME", "TYPE", "COLUMNS", "METRIC", "STATUS", "CREATED", ], @@ -454,7 +550,7 @@ pub fn list( ] }) .collect(); - crate::table::print( + crate::output::table::print( &["NAME", "TYPE", "COLUMNS", "METRIC", "STATUS", "CREATED"], &table_rows, ); @@ -607,8 +703,8 @@ pub fn delete(workspace_id: &str, scope: IndexScope<'_>, index_name: &str) { if let Err(e) = result { let body = match e { - crate::sdk::ApiError::Status { body, .. } => body, - crate::sdk::ApiError::Transport(msg) => msg, + crate::client::sdk::ApiError::Status { body, .. } => body, + crate::client::sdk::ApiError::Transport(msg) => msg, }; eprintln!("{}", crate::util::api_error(body).red()); std::process::exit(1); diff --git a/src/jobs.rs b/src/commands/jobs.rs similarity index 84% rename from src/jobs.rs rename to src/commands/jobs.rs index 1d4cb3f..49d2b99 100644 --- a/src/jobs.rs +++ b/src/commands/jobs.rs @@ -1,7 +1,38 @@ -use crate::sdk::Api; +use crate::client::sdk::Api; use hotdata::models::{JobStatusResponse, JobType}; use serde::{Deserialize, Serialize}; +/// Subcommands for `hotdata jobs`. +#[derive(clap::Subcommand)] +pub enum JobsCommands { + /// List background jobs (shows active jobs by default) + List { + /// Filter by job type + #[arg(long, value_parser = ["data_refresh_table", "data_refresh_connection", "create_index", "managed_load"])] + job_type: Option, + + /// Filter by status + #[arg(long, value_parser = ["pending", "running", "succeeded", "partially_succeeded", "failed"])] + status: Option, + + /// Show all jobs, not just active ones + #[arg(long)] + all: bool, + + /// Maximum number of results (default: 50) + #[arg(long)] + limit: Option, + + /// Pagination offset + #[arg(long)] + offset: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, +} + #[derive(Deserialize, Serialize)] struct Job { id: String, @@ -48,10 +79,13 @@ fn parse_job_type(s: &str) -> Option { pub fn get(job_id: &str, workspace_id: &str, format: &str) { let api = Api::new(Some(workspace_id)); - let job: Job = - crate::sdk::block_with_wakeup(&api, "Loading job…", api.client().jobs().get(job_id)) - .unwrap_or_else(|e| e.exit()) - .into(); + let job: Job = crate::client::sdk::block_with_wakeup( + &api, + "Loading job…", + api.client().jobs().get(job_id), + ) + .unwrap_or_else(|e| e.exit()) + .into(); match format { "json" => println!("{}", serde_json::to_string_pretty(&job).unwrap()), @@ -142,7 +176,7 @@ fn fetch_jobs( limit: Option, offset: Option, ) -> Vec { - let resp = crate::sdk::block(api.client().jobs().list( + let resp = crate::client::sdk::block(api.client().jobs().list( job_type.and_then(parse_job_type), status, limit.map(|l| l as i32), @@ -199,7 +233,7 @@ pub fn list( ] }) .collect(); - crate::table::print( + crate::output::table::print( &["ID", "TYPE", "STATUS", "ATTEMPTS", "CREATED", "COMPLETED"], &rows, ); diff --git a/src/queries.rs b/src/commands/queries.rs similarity index 93% rename from src/queries.rs rename to src/commands/queries.rs index cdbde3e..29ac339 100644 --- a/src/queries.rs +++ b/src/commands/queries.rs @@ -1,8 +1,31 @@ -use crate::sdk::Api; +use crate::client::sdk::Api; use crossterm::style::Stylize; use hotdata::models::QueryRunInfo; use serde::Serialize; +/// Subcommands for `hotdata queries`. +#[derive(clap::Subcommand)] +pub enum QueriesCommands { + /// List query runs + List { + /// Maximum number of results + #[arg(long, default_value_t = 20)] + limit: u32, + + /// Pagination cursor from a previous response + #[arg(long)] + cursor: Option, + + /// Filter by status (comma-separated, e.g. running,failed) + #[arg(long)] + status: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, +} + const SQL_KEYWORDS: &[&str] = &[ "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", "AS", "ON", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", @@ -178,7 +201,7 @@ pub fn list( ) { let api = Api::new(Some(workspace_id)); - let resp = crate::sdk::block_with_wakeup( + let resp = crate::client::sdk::block_with_wakeup( &api, "Loading query runs…", api.client() @@ -217,7 +240,7 @@ pub fn list( ] }) .collect(); - crate::table::print( + crate::output::table::print( &["ID", "STATUS", "CREATED", "MS", "ROWS", "RESULT_ID", "SQL"], &rows, ); @@ -236,7 +259,7 @@ pub fn list( pub fn get(query_run_id: &str, workspace_id: &str, format: &str) { let api = Api::new(Some(workspace_id)); - let run: QueryRun = crate::sdk::block_with_wakeup( + let run: QueryRun = crate::client::sdk::block_with_wakeup( &api, "Loading query run…", api.client().query_runs().get(query_run_id), diff --git a/src/query.rs b/src/commands/query.rs similarity index 97% rename from src/query.rs rename to src/commands/query.rs index a203e4d..3a97b48 100644 --- a/src/query.rs +++ b/src/commands/query.rs @@ -1,7 +1,19 @@ -use crate::sdk::{Api, ApiError}; +use crate::client::sdk::{Api, ApiError}; use serde::Deserialize; use serde_json::Value; +/// Subcommands for `hotdata query`. +#[derive(clap::Subcommand)] +pub enum QueryCommands { + /// Check the status of a running query and retrieve results. + /// Exit codes: 0 = succeeded, 1 = failed, 2 = still running (poll again), + /// 3 = succeeded but the result is an incomplete/truncated preview + Status { + /// Query run ID + id: String, + }, +} + #[derive(Deserialize)] pub struct QueryResponse { pub result_id: Option, @@ -79,7 +91,7 @@ fn value_to_string(v: &Value) -> String { Value::Number(n) => n.to_string(), Value::String(s) => s.clone(), Value::Array(arr) => { - let (formatted, count) = crate::table::truncate_array(arr); + let (formatted, count) = crate::output::table::truncate_array(arr); match count { Some(n) => format!("{formatted} ({n} items)"), None => formatted, @@ -425,7 +437,7 @@ pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &s request.r#async = Some(true); request.async_after_ms = Some(Some(1000)); - let outcome = crate::sdk::block_with_wakeup( + let outcome = crate::client::sdk::block_with_wakeup( &api, "running query...", api.client().submit_query(request, database), @@ -459,8 +471,8 @@ pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &s loop { // Drive the poll loop ourselves to preserve the 5-minute deadline and // 500ms cadence (NOT the SDK's PollConfig defaults). - let run = - crate::sdk::block(api.client().query_runs().get(run_id)).unwrap_or_else(|e| e.exit()); + let run = crate::client::sdk::block(api.client().query_runs().get(run_id)) + .unwrap_or_else(|e| e.exit()); match run.status.as_str() { "succeeded" => { spinner.finish_and_clear(); @@ -516,8 +528,8 @@ pub fn execute(sql: &str, workspace_id: &str, database: Option<&str>, format: &s pub fn poll(query_run_id: &str, workspace_id: &str, format: &str) { let api = Api::new(Some(workspace_id)); - let run = - crate::sdk::block(api.client().query_runs().get(query_run_id)).unwrap_or_else(|e| e.exit()); + let run = crate::client::sdk::block(api.client().query_runs().get(query_run_id)) + .unwrap_or_else(|e| e.exit()); match run.status.as_str() { "succeeded" => { @@ -648,7 +660,7 @@ pub fn print_result(result: &QueryResponse, format: &str) { } } "table" => { - crate::table::print_json(&result.columns, &result.rows); + crate::output::table::print_json(&result.columns, &result.rows); use crossterm::style::Stylize; let footer = table_footer(result); // Loud (red) when the preview is incomplete so it can't be mistaken @@ -674,7 +686,7 @@ pub fn print_result(result: &QueryResponse, format: &str) { #[cfg(test)] mod tests { use super::*; - use crate::sdk::Api; + use crate::client::sdk::Api; use std::sync::Arc; /// A truncated inline 200: one preview row standing in for a larger result. diff --git a/src/results.rs b/src/commands/results.rs similarity index 84% rename from src/results.rs rename to src/commands/results.rs index f7f13f6..b3cb48a 100644 --- a/src/results.rs +++ b/src/commands/results.rs @@ -1,7 +1,26 @@ -use crate::sdk::Api; +use crate::client::sdk::Api; use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; +/// Subcommands for `hotdata results`. +#[derive(clap::Subcommand)] +pub enum ResultsCommands { + /// List stored query results + List { + /// Maximum number of results (default: 100, max: 1000) + #[arg(long)] + limit: Option, + + /// Pagination offset + #[arg(long)] + offset: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, +} + #[derive(Deserialize, Serialize)] struct ResultEntry { id: String, @@ -93,7 +112,7 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: headers.push("EXPIRES"); } - crate::table::print(&headers, &rows); + crate::output::table::print(&headers, &rows); } if body.has_more { let next = offset.unwrap_or(0) + body.count as u32; @@ -113,6 +132,6 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: pub fn get(result_id: &str, workspace_id: &str, format: &str) { let api = Api::new(Some(workspace_id)); - let result = crate::query::fetch_arrow_result(&api, result_id); - crate::query::print_result(&result, format); + let result = crate::commands::query::fetch_arrow_result(&api, result_id); + crate::commands::query::print_result(&result, format); } diff --git a/src/skill.rs b/src/commands/skill.rs similarity index 98% rename from src/skill.rs rename to src/commands/skill.rs index 1fafad2..8785d89 100644 --- a/src/skill.rs +++ b/src/commands/skill.rs @@ -4,6 +4,21 @@ use semver::Version; use std::fs; use std::path::PathBuf; +/// Subcommands for `hotdata skills`. +#[derive(clap::Subcommand)] +pub enum SkillCommands { + /// Install or update the hotdata skill into agent directories + Install { + /// Install into the current project directory instead of globally + #[arg(long)] + project: bool, + }, + /// Show the installation status of the hotdata skill + Status, + /// List installed skills and their versions (alias for status) + List, +} + const REPO: &str = "hotdata-dev/hotdata-cli"; const PRIMARY_SKILL_NAME: &str = "hotdata"; const SKILL_NAMES: &[&str] = &[ diff --git a/src/tables.rs b/src/commands/tables.rs similarity index 77% rename from src/tables.rs rename to src/commands/tables.rs index 525d1d0..a708c17 100644 --- a/src/tables.rs +++ b/src/commands/tables.rs @@ -1,7 +1,42 @@ -use crate::sdk::Api; +use crate::client::sdk::Api; use hotdata::models::TableInfo; use serde::Serialize; +/// Subcommands for `hotdata tables`. +#[derive(clap::Subcommand)] +pub enum TablesCommands { + /// List all tables in a workspace + List { + /// Workspace ID (defaults to first workspace from login) + #[arg(long, short = 'w')] + workspace_id: Option, + + /// Filter by connection ID (also enables column output) + #[arg(long, short = 'c')] + connection_id: Option, + + /// Filter by schema name (supports % wildcards) + #[arg(long)] + schema: Option, + + /// Filter by table name (supports % wildcards) + #[arg(long)] + table: Option, + + /// Maximum number of results to return + #[arg(long)] + limit: Option, + + /// Pagination cursor from a previous response + #[arg(long)] + cursor: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, +} + #[derive(Serialize)] struct Column { name: String, @@ -42,7 +77,7 @@ pub fn list( // the old behavior (include_columns=true iff connection_id is set). let include_columns = connection_id.map(|_| true); - let body = crate::sdk::block_with_wakeup( + let body = crate::client::sdk::block_with_wakeup( &api, "Loading tables…", api.client().information_schema().get( @@ -99,7 +134,10 @@ pub fn list( }) }) .collect(); - crate::table::print(&["TABLE", "COLUMN", "DATA_TYPE", "NULLABLE"], &rows); + crate::output::table::print( + &["TABLE", "COLUMN", "DATA_TYPE", "NULLABLE"], + &rows, + ); } } _ => unreachable!(), @@ -136,7 +174,7 @@ pub fn list( ] }) .collect(); - crate::table::print(&["TABLE", "SYNCED", "LAST_SYNC"], &rows); + crate::output::table::print(&["TABLE", "SYNCED", "LAST_SYNC"], &rows); } } _ => unreachable!(), diff --git a/src/update.rs b/src/commands/update.rs similarity index 99% rename from src/update.rs rename to src/commands/update.rs index b5c255e..18ddc56 100644 --- a/src/update.rs +++ b/src/commands/update.rs @@ -309,7 +309,7 @@ fn update_to(latest: &Version) -> Result<(), String> { // from `latest` (not CURRENT_VERSION) because the old binary is still // running at this point — we want the skills for the version we just // downloaded, not the one we replaced. - crate::skill::install_for_version(latest); + crate::commands::skill::install_for_version(latest); // Bust the cache so the notice clears on the next run. write_cache(&UpdateCheckCache { diff --git a/src/usage.rs b/src/commands/usage.rs similarity index 97% rename from src/usage.rs rename to src/commands/usage.rs index 5590717..931ca9d 100644 --- a/src/usage.rs +++ b/src/commands/usage.rs @@ -1,4 +1,4 @@ -use crate::sdk::Api; +use crate::client::sdk::Api; use serde::{Deserialize, Serialize}; /// CLI output shape for `usage`, mapped from the `/v1/usage` @@ -59,7 +59,7 @@ pub fn usage(workspace_id: &str, since: Option<&str>, format: &str) { .unwrap_or_else(|| "-".to_string()), ], ]; - crate::table::print(&["METRIC", "VALUE"], &rows); + crate::output::table::print(&["METRIC", "VALUE"], &rows); } } } diff --git a/src/workspace.rs b/src/commands/workspace.rs similarity index 86% rename from src/workspace.rs rename to src/commands/workspace.rs index b60ba78..2af03aa 100644 --- a/src/workspace.rs +++ b/src/commands/workspace.rs @@ -1,7 +1,24 @@ +use crate::client::sdk::Api; use crate::config; -use crate::sdk::Api; use serde::Serialize; +/// Subcommands for `hotdata workspaces`. +#[derive(clap::Subcommand)] +pub enum WorkspaceCommands { + /// List all workspaces + List { + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, + }, + + /// Set the default workspace + Set { + /// Workspace ID to set as default (omit for interactive selection) + workspace_id: Option, + }, +} + #[derive(Serialize)] struct Workspace { public_id: String, @@ -127,7 +144,10 @@ pub fn list(format: &str) { ] }) .collect(); - crate::table::print(&["DEFAULT", "PUBLIC_ID", "NAME", "PROVISION_STATUS"], &rows); + crate::output::table::print( + &["DEFAULT", "PUBLIC_ID", "NAME", "PROVISION_STATUS"], + &rows, + ); } } _ => unreachable!(), diff --git a/src/config.rs b/src/config.rs index cf62f6c..74cc20d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -116,7 +116,7 @@ fn write_config(config_path: &std::path::Path, content: &str) -> Result<(), Stri } /// Wipe the workspace cache for a profile. Paired with -/// `jwt::clear_session()` in `auth::logout` — together they reset the +/// `jwt::clear_session()` in `commands::auth::logout` — together they reset the /// on-disk state that login populates. pub fn clear_workspaces(profile: &str) -> Result<(), String> { let config_path = config_path()?; diff --git a/src/main.rs b/src/main.rs index 5132446..1e7b53e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,36 +1,28 @@ -mod auth; -mod command; +mod cli; +mod client; +mod commands; mod config; -mod connections; -mod connections_new; -mod context; -mod database_session; -mod databases; -mod embedding_providers; -mod indexes; -mod jobs; -mod jwt; -mod queries; -mod query; -mod raw_http; -mod results; -mod sdk; -mod skill; -mod table; -mod tables; -mod update; -mod usage; +mod output; mod util; -mod workspace; use anstyle::AnsiColor; use clap::{Parser, builder::Styles}; -use command::{ - AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, ContextCommands, - DatabaseTablesCommands, DatabasesCommands, EmbeddingProvidersCommands, IndexesCommands, - JobsCommands, QueriesCommands, QueryCommands, ResultsCommands, SkillCommands, TablesCommands, - WorkspaceCommands, -}; +use cli::Commands; +use client::{credentials, database_session, sdk}; +use commands::auth::{self, AuthCommands}; +use commands::connections::{self, ConnectionsCommands, ConnectionsCreateCommands}; +use commands::context::{self, ContextCommands}; +use commands::databases::{self, DatabaseTablesCommands, DatabasesCommands}; +use commands::embedding_providers::{self, EmbeddingProvidersCommands}; +use commands::indexes::{self, IndexesCommands}; +use commands::jobs::{self, JobsCommands}; +use commands::queries::{self, QueriesCommands}; +use commands::query::{self, QueryCommands}; +use commands::results::{self, ResultsCommands}; +use commands::skill::{self, SkillCommands}; +use commands::tables::{self, TablesCommands}; +use commands::workspace::{self, WorkspaceCommands}; +use commands::{update, usage}; #[derive(Parser)] #[command(name = "hotdata", version, about = concat!("Hotdata CLI - Command line interface for Hotdata (v", env!("CARGO_PKG_VERSION"), ")"), long_about = None, disable_version_flag = true)] @@ -89,7 +81,7 @@ fn resolve_workspace(provided: Option) -> String { config::ApiKeySource::Flag | config::ApiKeySource::Env ) { - let ids = auth::api_key_workspace_ids(&profile); + let ids = credentials::api_key_workspace_ids(&profile); if let [only] = ids.as_slice() { let _ = ACTIVE_WORKSPACE_ID.set(only.clone()); return only.clone(); @@ -248,7 +240,9 @@ fn main() { connections::get(&workspace_id, &id, &output) } else { match command { - Some(ConnectionsCommands::New) => connections_new::run(&workspace_id), + Some(ConnectionsCommands::New) => { + connections::interactive::run(&workspace_id) + } Some(ConnectionsCommands::List { output }) => { connections::list(&workspace_id, &output) } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..13971b0 --- /dev/null +++ b/src/output.rs @@ -0,0 +1 @@ +pub mod table; diff --git a/src/table.rs b/src/output/table.rs similarity index 100% rename from src/table.rs rename to src/output/table.rs