From 1665bda1c30ee218a5619f8de90fc71c883b5452 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:08:08 -0700 Subject: [PATCH] feat(usage): add `hotdata usage` command SDK 0.4.0 added the usage API (`GET /v1/usage`) but the CLI exposed no surface for it. Add a `usage` command that reports workspace usage for the current billing window (or `--since `): hotdata usage [--since ] [-w ] [-o table|json|yaml] Shows query_count, bytes_scanned, storage_bytes, and storage_captured_at. Table view renders byte counts human-readably; json/yaml keep raw integers. query_count/bytes_scanned accrue per query in real time; storage_bytes is a periodic snapshot (storage_captured_at), so uploads show up on the next capture, not instantly. Uses the seam's raw `get_json` helper (no typed SDK handle exists for usage). Documented in the core skill. Verified against production (table + --since + json). --- skills/hotdata/SKILL.md | 11 ++++- src/command.rs | 15 ++++++ src/main.rs | 9 ++++ src/usage.rs | 102 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/usage.rs diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index 778050c..e28ef94 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -72,7 +72,7 @@ Catalog, skill decision tree, epic flows (onboard, chain, retrieval), and manage ## Available Commands -Top-level subcommands (each detailed below): **`auth`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`context`**, **`completions`**, **`update`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**. +Top-level subcommands (each detailed below): **`auth`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`context`**, **`usage`**, **`completions`**, **`update`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**. Global CLI options: **`--api-key`**, **`-v` / `--version`**, **`-h` / `--help`**, **`--no-input`** (disable interactive prompts; commands that require input will error instead — useful in CI or non-TTY environments). Hidden developer flag: **`--debug`** (verbose HTTP logs). @@ -262,6 +262,15 @@ hotdata jobs [--workspace-id ] [--output table|json|yaml] - `--status`: `pending`, `running`, `succeeded`, `partially_succeeded`, `failed`. - Use `hotdata jobs ` to inspect a specific job's status, error, and result. +### Usage +``` +hotdata usage [--since ] [--workspace-id ] [--output table|json|yaml] +``` +Workspace usage for the current billing window (or since `--since`): `query_count`, `bytes_scanned`, `storage_bytes`, and `storage_captured_at`. +- `query_count` and `bytes_scanned` accrue **per query in real time** (data reads). +- `storage_bytes` is a **periodic snapshot** taken at `storage_captured_at`, so it reflects uploads only after the next capture — not instantly. +- Table output renders byte counts human-readably (raw integers in `-o json`/`yaml`). + ### Agent skills (`skills`) Bundled Markdown skills (**`hotdata`**, **`hotdata-search`**, **`hotdata-analytics`**, **`hotdata-geospatial`**) ship with the CLI release tarball. diff --git a/src/command.rs b/src/command.rs index 8cf0903..d90cbd8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -208,6 +208,21 @@ pub enum Commands { 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 diff --git a/src/main.rs b/src/main.rs index 9f88050..2c79faf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod skill; mod table; mod tables; mod update; +mod usage; mod util; mod workspace; @@ -816,6 +817,14 @@ fn main() { } } } + Commands::Usage { + since, + workspace_id, + output, + } => { + let workspace_id = resolve_workspace(workspace_id); + usage::usage(&workspace_id, since.as_deref(), &output); + } Commands::Completions { shell } => { use clap::CommandFactory; use clap_complete::generate; diff --git a/src/usage.rs b/src/usage.rs new file mode 100644 index 0000000..5590717 --- /dev/null +++ b/src/usage.rs @@ -0,0 +1,102 @@ +use crate::sdk::Api; +use serde::{Deserialize, Serialize}; + +/// CLI output shape for `usage`, mapped from the `/v1/usage` +/// (`WorkspaceUsageResponse`) body. +/// +/// `since` is the start of the reporting window. `query_count` and +/// `bytes_scanned` accrue per query in real time; `storage_bytes` is a periodic +/// snapshot taken at `storage_captured_at`, so it lags writes (uploads) by up to +/// one capture interval and is not real-time. +#[derive(Deserialize, Serialize)] +struct Usage { + since: String, + query_count: i64, + bytes_scanned: i64, + storage_bytes: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + storage_captured_at: Option, +} + +/// Human-readable byte count in binary units, keeping the exact value in +/// parentheses (table view only; JSON/YAML keep raw integers). +fn human_bytes(n: i64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + if n < 1024 { + return format!("{n} B"); + } + let mut v = n as f64; + let mut u = 0; + while v >= 1024.0 && u < UNITS.len() - 1 { + v /= 1024.0; + u += 1; + } + format!("{v:.1} {} ({n} B)", UNITS[u]) +} + +/// `hotdata usage` — workspace usage for the current billing window (or since a +/// caller-supplied timestamp). +pub fn usage(workspace_id: &str, since: Option<&str>, format: &str) { + let api = Api::new(Some(workspace_id)); + let query: Vec<(&str, String)> = since + .map(|s| vec![("since", s.to_string())]) + .unwrap_or_default(); + let u: Usage = api.get_json("/usage", &query).unwrap_or_else(|e| e.exit()); + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&u).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&u).unwrap()), + _ => { + let rows = vec![ + vec!["since".to_string(), u.since.clone()], + vec!["query_count".to_string(), u.query_count.to_string()], + vec!["bytes_scanned".to_string(), human_bytes(u.bytes_scanned)], + vec!["storage_bytes".to_string(), human_bytes(u.storage_bytes)], + vec![ + "storage_captured_at".to_string(), + u.storage_captured_at + .clone() + .unwrap_or_else(|| "-".to_string()), + ], + ]; + crate::table::print(&["METRIC", "VALUE"], &rows); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_bytes_scales_units_and_keeps_exact() { + assert_eq!(human_bytes(512), "512 B"); + assert_eq!(human_bytes(1024), "1.0 KiB (1024 B)"); + assert_eq!(human_bytes(98_209_424), "93.7 MiB (98209424 B)"); + } + + #[test] + fn usage_deserializes_real_response_shape() { + // Mirrors a live `/v1/usage` body; storage_captured_at may be null. + let body = r#"{"since":"2026-06-01T00:00:00Z","bytes_scanned":19814572, + "query_count":184,"storage_bytes":98209424, + "storage_captured_at":"2026-06-20T00:55:19Z"}"#; + let u: Usage = serde_json::from_str(body).unwrap(); + assert_eq!(u.query_count, 184); + assert_eq!(u.bytes_scanned, 19814572); + assert_eq!(u.storage_bytes, 98209424); + assert_eq!(u.since, "2026-06-01T00:00:00Z"); + + // Round-trips back out for -o json/yaml consumers. + let out = serde_json::to_string(&u).unwrap(); + assert!(out.contains("\"query_count\":184")); + } + + #[test] + fn usage_tolerates_null_storage_captured_at() { + let body = r#"{"since":"2026-06-01T00:00:00Z","bytes_scanned":0, + "query_count":0,"storage_bytes":0,"storage_captured_at":null}"#; + let u: Usage = serde_json::from_str(body).unwrap(); + assert!(u.storage_captured_at.is_none()); + } +}