From 8c99bdd2abfdccddedcf07215fb59b67abb39d09 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 25 Jul 2026 16:44:48 -0400 Subject: [PATCH 1/2] Pearl th-c7f72b: th api dashboard layout get/add/remove (SMOODEV-2753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main-dashboard widget layout (per user + org, served by api-prime's GET/PUT /organizations/{org}/dashboard/layout) had no CLI surface — adding a widget meant clicking the web picker or hand-rolling curl with a user JWT. Add 'th api dashboard layout get|add|remove': add is a read-modify-write that appends below the current grid using the web's size→span mapping and lets the server's registry validation reject unknown ids loudly; remove is the inverse; get prints the saved-or-default layout. Rides the user session (layout rows are keyed by user id, so M2M cannot own one). First consumer: adding the new aws_cost_forecast widget to the Smoo AI org dashboard. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PFBBaG8GMKhVKntX36dkys --- .changeset/th-api-dashboard-layout.md | 19 +++ Cargo.lock | 4 +- crates/smooth-cli/src/main.rs | 8 + crates/smooth-cli/src/smooai/dashboard.rs | 185 ++++++++++++++++++++++ crates/smooth-cli/src/smooai/mod.rs | 1 + 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 .changeset/th-api-dashboard-layout.md create mode 100644 crates/smooth-cli/src/smooai/dashboard.rs diff --git a/.changeset/th-api-dashboard-layout.md b/.changeset/th-api-dashboard-layout.md new file mode 100644 index 000000000..91ae0dbd0 --- /dev/null +++ b/.changeset/th-api-dashboard-layout.md @@ -0,0 +1,19 @@ +--- +'@smooai/smooth': minor +--- + +`th api dashboard layout get|add|remove` — manage your Smoo AI main-dashboard +widget layout from the CLI (SMOODEV-2753). + +Layouts are per (user, org, dashboard type) rows behind +`GET/PUT /organizations/{org}/dashboard/layout`, previously reachable only by +clicking around the web dashboard or hand-rolling curl against a user JWT. +`layout add ` does a read-modify-write: fetches the saved layout, +appends the widget below the current grid with the size→span mapping the web +uses (small 3 / medium 6 / large 9 / full 12 on the 12-column grid), and PUTs +it back — the server validates the widget id against its registry, so a typo +fails loudly instead of saving a dead tile. `layout remove` is the inverse; +`layout get` prints the saved (or default) layout as JSON. Rides the user JWT +(`th auth login`) because layout rows are keyed by user id — M2M tokens can't +own a layout. Dogfood: added the new `aws_cost_forecast` widget to the Smoo AI +org dashboard with `th api dashboard layout add aws_cost_forecast`. diff --git a/Cargo.lock b/Cargo.lock index ce6fc021a..59b0941e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-adapter-memory" -version = "0.23.2" +version = "1.23.1" source = "git+https://github.com/SmooAI/smooth-operator.git?rev=9db9d319287e2ebcd3ab027e39971a0f51ef5b67#9db9d319287e2ebcd3ab027e39971a0f51ef5b67" dependencies = [ "anyhow", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-ingestion" -version = "0.23.2" +version = "1.23.1" source = "git+https://github.com/SmooAI/smooth-operator.git?rev=9db9d319287e2ebcd3ab027e39971a0f51ef5b67#9db9d319287e2ebcd3ab027e39971a0f51ef5b67" dependencies = [ "anyhow", diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index 663628f36..83a20070b 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -777,6 +777,13 @@ enum ApiCommands { #[command(subcommand)] cmd: smooai::jobs::Cmd, }, + /// Smoo AI main-dashboard widget layout — get / add / remove widgets + /// on your `/apps` dashboard (per user + org; widget ids from `th + /// widgets list`). + Dashboard { + #[command(subcommand)] + cmd: smooai::dashboard::Cmd, + }, /// Smoo AI org integrations (SendGrid email). #[command(visible_alias = "integration")] Integrations { @@ -1626,6 +1633,7 @@ async fn main() -> Result<()> { ApiCommands::Knowledge { cmd } => smooai::knowledge::cmd(cmd).await, ApiCommands::Files { cmd } => smooai::files::cmd(cmd).await, ApiCommands::Jobs { cmd } => smooai::jobs::cmd(cmd).await, + ApiCommands::Dashboard { cmd } => smooai::dashboard::cmd(cmd).await, ApiCommands::Integrations { cmd } => smooai::integrations::cmd(cmd).await, ApiCommands::Products { cmd } => smooai::products::cmd(cmd).await, ApiCommands::Booking { cmd } => smooai::booking::cmd(cmd).await, diff --git a/crates/smooth-cli/src/smooai/dashboard.rs b/crates/smooth-cli/src/smooai/dashboard.rs new file mode 100644 index 000000000..fad0ecf6a --- /dev/null +++ b/crates/smooth-cli/src/smooai/dashboard.rs @@ -0,0 +1,185 @@ +//! `th api dashboard …` — the user's main-dashboard widget layout. +//! +//! Layouts are per (user, org, dashboard type) and served by api-prime: +//! +//! | verb | route | +//! |--------|----------------------------------------------| +//! | GET | `/organizations/{org}/dashboard/layout?type=` | +//! | PUT | `/organizations/{org}/dashboard/layout` | +//! +//! Rows are keyed by user id, so this rides the user JWT ([`UserClient`]), +//! not M2M. `layout add` / `layout remove` are read-modify-write over the +//! same GET/PUT the web dashboard uses — the server validates widget ids +//! against its registry, so an unknown id fails loudly rather than saving +//! a dead tile. SMOODEV-2753 (dogfood: `th api dashboard layout add +//! aws_cost_forecast`). + +use anyhow::{Context, Result}; +use clap::Subcommand; +use serde_json::{json, Value}; + +use crate::smooai::user_client::UserClient; + +#[derive(Subcommand)] +pub enum Cmd { + /// Read or edit the widget layout. + #[command(subcommand)] + Layout(LayoutCmd), +} + +#[derive(Subcommand)] +pub enum LayoutCmd { + /// Print the saved layout (or the org's default when none is saved). + Get { + /// Dashboard type (defaults to `main`). + #[arg(long = "type", default_value = "main")] + dashboard_type: String, + /// Override the active org. Falls back to `SMOOAI_ORG_ID`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Add a widget to the layout (appended below the current grid). + Add { + /// Widget id from the registry, e.g. `aws_cost_forecast` (see `th widgets list`). + widget_id: String, + /// Apple-style size: small | medium | large | full. + #[arg(long, default_value = "medium")] + size: String, + /// Dashboard type (defaults to `main`). + #[arg(long = "type", default_value = "main")] + dashboard_type: String, + /// Override the active org. Falls back to `SMOOAI_ORG_ID`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Remove a widget from the layout. + Remove { + /// Widget id to remove. + widget_id: String, + /// Dashboard type (defaults to `main`). + #[arg(long = "type", default_value = "main")] + dashboard_type: String, + /// Override the active org. Falls back to `SMOOAI_ORG_ID`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, +} + +fn resolve_org(override_org: Option) -> Result { + if let Some(o) = override_org.filter(|s| !s.trim().is_empty()) { + return Ok(o); + } + if let Ok(o) = std::env::var("SMOOAI_ORG_ID") { + if !o.trim().is_empty() { + return Ok(o); + } + } + anyhow::bail!("no org specified — pass `--org ` or set SMOOAI_ORG_ID") +} + +/// Grid width per size, mirroring the web dashboard's size→span mapping +/// (12-column grid: small 3, medium 6, large 9, full 12). +fn width_for(size: &str) -> Result { + match size { + "small" => Ok(3), + "medium" => Ok(6), + "large" => Ok(9), + "full" => Ok(12), + other => anyhow::bail!("unknown size `{other}` — use small | medium | large | full"), + } +} + +async fn fetch_layout(client: &UserClient, org: &str, dashboard_type: &str) -> Result { + client + .get(&format!("/organizations/{org}/dashboard/layout?type={dashboard_type}")) + .await + .context("GET dashboard layout") +} + +fn widgets_of(layout: &Value) -> Vec { + layout.get("widgets").and_then(Value::as_array).cloned().unwrap_or_default() +} + +async fn save_layout(client: &UserClient, org: &str, dashboard_type: &str, widgets: &[Value]) -> Result { + client + .put( + &format!("/organizations/{org}/dashboard/layout"), + &json!({ "widgets": widgets, "dashboardType": dashboard_type }), + ) + .await + .context("PUT dashboard layout") +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + let Cmd::Layout(cmd) = cmd; + let client = UserClient::from_user_session().await?; + match cmd { + LayoutCmd::Get { dashboard_type, org } => { + let o = resolve_org(org)?; + let layout = fetch_layout(&client, &o, &dashboard_type).await?; + println!("{}", serde_json::to_string_pretty(&layout)?); + } + LayoutCmd::Add { + widget_id, + size, + dashboard_type, + org, + } => { + let o = resolve_org(org)?; + let w = width_for(&size)?; + let layout = fetch_layout(&client, &o, &dashboard_type).await?; + let mut widgets = widgets_of(&layout); + if widgets.iter().any(|x| x.get("widgetId").and_then(Value::as_str) == Some(widget_id.as_str())) { + anyhow::bail!("widget `{widget_id}` is already on the {dashboard_type} dashboard"); + } + // Stack below the current grid — the same placement the web + // dashboard uses for newly merged default widgets. + let y = widgets + .iter() + .map(|x| x.get("y").and_then(Value::as_i64).unwrap_or(0) + x.get("h").and_then(Value::as_i64).unwrap_or(0)) + .max() + .unwrap_or(0); + widgets.push(json!({ "widgetId": widget_id, "x": 0, "y": y, "w": w, "h": 4, "size": size })); + save_layout(&client, &o, &dashboard_type, &widgets).await?; + println!("✓ added `{widget_id}` ({size}) to the {dashboard_type} dashboard ({} widgets)", widgets.len()); + } + LayoutCmd::Remove { + widget_id, + dashboard_type, + org, + } => { + let o = resolve_org(org)?; + let layout = fetch_layout(&client, &o, &dashboard_type).await?; + let mut widgets = widgets_of(&layout); + let before = widgets.len(); + widgets.retain(|x| x.get("widgetId").and_then(Value::as_str) != Some(widget_id.as_str())); + if widgets.len() == before { + anyhow::bail!("widget `{widget_id}` is not on the {dashboard_type} dashboard"); + } + save_layout(&client, &o, &dashboard_type, &widgets).await?; + println!("✓ removed `{widget_id}` from the {dashboard_type} dashboard ({} widgets)", widgets.len()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn width_maps_sizes_and_rejects_junk() { + assert_eq!(width_for("small").unwrap(), 3); + assert_eq!(width_for("medium").unwrap(), 6); + assert_eq!(width_for("large").unwrap(), 9); + assert_eq!(width_for("full").unwrap(), 12); + assert!(width_for("jumbo").is_err()); + } + + #[test] + fn widgets_of_tolerates_missing_or_malformed() { + assert!(widgets_of(&json!({})).is_empty()); + assert!(widgets_of(&json!({ "widgets": "nope" })).is_empty()); + assert_eq!(widgets_of(&json!({ "widgets": [{ "widgetId": "a" }] })).len(), 1); + } +} diff --git a/crates/smooth-cli/src/smooai/mod.rs b/crates/smooth-cli/src/smooai/mod.rs index 0752ecf5c..6a129cbfb 100644 --- a/crates/smooth-cli/src/smooai/mod.rs +++ b/crates/smooth-cli/src/smooai/mod.rs @@ -13,6 +13,7 @@ pub mod agents; pub mod booking; pub mod crawl; pub mod crm; +pub mod dashboard; pub mod files; pub mod heypage; pub mod integrations; From 55dfec5d82a1ef06e5c6c0b8c3aacb325ce9fdaa Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sun, 26 Jul 2026 01:01:37 -0400 Subject: [PATCH 2/2] ci: retrigger checks