From 1e4de7dfa8737ad54db8a84ab90c5cdfe212ce33 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 20 Aug 2026 11:26:03 -0400 Subject: [PATCH 1/2] th-mcp-cli-parity: scaffold nine SmooCommands stubs for the parity fan-out Co-Authored-By: Claude Fable 5 --- crates/smooth-cli/src/main.rs | 64 +++++++++++++++++++ crates/smooth-cli/src/smooai/analytics.rs | 18 ++++++ crates/smooth-cli/src/smooai/audiences.rs | 18 ++++++ crates/smooth-cli/src/smooai/campaigns.rs | 18 ++++++ crates/smooth-cli/src/smooai/drip.rs | 18 ++++++ crates/smooth-cli/src/smooai/forms.rs | 18 ++++++ crates/smooth-cli/src/smooai/gbp.rs | 18 ++++++ crates/smooth-cli/src/smooai/mod.rs | 9 +++ .../smooth-cli/src/smooai/search_console.rs | 18 ++++++ crates/smooth-cli/src/smooai/sheets.rs | 18 ++++++ crates/smooth-cli/src/smooai/workforce.rs | 18 ++++++ 11 files changed, 235 insertions(+) create mode 100644 crates/smooth-cli/src/smooai/analytics.rs create mode 100644 crates/smooth-cli/src/smooai/audiences.rs create mode 100644 crates/smooth-cli/src/smooai/campaigns.rs create mode 100644 crates/smooth-cli/src/smooai/drip.rs create mode 100644 crates/smooth-cli/src/smooai/forms.rs create mode 100644 crates/smooth-cli/src/smooai/gbp.rs create mode 100644 crates/smooth-cli/src/smooai/search_console.rs create mode 100644 crates/smooth-cli/src/smooai/sheets.rs create mode 100644 crates/smooth-cli/src/smooai/workforce.rs diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index 47238011..0dd8eb3e 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -884,6 +884,61 @@ enum SmooCommands { #[command(subcommand)] cmd: smooai::crm::Cmd, }, + /// Smoo AI analytics — the catalog of queryable tables, ad-hoc SQL + /// (org-scoped, server-validated), and rendered reports. CLI twin of the + /// hosted MCP analytics_* tools. + Analytics { + #[command(subcommand)] + cmd: smooai::analytics::Cmd, + }, + /// Smoo AI campaigns — list, per-campaign analytics, and (preview-first) + /// send. CLI twin of the hosted MCP campaign_* tools. + #[command(visible_alias = "campaign")] + Campaigns { + #[command(subcommand)] + cmd: smooai::campaigns::Cmd, + }, + /// Smoo AI drip sequences — sequences, enrollments, test sends. CLI twin + /// of the hosted MCP drip_* tools. + Drip { + #[command(subcommand)] + cmd: smooai::drip::Cmd, + }, + /// Smoo AI audiences — saved segments: list / create / members / resolve. + /// CLI twin of the hosted MCP audience_* tools. + #[command(visible_alias = "audience")] + Audiences { + #[command(subcommand)] + cmd: smooai::audiences::Cmd, + }, + /// Smoo AI forms — list the org's forms and their submission counts. + #[command(visible_alias = "form")] + Forms { + #[command(subcommand)] + cmd: smooai::forms::Cmd, + }, + /// Google Business Profile — reviews for the org's GBP location. + Gbp { + #[command(subcommand)] + cmd: smooai::gbp::Cmd, + }, + /// Google Search Console — top queries for the org's property. + #[command(name = "search-console")] + SearchConsole { + #[command(subcommand)] + cmd: smooai::search_console::Cmd, + }, + /// Smoo AI sheets — snapshots of connected spreadsheets. + #[command(visible_alias = "sheet")] + Sheets { + #[command(subcommand)] + cmd: smooai::sheets::Cmd, + }, + /// Smoo AI workforce — the org's AI + human workforce directory. + Workforce { + #[command(subcommand)] + cmd: smooai::workforce::Cmd, + }, /// Smoo AI agents — list / show / create / update / delete, the /// regenerate-* and per-agent knowledge endpoints, and `tools` (which /// tools each agent may actually use). @@ -1763,6 +1818,15 @@ async fn run_smoo(cmd: SmooCommands) -> Result<()> { SmooCommands::Search { args } => smooai::websearch::run(args).await, SmooCommands::Knowledge { cmd } => smooai::knowledge::cmd(cmd).await, SmooCommands::Crm { cmd } => smooai::crm::cmd(cmd).await, + SmooCommands::Analytics { cmd } => smooai::analytics::cmd(cmd).await, + SmooCommands::Campaigns { cmd } => smooai::campaigns::cmd(cmd).await, + SmooCommands::Drip { cmd } => smooai::drip::cmd(cmd).await, + SmooCommands::Audiences { cmd } => smooai::audiences::cmd(cmd).await, + SmooCommands::Forms { cmd } => smooai::forms::cmd(cmd).await, + SmooCommands::Gbp { cmd } => smooai::gbp::cmd(cmd).await, + SmooCommands::SearchConsole { cmd } => smooai::search_console::cmd(cmd).await, + SmooCommands::Sheets { cmd } => smooai::sheets::cmd(cmd).await, + SmooCommands::Workforce { cmd } => smooai::workforce::cmd(cmd).await, SmooCommands::Agents { cmd } => smooai::agents::cmd(cmd).await, SmooCommands::Branding { cmd } => smooai::branding::cmd(cmd).await, SmooCommands::Llm { cmd } => smooai::llm_gateway::cmd(cmd).await, diff --git a/crates/smooth-cli/src/smooai/analytics.rs b/crates/smooth-cli/src/smooai/analytics.rs new file mode 100644 index 00000000..071f5e11 --- /dev/null +++ b/crates/smooth-cli/src/smooai/analytics.rs @@ -0,0 +1,18 @@ +//! `smoo analytics …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/audiences.rs b/crates/smooth-cli/src/smooai/audiences.rs new file mode 100644 index 00000000..9093c739 --- /dev/null +++ b/crates/smooth-cli/src/smooai/audiences.rs @@ -0,0 +1,18 @@ +//! `smoo audiences …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/campaigns.rs b/crates/smooth-cli/src/smooai/campaigns.rs new file mode 100644 index 00000000..ce48c0c4 --- /dev/null +++ b/crates/smooth-cli/src/smooai/campaigns.rs @@ -0,0 +1,18 @@ +//! `smoo campaigns …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/drip.rs b/crates/smooth-cli/src/smooai/drip.rs new file mode 100644 index 00000000..9113dd2d --- /dev/null +++ b/crates/smooth-cli/src/smooai/drip.rs @@ -0,0 +1,18 @@ +//! `smoo drip …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/forms.rs b/crates/smooth-cli/src/smooai/forms.rs new file mode 100644 index 00000000..9ae7e6d5 --- /dev/null +++ b/crates/smooth-cli/src/smooai/forms.rs @@ -0,0 +1,18 @@ +//! `smoo forms …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/gbp.rs b/crates/smooth-cli/src/smooai/gbp.rs new file mode 100644 index 00000000..3bc28f08 --- /dev/null +++ b/crates/smooth-cli/src/smooai/gbp.rs @@ -0,0 +1,18 @@ +//! `smoo gbp …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/mod.rs b/crates/smooth-cli/src/smooai/mod.rs index 14cae313..b6dbf433 100644 --- a/crates/smooth-cli/src/smooai/mod.rs +++ b/crates/smooth-cli/src/smooai/mod.rs @@ -11,12 +11,18 @@ pub mod agent_tools; pub mod agents; +pub mod analytics; +pub mod audiences; pub mod booking; pub mod branding; +pub mod campaigns; pub mod crawl; pub mod crm; pub mod dashboard; +pub mod drip; pub mod files; +pub mod forms; +pub mod gbp; pub mod heypage; pub mod integrations; pub mod jobs; @@ -30,6 +36,8 @@ pub mod products; pub mod profile; pub mod referrals; pub mod roles; +pub mod search_console; +pub mod sheets; pub mod smooth_operator; pub mod smooth_operator_ws; pub mod teams; @@ -37,6 +45,7 @@ pub mod testing; pub mod user_client; pub mod websearch; pub mod widgets; +pub mod workforce; use std::io::IsTerminal; diff --git a/crates/smooth-cli/src/smooai/search_console.rs b/crates/smooth-cli/src/smooai/search_console.rs new file mode 100644 index 00000000..41680cac --- /dev/null +++ b/crates/smooth-cli/src/smooai/search_console.rs @@ -0,0 +1,18 @@ +//! `smoo search-console …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/sheets.rs b/crates/smooth-cli/src/smooai/sheets.rs new file mode 100644 index 00000000..c56d048a --- /dev/null +++ b/crates/smooth-cli/src/smooai/sheets.rs @@ -0,0 +1,18 @@ +//! `smoo sheets …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} diff --git a/crates/smooth-cli/src/smooai/workforce.rs b/crates/smooth-cli/src/smooai/workforce.rs new file mode 100644 index 00000000..9522407a --- /dev/null +++ b/crates/smooth-cli/src/smooai/workforce.rs @@ -0,0 +1,18 @@ +//! `smoo workforce …` — scaffold stub; implementation lands in this PR +//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). + +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum Cmd { + /// Placeholder — replaced by the implementing lane in this PR. + #[command(hide = true)] + Todo, +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + match cmd { + Cmd::Todo => anyhow::bail!("not implemented yet"), + } +} From 5ed18f3231eed7f8d0cdcd220591407505494945 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 20 Aug 2026 11:49:54 -0400 Subject: [PATCH 2/2] =?UTF-8?q?MCP=E2=86=92CLI=20parity:=209=20new=20smoo?= =?UTF-8?q?=20command=20groups=20+=20files/heypage/o11y=20extensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the CLI side of the hosted-MCP gap (pearls th-739bb1, th-b1f09c, th-088c93, th-a5d991): every mcp.smoo.ai read tool now has a smoo verb, mirroring the exact routes the MCP server calls (read from rust/mcp). campaign send is preview-first (--confirm for a real send; suppression server-side per smooai#4221). 748 tests pass (95 new across the 12 modules), clippy no errors, every read verb live-smoked against the real org by the implementing lanes (zero writes fired). Four-lane fan-out (w1 analytics+metrics, w2 campaigns/drip/audiences, w3 files/sites, w4 one-offs) on pre-scaffolded module stubs. Co-Authored-By: Claude Fable 5 --- .changeset/mcp-cli-parity.md | 5 + CLAUDE.md | 1 + crates/smooth-cli/src/main.rs | 12 +- crates/smooth-cli/src/smooai/analytics.rs | 635 +++++++++++++++++- crates/smooth-cli/src/smooai/audiences.rs | 512 +++++++++++++- crates/smooth-cli/src/smooai/campaigns.rs | 349 +++++++++- crates/smooth-cli/src/smooai/drip.rs | 445 +++++++++++- crates/smooth-cli/src/smooai/files.rs | 164 ++++- crates/smooth-cli/src/smooai/forms.rs | 190 +++++- crates/smooth-cli/src/smooai/gbp.rs | 225 ++++++- crates/smooth-cli/src/smooai/heypage.rs | 261 +++++++ crates/smooth-cli/src/smooai/observability.rs | 464 +++++++++++++ .../smooth-cli/src/smooai/search_console.rs | 183 ++++- crates/smooth-cli/src/smooai/sheets.rs | 188 +++++- crates/smooth-cli/src/smooai/workforce.rs | 187 +++++- docs/Engineering/Using-th-CLI.md | 7 + 16 files changed, 3761 insertions(+), 67 deletions(-) create mode 100644 .changeset/mcp-cli-parity.md diff --git a/.changeset/mcp-cli-parity.md b/.changeset/mcp-cli-parity.md new file mode 100644 index 00000000..2354acac --- /dev/null +++ b/.changeset/mcp-cli-parity.md @@ -0,0 +1,5 @@ +--- +'@smooai/smooth': minor +--- + +MCP→CLI parity batch: the ~35 hosted-MCP tools that had no `smoo` verb now do. New command groups `smoo analytics` (catalog / org-scoped validated query / GA4 reports), `smoo campaigns` (list / analytics / preview-first send — a real send requires `--confirm`, and per-recipient suppression stays server-side), `smoo drip` (sequences / enrollments / enroll / cancel / test-send), `smoo audiences` (list / create / members / add-members / resolve), `smoo forms`, `smoo gbp reviews`, `smoo search-console queries`, `smoo sheets snapshots`, and `smoo workforce` (bare command = the directory). Extended: `smoo files search|summarize`, `smoo heypage versions|rollback|source get|set|content get|set`, and `smoo api observability metrics list|query|attributes` + `web-vitals`. Every verb mirrors the exact route its mcp.smoo.ai twin calls, takes `--json`, reports empty results as answers, and always reports truncation. diff --git a/CLAUDE.md b/CLAUDE.md index aece7861..19dec090 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ smooth/ # Smoo platform — replaces every curl to api.smoo.ai (smoo == th smoo) smoo api orgs|agents|smooth-operator|knowledge|jobs|members|config|keys|observability|profile|testing smoo auth login|whoami|logout|profile · smoo agents|crm|config|orgs|knowledge|files|testing|branding +smoo analytics|campaigns|drip|audiences|forms|gbp|search-console|sheets|workforce # MCP-parity batch (th-739bb1…) # White-label an org — theme + logos (logo re-hosted from a path OR a remote URL). # `enable` is the live switch and refuses a theme that fails WCAG AA contrast. diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index 0dd8eb3e..666a5222 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -937,7 +937,7 @@ enum SmooCommands { /// Smoo AI workforce — the org's AI + human workforce directory. Workforce { #[command(subcommand)] - cmd: smooai::workforce::Cmd, + cmd: Option, }, /// Smoo AI agents — list / show / create / update / delete, the /// regenerate-* and per-agent knowledge endpoints, and `tools` (which @@ -1826,7 +1826,15 @@ async fn run_smoo(cmd: SmooCommands) -> Result<()> { SmooCommands::Gbp { cmd } => smooai::gbp::cmd(cmd).await, SmooCommands::SearchConsole { cmd } => smooai::search_console::cmd(cmd).await, SmooCommands::Sheets { cmd } => smooai::sheets::cmd(cmd).await, - SmooCommands::Workforce { cmd } => smooai::workforce::cmd(cmd).await, + SmooCommands::Workforce { cmd } => { + // Bare `smoo workforce` reads as "show me the directory". + smooai::workforce::cmd(cmd.unwrap_or(smooai::workforce::Cmd::Directory { + view: Default::default(), + json: false, + org: None, + })) + .await + } SmooCommands::Agents { cmd } => smooai::agents::cmd(cmd).await, SmooCommands::Branding { cmd } => smooai::branding::cmd(cmd).await, SmooCommands::Llm { cmd } => smooai::llm_gateway::cmd(cmd).await, diff --git a/crates/smooth-cli/src/smooai/analytics.rs b/crates/smooth-cli/src/smooai/analytics.rs index 071f5e11..1b1d1adc 100644 --- a/crates/smooth-cli/src/smooai/analytics.rs +++ b/crates/smooth-cli/src/smooai/analytics.rs @@ -1,18 +1,639 @@ -//! `smoo analytics …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo analytics …` — the analytics warehouse + GA4 read surface. +//! +//! CLI parity with the hosted `mcp.smoo.ai` tools `analytics_catalog`, +//! `analytics_query` and `analytics_report` (pearl th-739bb1). Same routes, +//! same rules: +//! +//! 1. *"No data" and "unavailable" never render the same.* A failed request is +//! an `Err` all the way out; a successful empty result says so in words. +//! 2. *Truncation is always reported.* When fewer rows print than the server +//! counted, the summary says so. -use anyhow::Result; +use std::fmt::Write as _; +use std::path::PathBuf; + +use anstream::println; +use anyhow::{bail, Context, Result}; use clap::Subcommand; +use serde_json::{json, Value}; +use smooth_api_client::SmoothApiClient; + +use super::observability::Common; +use super::{print_json, require_active_org, require_authed}; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// The preset queries and custom data sources available to this org. + /// + /// Run this before `query` to pick a preset; pass a data-source id to see + /// that source's columns (the grounding you need to write ad-hoc SQL). + Catalog { + /// A custom data source id (from a bare `catalog` call) to describe + /// its columns instead of listing the catalog. + data_source_id: Option, + #[command(flatten)] + common: Common, + }, + /// Run a warehouse query: a preset key, or ad-hoc ClickHouse SELECT. + /// + /// Ad-hoc SQL is SELECT-only, a single statement, and MUST reference the + /// bound `{orgId: String}` parameter — the warehouse is multi-tenant and + /// the query is not rewritten for you. `{startDate: DateTime64(3, 'UTC')}` + /// and `{endDate: …}` are bound too. All guards (SELECT-only, org scoping, + /// cost pre-flight) run server-side. + Query { + /// Ad-hoc SELECT SQL. Alternatively `--file` or `--preset`. + sql: Option, + /// Read the SQL from a file instead of the command line. + #[arg(long, conflicts_with = "sql")] + file: Option, + /// A preset key from `catalog`. Wins over SQL if both are given. + #[arg(long)] + preset: Option, + /// Window start, ISO-8601 (bound as `{startDate}`; default 30 days ago). + #[arg(long)] + start_date: Option, + /// Window end, ISO-8601 (bound as `{endDate}`; default now). + #[arg(long)] + end_date: Option, + #[command(flatten)] + common: Common, + }, + /// Google Analytics 4 for the org's connected Google account. + /// + /// `properties` lists the GA4 properties you can report on — start there. + /// Then `overview`, `top-pages` or `traffic` with `--property-id`. + Report { + /// properties | overview | top-pages | traffic. + #[arg(default_value = "overview")] + report: String, + /// The GA4 property, from `report properties`. Required for every + /// report except `properties`. + #[arg(long)] + property_id: Option, + /// Trailing window in days (default 30). GA4 serves fixed windows, so + /// this SNAPS up: 1–7 → 7d, 8–30 → 30d, larger → 90d. + #[arg(long)] + days: Option, + /// Rows for `top-pages` (1–50, default 20). + #[arg(long)] + limit: Option, + #[command(flatten)] + common: Common, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::Catalog { data_source_id, common } => { + let org = require_active_org(&client, common.org)?; + if let Some(id) = data_source_id { + let resp = data_source_catalog(&client, &org, &id).await?; + emit(&resp, common.json, render_data_source); + } else { + let resp = catalog(&client, &org).await?; + emit(&resp, common.json, render_catalog); + } + } + Cmd::Query { + sql, + file, + preset, + start_date, + end_date, + common, + } => { + let org = require_active_org(&client, common.org)?; + let sql = sql_from(sql, file)?; + if sql.is_none() && preset.is_none() { + bail!("provide SELECT sql (positional or --file), or --preset from `smoo analytics catalog`"); + } + let resp = query(&client, &org, preset.as_deref(), sql.as_deref(), start_date.as_deref(), end_date.as_deref()).await?; + emit(&resp, common.json, render_query); + } + Cmd::Report { + report, + property_id, + days, + limit, + common, + } => { + let org = require_active_org(&client, common.org)?; + let kind = report_kind(&report, property_id.as_deref())?; + let resp = ga_report(&client, &org, kind, property_id.as_deref(), days, limit).await?; + emit(&resp, common.json, |r| render_report(r, kind)); + } + } + Ok(()) +} + +/// Print a query result: raw JSON on `--json`, otherwise the summary. +fn emit(resp: &Value, as_json: bool, render: impl Fn(&Value) -> String) { + if as_json { + print_json(resp); + } else { + println!(); + println!("{}", render(resp)); + println!(); + } +} + +/// The SQL text: positional wins by clap `conflicts_with`, `--file` reads a +/// file. `None` when neither was given. +fn sql_from(sql: Option, file: Option) -> Result> { + match (sql, file) { + (Some(s), _) => Ok(Some(s)), + (None, Some(path)) => { + let text = std::fs::read_to_string(&path).with_context(|| format!("read SQL from {}", path.display()))?; + if text.trim().is_empty() { + bail!("{} is empty — nothing to run", path.display()); + } + Ok(Some(text)) + } + (None, None) => Ok(None), + } +} + +/// Validate the GA4 report kind — mirrors the hosted MCP tool exactly: +/// `properties` is the discovery step so it cannot need a property id; +/// everything else does. +fn report_kind<'a>(report: &'a str, property_id: Option<&str>) -> Result<&'a str> { + match report { + "properties" => return Ok("properties"), + "overview" | "top-pages" | "traffic" => {} + other => bail!("unknown report `{other}` — use properties, overview, top-pages or traffic"), + } + if property_id.unwrap_or_default().trim().is_empty() { + bail!("`{report}` needs --property-id — run `smoo analytics report properties` to list them"); + } + Ok(report) +} + +// --------------------------------------------------------------------------- +// Queries — one function per route, mirroring the hosted MCP tools +// --------------------------------------------------------------------------- + +/// The preset catalog + custom data sources, merged into +/// `{ presets, dataSources, dataSourcesUnavailable? }`. +/// +/// Custom data sources are a separate product (`analyticsCustom`), so an org +/// without it gets a 403 there while its presets are fine — that renders as +/// "none", not as a failure. Any OTHER data-sources error surfaces: a timeout +/// rendering as "no custom data sources" would be a confident lie (th-ed81e4). +/// +/// # Errors +/// Non-2xx from the presets route, or a non-403 from the data-sources route. +pub async fn catalog(client: &SmoothApiClient, org: &str) -> Result { + let presets = client + .get(&format!("/organizations/{org}/analytics/preset-queries")) + .await + .context("GET analytics/preset-queries")?; + let mut out = json!({ "presets": unwrap_list(presets, "presets") }); + match client.get(&format!("/organizations/{org}/analytics/data-sources")).await { + Ok(sources) => { + out["dataSources"] = unwrap_list(sources, "dataSources"); + } + // The client's error format is "{method} {path} returned HTTP {status}: …". + Err(e) if e.to_string().contains("returned HTTP 403") => { + out["dataSources"] = json!([]); + out["dataSourcesUnavailable"] = json!("this org does not have the custom data sources product"); + } + Err(e) => return Err(e).context("GET analytics/data-sources"), + } + Ok(out) +} + +/// The API's list routes answer either as a bare array or as an object keyed +/// by `key` (both conventions exist upstream) — normalize to the array. +fn unwrap_list(body: Value, key: &str) -> Value { + if body.is_array() { + body + } else { + body.get(key).cloned().unwrap_or_else(|| json!([])) + } +} + +/// `GET /analytics/data-sources/{id}/catalog` — one source's columns. +/// +/// # Errors +/// Non-2xx from the API. +pub async fn data_source_catalog(client: &SmoothApiClient, org: &str, id: &str) -> Result { + client + .get(&format!("/organizations/{org}/analytics/data-sources/{}/catalog", urlencoding::encode(id))) + .await + .context("GET analytics/data-sources catalog") +} + +/// `POST /analytics/query` → `{ columns, rows, rowCount }`. +/// +/// # Errors +/// Non-2xx from the API — including the server-side SELECT-only / org-scoping +/// / cost-pre-flight refusals, which come back verbatim. +pub async fn query( + client: &SmoothApiClient, + org: &str, + preset_key: Option<&str>, + sql: Option<&str>, + start_date: Option<&str>, + end_date: Option<&str>, +) -> Result { + let mut body = serde_json::Map::new(); + for (key, value) in [("presetKey", preset_key), ("sql", sql), ("startDate", start_date), ("endDate", end_date)] { + if let Some(v) = value.map(str::trim).filter(|s| !s.is_empty()) { + body.insert(key.to_string(), json!(v)); + } + } + client + .post(&format!("/organizations/{org}/analytics/query"), Some(&Value::Object(body))) + .await + .context("POST analytics/query") +} + +/// GA4 reads: `GET /analytics/google/properties` for the discovery step, else +/// `GET /analytics/google/{overview|top-pages|traffic}?propertyId=&days=&limit=`. +/// +/// # Errors +/// Non-2xx from the API (e.g. no Google account connected). +pub async fn ga_report(client: &SmoothApiClient, org: &str, kind: &str, property_id: Option<&str>, days: Option, limit: Option) -> Result { + if kind == "properties" { + return client + .get(&format!("/organizations/{org}/analytics/google/properties")) + .await + .context("GET analytics/google/properties"); + } + let mut qs = format!("?propertyId={}", urlencoding::encode(property_id.unwrap_or_default())); + if let Some(d) = days { + let _ = write!(qs, "&days={d}"); + } + if let Some(l) = limit { + let _ = write!(qs, "&limit={l}"); + } + client + .get(&format!("/organizations/{org}/analytics/google/{kind}{qs}")) + .await + .with_context(|| format!("GET analytics/google/{kind}")) +} + +// --------------------------------------------------------------------------- +// Renderers +// --------------------------------------------------------------------------- + +/// The array under `key` — or the body itself when the route answers as a +/// bare array (both conventions exist upstream). Missing and empty both mean +/// "the query succeeded and matched nothing", said in words by the callers. +fn rows<'a>(body: &'a Value, key: &str) -> &'a [Value] { + body.as_array().or_else(|| body.get(key).and_then(Value::as_array)).map_or(&[], Vec::as_slice) +} + +/// A value compact enough for one table cell. +fn cell(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => "-".to_string(), + other => other.to_string(), + } +} + +fn field(v: &Value, key: &str) -> String { + v.get(key).map_or_else(|| "-".to_string(), cell) +} + +/// The preset catalog + data sources. +pub fn render_catalog(body: &Value) -> String { + let presets = rows(body, "presets"); + let mut out = String::new(); + if presets.is_empty() { + out.push_str("No preset queries on this org. (The catalog read succeeded and returned zero presets.)\n"); + } else { + let _ = writeln!(out, "{} preset quer(ies) — pass a key to `smoo analytics query --preset`:", presets.len()); + for p in presets { + let _ = writeln!(out, " {} [{}] {}", field(p, "key"), field(p, "domain"), field(p, "description")); + } + } + out.push('\n'); + if let Some(reason) = body.get("dataSourcesUnavailable").and_then(Value::as_str) { + let _ = write!(out, "No custom data sources — {reason}."); + } else { + let sources = rows(body, "dataSources"); + if sources.is_empty() { + out.push_str("No custom data sources on this org. (The read succeeded and returned zero sources.)"); + } else { + let _ = writeln!(out, "{} custom data source(s) — pass an id back to `catalog` for its columns:", sources.len()); + for s in sources { + let _ = writeln!( + out, + " {} [{}] {} {}", + field(s, "id"), + field(s, "status"), + field(s, "name"), + field(s, "tableName") + ); + } + } + } + out.trim_end().to_string() +} + +/// One data source's columns. +pub fn render_data_source(body: &Value) -> String { + let mut out = format!( + "{} (table {}, {} row(s))\n{}\n", + field(body, "name"), + field(body, "tableName"), + field(body, "rowCount"), + field(body, "description"), + ); + let columns = rows(body, "columns"); + if columns.is_empty() { + out.push_str("\nNo columns described. (The read succeeded — the source may still be ingesting.)"); + return out.trim_end().to_string(); + } + let _ = writeln!(out, "\n{} column(s):", columns.len()); + for c in columns { + let _ = writeln!( + out, + " {} {} ({}) nullable={}", + field(c, "name"), + field(c, "clickhouseType"), + field(c, "semanticType"), + field(c, "nullable"), + ); + } + out.trim_end().to_string() +} + +/// Warehouse query rows, in the route's own column order. +pub fn render_query(body: &Value) -> String { + let data = rows(body, "rows"); + if data.is_empty() { + return "The query ran and matched no rows. (That is a real answer, not a failure — widen the date window or check the preset/SQL.)".to_string(); + } + // The route's projection order; fall back to the first row's own keys so a + // missing `columns` never renders every row blank. + let mut columns: Vec = body + .get("columns") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|c| c.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + if columns.is_empty() { + columns = data.first().and_then(Value::as_object).map(|o| o.keys().cloned().collect()).unwrap_or_default(); + } + let mut out = format!("{} row(s):\n", data.len()); + for r in data { + let line: Vec = columns.iter().map(|c| format!("{c}={}", field(r, c))).collect(); + let _ = writeln!(out, " {}", line.join(" ")); + } + if let Some(total) = body.get("rowCount").and_then(Value::as_u64) { + // A total too big for usize is certainly bigger than the page. + if usize::try_from(total).unwrap_or(usize::MAX) > data.len() { + let _ = write!(out, "(showing {} of {total} rows — the server truncated the page)", data.len()); + } + } + out.trim_end().to_string() +} + +/// One GA4 report, shaped per kind. +pub fn render_report(body: &Value, kind: &str) -> String { + match kind { + "properties" => { + let props = rows(body, "properties"); + if props.is_empty() { + return "No GA4 properties visible. (The read succeeded — the connected Google account may not have Analytics access, or no account is connected.)".to_string(); + } + let mut out = format!("{} GA4 propert(ies) — pass one to --property-id:\n", props.len()); + for p in props { + let _ = writeln!(out, " {} {} ({})", field(p, "propertyId"), field(p, "displayName"), field(p, "accountName")); + } + out.trim_end().to_string() + } + // A single object of totals, not a list. + "overview" => format!( + "sessions={} users={} pageviews={} bounceRate={}", + field(body, "sessions"), + field(body, "users"), + field(body, "pageviews"), + field(body, "bounceRate"), + ), + "top-pages" => { + let pages = rows(body, "pages"); + if pages.is_empty() { + return "No pages in this window. (The report ran and returned zero rows.)".to_string(); + } + let mut out = format!("{} page(s):\n", pages.len()); + for p in pages { + let _ = writeln!( + out, + " {:>8} views {:>7} users {} {}", + field(p, "pageviews"), + field(p, "users"), + field(p, "path"), + field(p, "title"), + ); + } + out.trim_end().to_string() + } + _ => { + let days = rows(body, "traffic"); + if days.is_empty() { + return "No traffic in this window. (The report ran and returned zero rows.)".to_string(); + } + let mut out = format!("{} day(s) of traffic:\n", days.len()); + for d in days { + let _ = writeln!( + out, + " {} sessions={} users={} pageviews={}", + field(d, "date"), + field(d, "sessions"), + field(d, "users"), + field(d, "pageviews"), + ); + } + out.trim_end().to_string() + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "expect is the idiom for test assertions")] +mod tests { + use super::*; + + // ── clap wiring ───────────────────────────────────────────────────────── + + #[test] + fn every_verb_parses_with_json() { + use clap::{CommandFactory, Parser}; + + #[derive(Parser)] + struct Harness { + #[command(subcommand)] + cmd: Cmd, + } + Harness::command().debug_assert(); + + for argv in [ + vec!["smoo", "catalog", "--json"], + vec!["smoo", "catalog", "ds-123", "--json"], + vec!["smoo", "query", "SELECT 1", "--json"], + vec!["smoo", "query", "--file", "q.sql", "--json"], + vec![ + "smoo", + "query", + "--preset", + "conversations-by-day", + "--start-date", + "2026-08-01", + "--end-date", + "2026-08-20", + ], + vec!["smoo", "report", "properties", "--json"], + vec!["smoo", "report", "overview", "--property-id", "123", "--days", "7"], + vec!["smoo", "report", "top-pages", "--property-id", "123", "--limit", "10", "--json"], + vec!["smoo", "report", "--property-id", "123"], + ] { + Harness::try_parse_from(&argv).unwrap_or_else(|e| panic!("{argv:?} must parse: {e}")); + } + } + + /// Positional SQL and `--file` are the same argument twice — refuse both. + #[test] + fn query_refuses_sql_and_file_together() { + use clap::Parser; + + #[derive(Parser)] + struct Harness { + #[command(subcommand)] + cmd: Cmd, + } + assert!(Harness::try_parse_from(["smoo", "query", "SELECT 1", "--file", "q.sql"]).is_err()); + } + + // ── SQL sources ───────────────────────────────────────────────────────── + + #[test] + fn sql_from_prefers_positional_reads_file_and_rejects_empty() { + assert_eq!(sql_from(Some("SELECT 1".into()), None).expect("positional"), Some("SELECT 1".to_string())); + assert_eq!(sql_from(None, None).expect("neither"), None); + + let dir = std::env::temp_dir().join(format!("smoo-analytics-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join("q.sql"); + std::fs::write(&path, "SELECT count() FROM conversations WHERE org_id = {orgId: String}").expect("write"); + let from_file = sql_from(None, Some(path)).expect("file").expect("some"); + assert!(from_file.contains("{orgId: String}")); + + let empty = dir.join("empty.sql"); + std::fs::write(&empty, " \n").expect("write"); + assert!(sql_from(None, Some(empty)).is_err(), "an empty file must be an error, not an empty query"); + assert!(sql_from(None, Some(dir.join("missing.sql"))).is_err()); + } + + // ── report-kind validation (mirrors the hosted MCP tool) ──────────────── + + #[test] + fn report_kind_gates_property_id_like_the_mcp_tool() { + assert_eq!(report_kind("properties", None).expect("discovery step"), "properties"); + assert_eq!(report_kind("overview", Some("123")).expect("with id"), "overview"); + let err = report_kind("overview", None).expect_err("overview needs an id").to_string(); + assert!(err.contains("--property-id") && err.contains("properties"), "must say where to get one: {err}"); + assert!(report_kind("traffic", Some(" ")).is_err(), "a blank id is no id"); + assert!(report_kind("bogus", Some("123")).is_err()); + } + + // ── Rule 1: "no data" never reads like "unavailable" ──────────────────── + + #[test] + fn empty_results_state_that_the_query_ran() { + let cases = vec![ + render_query(&serde_json::json!({ "rows": [], "columns": ["a"] })), + render_report(&serde_json::json!({ "properties": [] }), "properties"), + render_report(&serde_json::json!({ "pages": [] }), "top-pages"), + render_report(&serde_json::json!({ "traffic": [] }), "traffic"), + render_catalog(&serde_json::json!({ "presets": [], "dataSources": [] })), + ]; + for text in cases { + let lower = text.to_lowercase(); + assert!(!text.trim().is_empty(), "an empty result must still render text"); + assert!(lower.contains("no ") || lower.contains("none"), "must say it is empty: {text}"); + assert!( + lower.contains("ran") || lower.contains("returned") || lower.contains("succeeded"), + "must make clear the query SUCCEEDED: {text}" + ); + } + } + + /// A 403 on custom data sources is a product gate, not an outage — it + /// renders as "none, because …", while presets still print. + #[test] + fn gated_data_sources_render_as_none_with_the_reason() { + let text = render_catalog(&serde_json::json!({ + "presets": [{ "key": "conv-by-day", "name": "Conversations by day", "description": "d", "domain": "conversations" }], + "dataSources": [], + "dataSourcesUnavailable": "this org does not have the custom data sources product", + })); + assert!(text.contains("conv-by-day"), "{text}"); + assert!(text.contains("does not have"), "{text}"); + } + + // ── Rule 2: truncation is always reported ─────────────────────────────── + + #[test] + fn a_truncated_query_page_says_so() { + let body = serde_json::json!({ "columns": ["day", "n"], "rows": [{ "day": "2026-08-19", "n": 4 }], "rowCount": 91 }); + let text = render_query(&body); + assert!(text.contains("showing 1 of 91"), "{text}"); + + let full = serde_json::json!({ "columns": ["day"], "rows": [{ "day": "d" }], "rowCount": 1 }); + assert!(!render_query(&full).contains("showing"), "a complete page must not claim truncation"); + } + + // ── Rendering details ─────────────────────────────────────────────────── + + /// Rows print in the route's own column order, not object-key order, and a + /// missing `columns` falls back to the row's keys rather than blank lines. + #[test] + fn query_rows_follow_the_columns_projection() { + let body = serde_json::json!({ + "columns": ["b", "a"], + "rows": [{ "a": 1, "b": "x" }], + }); + let text = render_query(&body); + let b = text.find("b=x").expect("b renders"); + let a = text.find("a=1").expect("a renders"); + assert!(b < a, "projection order must win: {text}"); + + let no_columns = serde_json::json!({ "rows": [{ "a": 1 }] }); + assert!(render_query(&no_columns).contains("a=1"), "missing columns must fall back to row keys"); + } + + /// Upstream list routes answer either `{key: [...]}` or a bare array — + /// both must render, and both must normalize in the catalog merge. + #[test] + fn list_shapes_tolerate_keyed_and_bare_arrays() { + let item = serde_json::json!({ "propertyId": "1", "displayName": "d", "accountName": "a" }); + let keyed = serde_json::json!({ "properties": [item.clone()] }); + let bare = serde_json::json!([item]); + assert!(render_report(&keyed, "properties").contains("d")); + assert!(render_report(&bare, "properties").contains("d")); + + assert_eq!(unwrap_list(serde_json::json!([1, 2]), "presets"), serde_json::json!([1, 2])); + assert_eq!(unwrap_list(serde_json::json!({ "presets": [3] }), "presets"), serde_json::json!([3])); + assert_eq!(unwrap_list(serde_json::json!({}), "presets"), serde_json::json!([])); + } + + #[test] + fn overview_renders_totals_and_properties_render_ids() { + let overview = render_report( + &serde_json::json!({ "sessions": 10, "users": 5, "pageviews": 30, "bounceRate": 0.4 }), + "overview", + ); + assert!(overview.contains("sessions=10") && overview.contains("bounceRate=0.4"), "{overview}"); + + let props = render_report( + &serde_json::json!({ "properties": [{ "propertyId": "123", "displayName": "smoo.ai", "accountName": "Smoo" }] }), + "properties", + ); + assert!(props.contains("123") && props.contains("smoo.ai"), "{props}"); } } diff --git a/crates/smooth-cli/src/smooai/audiences.rs b/crates/smooth-cli/src/smooai/audiences.rs index 9093c739..460dae93 100644 --- a/crates/smooth-cli/src/smooai/audiences.rs +++ b/crates/smooth-cli/src/smooai/audiences.rs @@ -1,18 +1,516 @@ -//! `smoo audiences …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo audiences …` — saved contact segments campaigns and drips send to: +//! list, create, members, add-members, resolve. CLI twin of the hosted MCP +//! `audience_*` tools (pearl th-b1f09c). +//! +//! `resolve` PREVIEWS by default — it reports who the audience currently +//! matches without changing the stored membership; only `--materialize` +//! writes the result back. Creating an audience or adding members sends +//! nothing — they only define who a later send would reach. -use anyhow::Result; +use std::fmt::Write as _; + +use anyhow::{bail, Context, Result}; use clap::Subcommand; +use serde_json::{json, Value}; + +use super::{print_json, require_active_org, require_authed}; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// List the org's audiences (segment = saved filter, static = fixed list). + List { + /// Print the raw JSON instead of the compact list. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Create a reusable audience. `--kind segment` needs at least one filter + /// flag; `--kind static` starts empty (fill it with add-members). + Create { + /// Display name for the audience. + #[arg(long)] + name: String, + /// "segment" (a saved filter that re-resolves against the CRM) or "static" (a fixed list). + #[arg(long, value_parser = ["segment", "static"])] + kind: String, + /// Optional description. + #[arg(long)] + description: Option, + /// Segment filter: contacts carrying ALL of these tag ids, comma-separated. + #[arg(long = "tags", value_delimiter = ',')] + tag_ids: Option>, + /// Segment filter: contacts in this funnel. + #[arg(long = "funnel")] + funnel_id: Option, + /// Segment filter: contacts at this funnel stage. + #[arg(long = "stage")] + stage_id: Option, + /// Segment filter: contacts created on/after this RFC3339 timestamp. + #[arg(long = "created-after")] + created_after: Option, + /// Segment filter: contacts created on/before this RFC3339 timestamp. + #[arg(long = "created-before")] + created_before: Option, + /// Segment filter: only contacts that have an email address. + #[arg(long = "has-email")] + has_email: bool, + /// Segment filter: only contacts that have a phone number. + #[arg(long = "has-phone")] + has_phone: bool, + /// Print the raw JSON response instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// The contacts currently in an audience (a segment shows whatever was + /// last resolved — run `resolve` first if the CRM has moved on). + Members { + /// The audience id from `smoo audiences list`. + audience_id: String, + /// Max members to return (1-200, default 50). + #[arg(long)] + limit: Option, + /// Rows to skip, for paging through a large audience. + #[arg(long)] + offset: Option, + /// Print the raw JSON instead of the compact list. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Add named contacts to a STATIC audience (idempotent; sends nothing). + AddMembers { + /// The audience id from `smoo audiences list` — must be a static audience. + audience_id: String, + /// Contact ids to add, comma-separated. + #[arg(long, value_delimiter = ',', required = true)] + contacts: Vec, + /// Print the raw JSON response instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Work out who an audience currently matches. PREVIEW by default — pass + /// --materialize to write the result back as the stored membership. + Resolve { + /// The audience id from `smoo audiences list`. + audience_id: String, + /// Write the resolved membership back to the audience. + #[arg(long)] + materialize: bool, + /// Print the raw JSON response instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } +#[allow(clippy::too_many_lines)] // one arm per verb, same shape as the sibling modules pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::List { json: as_json, org } => { + let o = require_active_org(&client, org)?; + let body = client.get(&format!("/organizations/{o}/audiences")).await.context("GET audiences")?; + if as_json { + print_json(&body); + return Ok(()); + } + let rows = body.get("data").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + if rows.is_empty() { + println!("\nNo audiences defined for this org. This is a confirmed read, not a read failure — create one with `smoo audiences create`.\n"); + return Ok(()); + } + println!(); + for r in &rows { + let id = r.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let name = r.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let kind = r.get("kind").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {id} {name} [{kind}]"); + } + println!("\n{} audience(s).\n", rows.len()); + } + Cmd::Create { + name, + kind, + description, + tag_ids, + funnel_id, + stage_id, + created_after, + created_before, + has_email, + has_phone, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let payload = create_body(&CreateInput { + name, + kind, + description, + tag_ids, + funnel_id, + stage_id, + created_after, + created_before, + has_email, + has_phone, + })?; + let body = client + .post(&format!("/organizations/{o}/audiences"), Some(&payload)) + .await + .context("POST audience")?; + if as_json { + print_json(&body); + return Ok(()); + } + let id = body.get("id").and_then(Value::as_str).unwrap_or("?"); + let kind = body.get("kind").and_then(Value::as_str).unwrap_or("?"); + println!("\nCreated {kind} audience {id}. Creating an audience sends nothing — it only defines who a later send would reach.\n"); + } + Cmd::Members { + audience_id, + limit, + offset, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let path = format!( + "/organizations/{o}/audiences/{}/members?limit={}&offset={}", + urlencoding::encode(audience_id.trim()), + limit.unwrap_or(50).clamp(1, 200), + offset.unwrap_or(0) + ); + let body = client.get(&path).await.context("GET audience members")?; + if as_json { + print_json(&body); + return Ok(()); + } + let rows = body.get("data").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + if rows.is_empty() { + println!( + "\nAudience {audience_id} has no members in this page. This is a confirmed read, not a read failure — a segment audience reports zero until it has been resolved.\n" + ); + return Ok(()); + } + println!(); + for r in &rows { + let id = r.get("contactId").and_then(|v| v.as_str()).unwrap_or("?"); + let email = r.get("contactEmail").and_then(|v| v.as_str()).unwrap_or(""); + let first = r.get("contactFirstName").and_then(|v| v.as_str()).unwrap_or(""); + let last = r.get("contactLastName").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {id} {first} {last} {email}"); + } + let total = body.get("total").and_then(Value::as_u64); + match total { + Some(t) if t > rows.len() as u64 => println!("\nShowing {} of {t} member(s) — page the rest with --offset.\n", rows.len()), + _ => println!("\n{} member(s) shown.\n", rows.len()), + } + } + Cmd::AddMembers { + audience_id, + contacts, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + if contacts.is_empty() { + bail!("no contact ids given — name the contacts to add with --contacts"); + } + let asked = contacts.len(); + let body = client + .post( + &format!("/organizations/{o}/audiences/{}/members", urlencoding::encode(audience_id.trim())), + Some(&json!({ "contactIds": contacts })), + ) + .await + .context("POST audience members")?; + if as_json { + print_json(&body); + return Ok(()); + } + println!("\n{}\n", render_add_members(&body, asked, &audience_id)); + } + Cmd::Resolve { + audience_id, + materialize, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let body = client + .post( + &format!("/organizations/{o}/audiences/{}/resolve", urlencoding::encode(audience_id.trim())), + Some(&json!({ "materialize": materialize })), + ) + .await + .context("POST audience resolve")?; + if as_json { + print_json(&body); + return Ok(()); + } + println!("\n{}\n", render_resolve(&body, &audience_id, materialize)); + } + } + Ok(()) +} + +/// Resolved `create` flags, separate from clap so the payload shaping and the +/// segment-needs-a-filter guard are unit-testable without a `Cmd`. +struct CreateInput { + name: String, + kind: String, + description: Option, + tag_ids: Option>, + funnel_id: Option, + stage_id: Option, + created_after: Option, + created_before: Option, + has_email: bool, + has_phone: bool, +} + +/// Build the POST body. Refuses a segment with no predicates — one with none +/// matches EVERY contact in the org, which is an expensive mistake downstream. +fn create_body(input: &CreateInput) -> Result { + let name = input.name.trim(); + if name.is_empty() { + bail!("--name is empty — an audience needs a name to be reusable"); + } + + let mut filter = serde_json::Map::new(); + if let Some(tags) = input.tag_ids.as_ref().filter(|t| !t.is_empty()) { + filter.insert("tagIds".to_string(), json!(tags)); + } + for (key, value) in [ + ("funnelId", input.funnel_id.as_deref()), + ("stageId", input.stage_id.as_deref()), + ("createdAfter", input.created_after.as_deref()), + ("createdBefore", input.created_before.as_deref()), + ] { + if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) { + filter.insert(key.to_string(), json!(v)); + } + } + if input.has_email { + filter.insert("hasEmail".to_string(), json!(true)); + } + if input.has_phone { + filter.insert("hasPhone".to_string(), json!(true)); + } + if input.kind == "segment" && filter.is_empty() { + bail!( + "a segment audience needs at least one filter — one with none matches EVERY contact in the org. \ + Pass a filter flag, or use --kind static and add the contacts by id." + ); + } + + let mut body = json!({ "name": name, "kind": input.kind }); + if let Some(d) = input.description.as_deref().map(str::trim).filter(|d| !d.is_empty()) { + body["description"] = json!(d); + } + if !filter.is_empty() { + body["filter"] = Value::Object(filter); + } + Ok(body) +} + +/// Human summary of an add-members response. Reports the gap, not just the +/// win — a lower count means ids that were already members or belong to +/// another org, and hiding that would hide a wrong id. +fn render_add_members(body: &Value, asked: usize, audience_id: &str) -> String { + let added = body + .get("addedCount") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(0); + let note = if added < asked { + format!(" The other {} were already members or are not contacts in this org.", asked - added) + } else { + String::new() + }; + format!("Added {added} of {asked} contact(s) to audience {audience_id}.{note}") +} + +/// How many resolved contact ids we print before summarizing. +const MAX_RENDER_IDS: usize = 50; + +/// Human summary of a resolve response — always says whether the stored +/// membership changed, and reports any truncation of the id list. +fn render_resolve(body: &Value, audience_id: &str, materialize: bool) -> String { + let matched = body.get("matchedCount").map_or_else(|| "0".to_string(), ToString::to_string); + let kind = body.get("kind").and_then(Value::as_str).unwrap_or("audience"); + let mut out = format!("Audience {audience_id} ({kind}) currently matches {matched} contact(s)."); + if let Some(ids) = body.get("contactIds").and_then(|v| v.as_array()) { + let shown: Vec<&str> = ids.iter().take(MAX_RENDER_IDS).filter_map(Value::as_str).collect(); + let _ = write!(out, "\nContact ids: {}", shown.join(", ")); + if ids.len() > shown.len() { + let _ = write!( + out, + "\n(Showing {} of {} ids — page the rest with `smoo audiences members`.)", + shown.len(), + ids.len() + ); + } + } + if materialize { + let written = body.get("materializedCount").map_or_else(|| "0".to_string(), ToString::to_string); + let _ = write!(out, "\nMembership written back: {written} contact(s)."); + } else { + out.push_str("\nThis was a preview — the audience's stored membership was NOT changed. Pass --materialize to write it."); + } + out +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use serde_json::json; + + use super::*; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + fn input(kind: &str) -> CreateInput { + CreateInput { + name: "Hot leads".to_string(), + kind: kind.to_string(), + description: None, + tag_ids: None, + funnel_id: None, + stage_id: None, + created_after: None, + created_before: None, + has_email: false, + has_phone: false, + } + } + + #[test] + fn every_verb_parses() { + assert!(matches!(Wrap::try_parse_from(["t", "list"]).expect("list").cmd, Cmd::List { json: false, .. })); + assert!(matches!( + Wrap::try_parse_from(["t", "list", "--json"]).expect("list json").cmd, + Cmd::List { json: true, .. } + )); + match Wrap::try_parse_from(["t", "create", "--name", "VIPs", "--kind", "static"]).expect("create").cmd { + Cmd::Create { name, kind, .. } => { + assert_eq!(name, "VIPs"); + assert_eq!(kind, "static"); + } + _ => panic!("expected Create"), + } + assert!( + Wrap::try_parse_from(["t", "create", "--name", "x", "--kind", "fuzzy"]).is_err(), + "unknown kind must be refused" + ); + match Wrap::try_parse_from(["t", "members", "aud-1", "--limit", "10", "--offset", "20"]) + .expect("members") + .cmd + { + Cmd::Members { + audience_id, limit, offset, .. + } => { + assert_eq!(audience_id, "aud-1"); + assert_eq!(limit, Some(10)); + assert_eq!(offset, Some(20)); + } + _ => panic!("expected Members"), + } + match Wrap::try_parse_from(["t", "add-members", "aud-1", "--contacts", "a,b"]) + .expect("add-members") + .cmd + { + Cmd::AddMembers { contacts, .. } => assert_eq!(contacts, vec!["a", "b"]), + _ => panic!("expected AddMembers"), + } + assert!(Wrap::try_parse_from(["t", "add-members", "aud-1"]).is_err(), "add-members requires --contacts"); + assert!(matches!( + Wrap::try_parse_from(["t", "resolve", "aud-1"]).expect("resolve").cmd, + Cmd::Resolve { materialize: false, .. } + )); + assert!(matches!( + Wrap::try_parse_from(["t", "resolve", "aud-1", "--materialize"]) + .expect("resolve materialize") + .cmd, + Cmd::Resolve { materialize: true, .. } + )); + } + + #[test] + fn segment_without_filters_is_refused() { + let err = create_body(&input("segment")).expect_err("empty segment"); + assert!(format!("{err}").contains("EVERY contact"), "{err}"); + } + + #[test] + fn static_without_filters_is_fine() { + let body = create_body(&input("static")).expect("static"); + assert_eq!(body, json!({ "name": "Hot leads", "kind": "static" })); + } + + #[test] + fn empty_name_is_refused() { + let mut i = input("static"); + i.name = " ".to_string(); + assert!(create_body(&i).is_err()); + } + + #[test] + fn segment_filters_map_to_camel_case() { + let mut i = input("segment"); + i.description = Some("recent, reachable".to_string()); + i.tag_ids = Some(vec!["t1".to_string(), "t2".to_string()]); + i.funnel_id = Some("f1".to_string()); + i.created_after = Some("2026-01-01T00:00:00Z".to_string()); + i.has_email = true; + let body = create_body(&i).expect("segment"); + assert_eq!(body["description"], json!("recent, reachable")); + assert_eq!(body["filter"]["tagIds"], json!(["t1", "t2"])); + assert_eq!(body["filter"]["funnelId"], json!("f1")); + assert_eq!(body["filter"]["createdAfter"], json!("2026-01-01T00:00:00Z")); + assert_eq!(body["filter"]["hasEmail"], json!(true)); + assert!(body["filter"].get("hasPhone").is_none(), "unset boolean filters stay absent"); + } + + #[test] + fn add_members_reports_the_gap() { + let full = render_add_members(&json!({ "addedCount": 2 }), 2, "aud-1"); + assert_eq!(full, "Added 2 of 2 contact(s) to audience aud-1."); + let partial = render_add_members(&json!({ "addedCount": 1 }), 3, "aud-1"); + assert!(partial.contains("Added 1 of 3"), "{partial}"); + assert!(partial.contains("The other 2"), "{partial}"); + } + + #[test] + fn resolve_preview_says_nothing_changed() { + let out = render_resolve(&json!({ "matchedCount": 4, "kind": "segment" }), "aud-1", false); + assert!(out.contains("matches 4 contact(s)"), "{out}"); + assert!(out.contains("NOT changed"), "{out}"); + let written = render_resolve(&json!({ "matchedCount": 4, "materializedCount": 4 }), "aud-1", true); + assert!(written.contains("written back: 4"), "{written}"); + } + + #[test] + fn resolve_reports_id_truncation() { + let ids: Vec = (0..60).map(|i| format!("c{i}")).collect(); + let out = render_resolve(&json!({ "matchedCount": 60, "contactIds": ids }), "aud-1", false); + assert!(out.contains("Showing 50 of 60 ids"), "{out}"); } } diff --git a/crates/smooth-cli/src/smooai/campaigns.rs b/crates/smooth-cli/src/smooai/campaigns.rs index ce48c0c4..99c20e4a 100644 --- a/crates/smooth-cli/src/smooai/campaigns.rs +++ b/crates/smooth-cli/src/smooai/campaigns.rs @@ -1,18 +1,353 @@ -//! `smoo campaigns …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo campaigns …` — one-shot email/SMS campaigns: list, delivery +//! analytics, and a preview-first send. CLI twin of the hosted MCP +//! `campaign_*` tools (pearl th-b1f09c). +//! +//! `send` is PREVIEW BY DEFAULT: without `--confirm` it posts the server's +//! dry-run mode (`{"dryRun": true}`), which reports the would-be recipient +//! count and every suppression refusal while sending nothing. Only +//! `--confirm` posts `{"dryRun": false}`. Suppression itself is enforced +//! server-side per recipient at send time — never re-implemented here. -use anyhow::Result; +use std::fmt::Write as _; + +use anyhow::{Context, Result}; use clap::Subcommand; +use serde_json::{json, Value}; + +use super::{print_json, require_active_org, require_authed}; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// List the org's campaigns, optionally narrowed by type or status. + List { + /// Only campaigns of this type, e.g. email, sms, social. + #[arg(long = "type")] + campaign_type: Option, + /// Only campaigns in this status: draft, scheduled, active, paused, completed. + #[arg(long)] + status: Option, + /// Print the raw JSON instead of the compact list. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Delivery + engagement metrics for one campaign (sent, delivered, opened, clicked, …). + Analytics { + /// The campaign id from `smoo campaigns list`. + campaign_id: String, + /// Which delivery surface — must match the campaign's own type (the routes are separate). + #[arg(long, default_value = "email", value_parser = ["email", "sms"])] + channel: String, + /// Print the raw JSON instead of the key: value lines. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Send a campaign to its whole recipient list. PREVIEW by default — + /// reports recipient count + suppression refusals and sends NOTHING. + /// Pass --confirm to actually send (no per-contact undo). + Send { + /// The campaign id from `smoo campaigns list`. + campaign_id: String, + /// Which send surface — must match the campaign's own type. + #[arg(long, default_value = "email", value_parser = ["email", "sms"])] + channel: String, + /// Actually send. Without this flag the server runs a dry run and nothing goes out. + #[arg(long)] + confirm: bool, + /// Print the raw JSON response instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::List { + campaign_type, + status, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + // The route takes no filters and returns every campaign, so type + // and status are applied here (case-insensitive) — same as the + // hosted MCP tool. + let body = client.get(&format!("/organizations/{o}/campaigns")).await.context("GET campaigns")?; + let rows: Vec = body + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|r| matches_filters(r, campaign_type.as_deref(), status.as_deref())) + .collect(); + if as_json { + print_json(&json!({ "data": rows })); + } else if rows.is_empty() { + println!("\nNo campaigns matched. This is a confirmed read of the campaign list, not a read failure.\n"); + } else { + println!(); + for r in &rows { + let id = r.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let name = r.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let ctype = r.get("type").and_then(|v| v.as_str()).unwrap_or("?"); + let cstatus = r.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {id} {name} [{ctype}/{cstatus}]"); + } + println!("\n{} campaign(s).\n", rows.len()); + } + } + Cmd::Analytics { + campaign_id, + channel, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let body = client + .get(&format!( + "/organizations/{o}/{channel}-campaigns/{}/analytics", + urlencoding::encode(campaign_id.trim()) + )) + .await + .context("GET campaign analytics")?; + if as_json { + print_json(&body); + } else { + println!("\nDelivery results for {channel} campaign {campaign_id}\n{}\n", render_metrics(&body)); + } + } + Cmd::Send { + campaign_id, + channel, + confirm, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let body = client + .post( + &format!("/organizations/{o}/{channel}-campaigns/{}/send", urlencoding::encode(campaign_id.trim())), + Some(&send_payload(confirm)), + ) + .await + .context("POST campaign send")?; + if as_json { + print_json(&body); + } else { + println!("\n{}\n", render_send(&body, &campaign_id, confirm)); + } + } + } + Ok(()) +} + +/// True when the row passes the optional (case-insensitive) type/status +/// filters. The campaigns route returns everything, so this is where +/// `--type`/`--status` are applied — mirroring the hosted MCP tool. +fn matches_filters(row: &Value, campaign_type: Option<&str>, status: Option<&str>) -> bool { + let want = |key: &str, filter: Option<&str>| -> bool { + filter + .map(str::trim) + .filter(|f| !f.is_empty()) + .is_none_or(|f| row.get(key).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case(f))) + }; + want("type", campaign_type) && want("status", status) +} + +/// The send body: server-side dry run unless the user passed `--confirm`. +/// The preview/commit decision lives in ONE place so the parse test that +/// pins "no --confirm ⇒ dryRun true" is testing the real payload. +fn send_payload(confirm: bool) -> Value { + json!({ "dryRun": !confirm }) +} + +/// Flat metrics object as sorted `key: value` lines. Not a fixed key list — +/// email and SMS analytics carry different metric sets. +fn render_metrics(body: &Value) -> String { + let Some(object) = body.as_object() else { + return "No delivery metrics were returned for this campaign.".to_string(); + }; + let mut lines: Vec = object + .iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, v)| format!("{k}: {}", v.as_str().map_or_else(|| v.to_string(), ToString::to_string))) + .collect(); + if lines.is_empty() { + return "No delivery metrics recorded for this campaign yet. This is a confirmed read, not a read failure.".to_string(); + } + lines.sort(); + lines.join("\n") +} + +/// Human summary of a send response. Always reports the suppression +/// refusals — they are the compliance answer for why someone was not +/// contacted — exactly like the hosted MCP tool. +fn render_send(body: &Value, campaign_id: &str, confirm: bool) -> String { + let number = |key: &str| body.get(key).and_then(Value::as_u64).unwrap_or(0); + let mut out = if confirm { + // The two send paths report differently by design: the synchronous + // route returns sent/failed, the durable starter 202s with sendingTo. + if body.get("started").and_then(Value::as_bool) == Some(true) { + format!("Campaign {campaign_id} started — {} recipient(s) queued for delivery.", number("sendingTo")) + } else { + format!("Campaign {campaign_id} sent to {} recipient(s); {} failed.", number("sent"), number("failed")) + } + } else { + format!( + "PREVIEW ONLY — nothing was sent. Campaign {campaign_id} would be sent to {} recipient(s).", + number("wouldSend") + ) + }; + + let skipped = body.get("skippedRecipients").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + if skipped.is_empty() { + out.push_str("\nNobody was refused by suppression."); + } else { + let reasons = body.get("skippedReasons").map(ToString::to_string).unwrap_or_default(); + let _ = write!(out, "\n\nRefused by the server ({} — {reasons}):", skipped.len()); + for row in &skipped { + let who = row.get("identifier").and_then(Value::as_str).unwrap_or("?"); + let why = row.get("reason").and_then(Value::as_str).unwrap_or("?"); + let _ = write!(out, "\n- {who}: {why}"); + } + out.push_str("\n(`unsubscribed` and `opted_out` are suppression rules — they cannot be overridden from here.)"); + } + if !confirm { + out.push_str("\n\nTo actually send, confirm the count with the user, then re-run with --confirm."); + } + out +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use serde_json::json; + + use super::*; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + #[test] + fn list_parses_with_and_without_filters() { + let bare = Wrap::try_parse_from(["t", "list"]).expect("bare list"); + assert!(matches!( + bare.cmd, + Cmd::List { + campaign_type: None, + status: None, + json: false, + .. + } + )); + let full = Wrap::try_parse_from(["t", "list", "--type", "email", "--status", "draft", "--json"]).expect("filtered list"); + match full.cmd { + Cmd::List { + campaign_type, status, json, .. + } => { + assert_eq!(campaign_type.as_deref(), Some("email")); + assert_eq!(status.as_deref(), Some("draft")); + assert!(json); + } + _ => panic!("expected List"), + } + } + + #[test] + fn analytics_parses_and_defaults_to_email() { + let got = Wrap::try_parse_from(["t", "analytics", "camp-1"]).expect("analytics"); + match got.cmd { + Cmd::Analytics { campaign_id, channel, .. } => { + assert_eq!(campaign_id, "camp-1"); + assert_eq!(channel, "email"); + } + _ => panic!("expected Analytics"), + } + assert!( + Wrap::try_parse_from(["t", "analytics", "camp-1", "--channel", "postal"]).is_err(), + "unknown channel must be refused" + ); + } + + /// THE invariant of this module: `send` without `--confirm` parses into + /// the preview path, and that path posts a server-side dry run. + #[test] + fn send_without_confirm_is_preview() { + let got = Wrap::try_parse_from(["t", "send", "camp-1"]).expect("bare send"); + match got.cmd { + Cmd::Send { confirm, .. } => { + assert!(!confirm, "send must default to preview"); + assert_eq!(send_payload(confirm), json!({ "dryRun": true })); + } + _ => panic!("expected Send"), + } + } + + #[test] + fn send_with_confirm_commits() { + let got = Wrap::try_parse_from(["t", "send", "camp-1", "--confirm", "--channel", "sms"]).expect("confirmed send"); + match got.cmd { + Cmd::Send { confirm, channel, .. } => { + assert!(confirm); + assert_eq!(channel, "sms"); + assert_eq!(send_payload(confirm), json!({ "dryRun": false })); + } + _ => panic!("expected Send"), + } + } + + #[test] + fn filters_match_case_insensitively() { + let row = json!({ "type": "email", "status": "Draft" }); + assert!(matches_filters(&row, None, None)); + assert!(matches_filters(&row, Some("EMAIL"), Some("draft"))); + assert!(matches_filters(&row, Some(" email "), None), "filters are trimmed"); + assert!(!matches_filters(&row, Some("sms"), None)); + assert!(!matches_filters(&row, None, Some("active"))); + // A row missing the field never matches a set filter. + assert!(!matches_filters(&json!({}), Some("email"), None)); + } + + #[test] + fn render_send_preview_reports_suppression() { + let body = json!({ + "wouldSend": 40, + "skippedRecipients": [{ "identifier": "a@x.com", "reason": "unsubscribed" }], + "skippedReasons": { "unsubscribed": 1 }, + }); + let out = render_send(&body, "camp-1", false); + assert!(out.contains("PREVIEW ONLY"), "{out}"); + assert!(out.contains("40 recipient(s)"), "{out}"); + assert!(out.contains("a@x.com: unsubscribed"), "{out}"); + assert!(out.contains("--confirm"), "{out}"); + } + + #[test] + fn render_send_commit_reports_both_shapes() { + let sync = render_send(&json!({ "sent": 10, "failed": 2, "skippedRecipients": [] }), "c", true); + assert!(sync.contains("sent to 10 recipient(s); 2 failed"), "{sync}"); + assert!(sync.contains("Nobody was refused"), "{sync}"); + let durable = render_send(&json!({ "started": true, "sendingTo": 7 }), "c", true); + assert!(durable.contains("7 recipient(s) queued"), "{durable}"); + } + + #[test] + fn render_metrics_handles_empty_and_flat() { + assert!(render_metrics(&json!({})).contains("confirmed read")); + let out = render_metrics(&json!({ "sent": 5, "opened": 2, "skipMe": null })); + assert_eq!(out, "opened: 2\nsent: 5"); } } diff --git a/crates/smooth-cli/src/smooai/drip.rs b/crates/smooth-cli/src/smooai/drip.rs index 9113dd2d..efce9972 100644 --- a/crates/smooth-cli/src/smooai/drip.rs +++ b/crates/smooth-cli/src/smooai/drip.rs @@ -1,18 +1,449 @@ -//! `smoo drip …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo drip …` — drip (nurture) sequences: list, inspect, enrollments, +//! enroll/cancel, and a test send to your own inbox. CLI twin of the hosted +//! MCP `drip_*` tools (pearl th-b1f09c). +//! +//! `enroll` takes explicit contact ids only (never a whole segment) and is +//! capped at 25 per call, mirroring the MCP tool: bulk sending is what +//! campaigns are for. Suppression (unsubscribed / opted out) is enforced by +//! the SERVER per contact and reported back — never re-implemented here. -use anyhow::Result; +use std::fmt::Write as _; + +use anyhow::{bail, Context, Result}; use clap::Subcommand; +use serde_json::{json, Value}; + +use super::{print_json, require_active_org, require_authed}; + +/// Most contacts a single `enroll` call will start — sales-sequence sized on +/// purpose, same cap as the hosted MCP tool. +const MAX_ENROLL_COHORT: usize = 25; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// List the org's drip sequences. + Sequences { + /// Print the raw JSON instead of the compact list. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// One drip sequence with its ordered steps (send / wait / branch). + Show { + /// The sequence id from `smoo drip sequences`. + sequence_id: String, + /// Print the raw JSON instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Who is enrolled in a sequence, with per-status counts. + Enrollments { + /// The sequence id from `smoo drip sequences`. + sequence_id: String, + /// Only enrollments in this status (e.g. active, completed, stopped). + #[arg(long)] + status: Option, + /// Print the raw JSON instead of the compact list. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Enrol SPECIFIC contacts (by id) into a sequence — real email reaches + /// real people. Max 25 per call; suppressed contacts are refused by the + /// server and reported, never silently dropped. + Enroll { + /// The sequence id from `smoo drip sequences`. + sequence_id: String, + /// Contact ids to enrol, comma-separated (from `smoo crm` / audience members). + #[arg(long, value_delimiter = ',', required = true)] + contacts: Vec, + /// Print the raw JSON response instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Stop ONE contact's enrollment, leaving everyone else's running. + Cancel { + /// The sequence id from `smoo drip sequences`. + sequence_id: String, + /// The contact whose enrollment should stop. + contact_id: String, + /// Print the raw JSON response instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Render drip copy with sample variables and email it to YOUR OWN inbox + /// only — never to a contact. Requires a signed-in user session. + TestSend { + /// The sequence id — supplies the sender identity and sample variables. + sequence_id: String, + /// Subject line to render and preview. + #[arg(long)] + subject: String, + /// Email body to render and preview (template variables get sample values). + #[arg(long)] + body: String, + /// Print the raw JSON response instead of the summary. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } +#[allow(clippy::too_many_lines)] // one arm per verb, same shape as the sibling modules pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::Sequences { json: as_json, org } => { + let o = require_active_org(&client, org)?; + let body = client.get(&format!("/organizations/{o}/drip-sequences")).await.context("GET drip sequences")?; + if as_json { + print_json(&body); + return Ok(()); + } + let rows = body.get("data").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + if rows.is_empty() { + println!("\nNo drip sequences in this org. This is a confirmed read, not a read failure.\n"); + return Ok(()); + } + println!(); + for r in &rows { + let id = r.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let name = r.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let status = r.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + let channel = r.get("channel").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {id} {name} [{status}] {channel}"); + } + println!("\n{} sequence(s).\n", rows.len()); + } + Cmd::Show { + sequence_id, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let body = client + .get(&format!("/organizations/{o}/drip-sequences/{}", urlencoding::encode(sequence_id.trim()))) + .await + .context("GET drip sequence")?; + if as_json { + print_json(&body); + return Ok(()); + } + println!("\n{}\n", render_sequence(&body)); + } + Cmd::Enrollments { + sequence_id, + status, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let mut path = format!("/organizations/{o}/drip-sequences/{}/enrollments", urlencoding::encode(sequence_id.trim())); + if let Some(s) = status.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + let _ = write!(path, "?status={}", urlencoding::encode(s)); + } + let body = client.get(&path).await.context("GET drip enrollments")?; + if as_json { + print_json(&body); + return Ok(()); + } + println!("\n{}\n", render_enrollments(&body)); + } + Cmd::Enroll { + sequence_id, + contacts, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + validate_cohort(contacts.len())?; + let asked = contacts.len(); + let body = client + .post( + &format!("/organizations/{o}/drip-sequences/{}/enroll", urlencoding::encode(sequence_id.trim())), + Some(&json!({ "contactIds": contacts })), + ) + .await + .context("POST drip enroll")?; + if as_json { + print_json(&body); + return Ok(()); + } + println!("\n{}\n", render_enroll(&body, asked)); + } + Cmd::Cancel { + sequence_id, + contact_id, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let body = client + .post( + &format!( + "/organizations/{o}/drip-sequences/{}/enrollments/{}/cancel", + urlencoding::encode(sequence_id.trim()), + urlencoding::encode(contact_id.trim()) + ), + Some(&json!({})), + ) + .await + .context("POST drip cancel enrollment")?; + if as_json { + print_json(&body); + return Ok(()); + } + // The route distinguishes a real cancel from an idempotent repeat — + // say which, so a no-op isn't reported as a cancel. + if body.get("cancelled").and_then(Value::as_bool).unwrap_or(false) { + println!("\nEnrollment cancelled — that contact receives no further steps. Everyone else is unaffected.\n"); + } else { + println!("\nThat enrollment was already cancelled — nothing changed.\n"); + } + } + Cmd::TestSend { + sequence_id, + subject, + body: email_body, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let body = client + .post( + &format!("/organizations/{o}/drip-sequences/{}/test-send", urlencoding::encode(sequence_id.trim())), + Some(&json!({ "subject": subject, "body": email_body })), + ) + .await + .context("POST drip test send")?; + if as_json { + print_json(&body); + return Ok(()); + } + let to = body.get("sentTo").and_then(Value::as_str).unwrap_or("your account email"); + println!("\nTest email sent to {to} (nobody else received it).\n"); + } + } + Ok(()) +} + +/// Refuse an empty or oversized enroll cohort. Refuses rather than truncating: +/// silently enrolling the first 25 of 500 and reporting success would hide the +/// gap from the person who believes the job is done. +fn validate_cohort(count: usize) -> Result<()> { + if count == 0 { + bail!("no contact ids given — name the contacts to enrol with --contacts"); + } + if count > MAX_ENROLL_COHORT { + bail!( + "{count} contacts is more than this command will enrol at once (max {MAX_ENROLL_COHORT}). \ + It's for one-off and small-cohort follow-up — use a campaign for a bulk send, or enrol in smaller batches." + ); + } + Ok(()) +} + +/// Human summary of one sequence + its steps. +fn render_sequence(body: &Value) -> String { + let s = |key: &str| body.get(key).and_then(Value::as_str).unwrap_or("?"); + let mut out = format!("{} {} [{}] {}", s("id"), s("name"), s("status"), s("channel")); + let steps = body.get("steps").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + if steps.is_empty() { + out.push_str("\n\nNo steps configured — this sequence would send nothing."); + return out; + } + let _ = write!(out, "\n\nSteps ({}):", steps.len()); + for (i, step) in steps.iter().enumerate() { + let kind = step.get("kind").and_then(Value::as_str).unwrap_or("?"); + let subject = step.get("subject").and_then(Value::as_str).unwrap_or(""); + let wait = step + .get("waitDuration") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| step.get("waitSeconds").and_then(Value::as_u64).map(|n| format!("{n}s"))); + let _ = write!(out, "\n{}. {kind} {subject}", i + 1); + if let Some(w) = wait { + let _ = write!(out, " (wait {w})"); + } + } + out +} + +/// Human summary of the enrollments list + the per-status counts map (which +/// the paginated rows can't convey). +fn render_enrollments(body: &Value) -> String { + let rows = body.get("data").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let mut out = if rows.is_empty() { + "No enrollments matched. This is a confirmed read, not a read failure.".to_string() + } else { + let mut s = String::new(); + for r in &rows { + let email = r.get("contactEmail").and_then(Value::as_str).unwrap_or("?"); + let status = r.get("status").and_then(Value::as_str).unwrap_or("?"); + let contact = r.get("contactId").and_then(Value::as_str).unwrap_or("?"); + let _ = writeln!(s, " {contact} {email} [{status}]"); + } + let _ = write!(s, "\n{} enrollment(s) shown.", rows.len()); + s + }; + if let Some(counts) = body.get("counts").and_then(|c| c.as_object()).filter(|c| !c.is_empty()) { + let summary = counts.iter().map(|(status, n)| format!("{status}: {n}")).collect::>().join(", "); + let _ = write!(out, "\nTotals by status — {summary}"); + } + out +} + +/// Human summary of an enroll response. Reports BOTH halves — the server's +/// suppression refusals and per-contact failures are the compliance answer +/// for why someone wasn't contacted. +fn render_enroll(body: &Value, asked: usize) -> String { + let enrolled = body.get("enrolled").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let skipped = body.get("skipped").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let failed: Vec<&Value> = enrolled.iter().filter(|r| r.get("error").is_some_and(|e| !e.is_null())).collect(); + let started = enrolled.len() - failed.len(); + + let mut out = format!("Enrolled {started} of {asked} contact(s)."); + if !skipped.is_empty() { + let _ = write!(out, "\n\nRefused by the server ({}):", skipped.len()); + for row in &skipped { + let who = row.get("contactId").and_then(Value::as_str).unwrap_or("?"); + let why = row.get("reason").and_then(Value::as_str).unwrap_or("?"); + let _ = write!(out, "\n- {who}: {why}"); + } + out.push_str("\n(`unsubscribed` and `opted_out` are suppression rules — they cannot be overridden from here.)"); + } + if !failed.is_empty() { + let _ = write!(out, "\n\nFailed to start ({}):", failed.len()); + for row in &failed { + let who = row.get("contactId").and_then(Value::as_str).unwrap_or("?"); + let err = row.get("error").map(ToString::to_string).unwrap_or_default(); + let _ = write!(out, "\n- {who}: {err}"); + } + } + out +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use serde_json::json; + + use super::*; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + #[test] + fn every_verb_parses() { + assert!(matches!( + Wrap::try_parse_from(["t", "sequences"]).expect("sequences").cmd, + Cmd::Sequences { json: false, .. } + )); + assert!(matches!( + Wrap::try_parse_from(["t", "sequences", "--json"]).expect("sequences json").cmd, + Cmd::Sequences { json: true, .. } + )); + assert!(matches!(Wrap::try_parse_from(["t", "show", "seq-1"]).expect("show").cmd, Cmd::Show { .. })); + match Wrap::try_parse_from(["t", "enrollments", "seq-1", "--status", "active"]) + .expect("enrollments") + .cmd + { + Cmd::Enrollments { sequence_id, status, .. } => { + assert_eq!(sequence_id, "seq-1"); + assert_eq!(status.as_deref(), Some("active")); + } + _ => panic!("expected Enrollments"), + } + match Wrap::try_parse_from(["t", "enroll", "seq-1", "--contacts", "a,b,c"]).expect("enroll").cmd { + Cmd::Enroll { contacts, .. } => assert_eq!(contacts, vec!["a", "b", "c"]), + _ => panic!("expected Enroll"), + } + assert!(Wrap::try_parse_from(["t", "enroll", "seq-1"]).is_err(), "enroll requires --contacts"); + match Wrap::try_parse_from(["t", "cancel", "seq-1", "contact-1"]).expect("cancel").cmd { + Cmd::Cancel { sequence_id, contact_id, .. } => { + assert_eq!(sequence_id, "seq-1"); + assert_eq!(contact_id, "contact-1"); + } + _ => panic!("expected Cancel"), + } + match Wrap::try_parse_from(["t", "test-send", "seq-1", "--subject", "Hi", "--body", "Hello {{name}}"]) + .expect("test-send") + .cmd + { + Cmd::TestSend { subject, body, .. } => { + assert_eq!(subject, "Hi"); + assert_eq!(body, "Hello {{name}}"); + } + _ => panic!("expected TestSend"), + } + assert!( + Wrap::try_parse_from(["t", "test-send", "seq-1", "--subject", "Hi"]).is_err(), + "test-send requires --body" + ); + } + + #[test] + fn cohort_guard_refuses_empty_and_oversized() { + assert!(validate_cohort(0).is_err()); + assert!(validate_cohort(1).is_ok()); + assert!(validate_cohort(MAX_ENROLL_COHORT).is_ok()); + let err = validate_cohort(MAX_ENROLL_COHORT + 1).expect_err("over cap"); + assert!(format!("{err}").contains("max 25"), "{err}"); + } + + #[test] + fn render_enroll_reports_skips_and_failures() { + let body = json!({ + "enrolled": [ + { "contactId": "c1" }, + { "contactId": "c2", "error": "boom" }, + ], + "skipped": [{ "contactId": "c3", "reason": "unsubscribed" }], + }); + let out = render_enroll(&body, 3); + assert!(out.contains("Enrolled 1 of 3"), "{out}"); + assert!(out.contains("c3: unsubscribed"), "{out}"); + assert!(out.contains("Failed to start (1)"), "{out}"); + assert!(out.contains("cannot be overridden"), "{out}"); + } + + #[test] + fn render_enrollments_empty_is_a_real_answer() { + let out = render_enrollments(&json!({ "data": [], "counts": { "active": 2 } })); + assert!(out.contains("confirmed read"), "{out}"); + assert!(out.contains("active: 2"), "{out}"); + } + + #[test] + fn render_sequence_flags_empty_steps() { + let out = render_sequence(&json!({ "id": "s1", "name": "Welcome", "status": "active", "channel": "email", "steps": [] })); + assert!(out.contains("would send nothing"), "{out}"); + let with_steps = render_sequence(&json!({ + "id": "s1", "name": "Welcome", "status": "active", "channel": "email", + "steps": [ + { "kind": "send", "subject": "Hi" }, + { "kind": "wait", "waitSeconds": 3600 }, + ], + })); + assert!(with_steps.contains("Steps (2):"), "{with_steps}"); + assert!(with_steps.contains("wait 3600s"), "{with_steps}"); } } diff --git a/crates/smooth-cli/src/smooai/files.rs b/crates/smooth-cli/src/smooai/files.rs index 8f56aab7..7bc19fae 100644 --- a/crates/smooth-cli/src/smooai/files.rs +++ b/crates/smooth-cli/src/smooai/files.rs @@ -6,6 +6,8 @@ //! bearer-less client — a presigned URL carries its own auth in the query, and //! an extra `Authorization` header makes S3 reject the request. +use std::fmt::Write as _; + use anstream::println; use anyhow::{bail, Context, Result}; use chrono::{Duration, Utc}; @@ -45,6 +47,36 @@ pub enum Cmd { #[arg(long)] json: bool, }, + /// Find files anywhere in the org by name (case-insensitive substring). + Search { + /// Name fragment to search for, matched anywhere in the filename. + query: String, + /// Narrow by MIME-type fragment, e.g. `pdf`, `image`, `csv`. + #[arg(long = "type")] + mime_type: Option, + /// Restrict the search to one folder id. + #[arg(long)] + folder: Option, + /// Max files to return (1–100, default 25). + #[arg(long)] + limit: Option, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + /// Print raw JSON instead of the listing. + #[arg(long)] + json: bool, + }, + /// Read a file's text contents (extracted markdown → raw text → a reason + /// when there is no text yet). Mirrors the MCP `files_summarize` tool. + Summarize { + /// The file id from `th files ls` or `th files search`. + file_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + /// Print the raw JSON response instead of the text. + #[arg(long)] + json: bool, + }, /// Create a folder. Mkdir { /// Folder name. @@ -220,6 +252,53 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { print_listing(&folders, &files); } } + Cmd::Search { + query, + mime_type, + folder, + limit, + org, + json, + } => { + let o = require_active_org(&client, org)?; + let q = query.trim(); + if q.is_empty() { + bail!("query is empty — pass part of the filename to search for"); + } + let mut path = format!( + "/organizations/{o}/files?search={}&limit={}", + urlencoding::encode(q), + limit.unwrap_or(25).clamp(1, 100) + ); + if let Some(t) = &mime_type { + let _ = write!(path, "&type={}", urlencoding::encode(t)); + } + if let Some(f) = &folder { + let _ = write!(path, "&folderId={}", urlencoding::encode(f)); + } + let files = client.get(&path).await.context("GET files search")?; + if json { + print_json(&files); + } else { + // No folders envelope in a search — reuse the listing printer + // with an empty one ("empty" IS the no-match answer). + print_listing(&json!({}), &files); + } + } + Cmd::Summarize { file_id, org, json } => { + let o = require_active_org(&client, org)?; + let resp = client + .get(&format!("/organizations/{o}/files/{file_id}/content")) + .await + .context("GET file content")?; + if json { + print_json(&resp); + } else { + println!(); + println!("{}", content_text(&resp)); + println!(); + } + } Cmd::Mkdir { name, parent, org } => { let o = require_active_org(&client, org)?; let body = json!({ "name": name, "parentFolderId": parent }); @@ -384,6 +463,30 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { Ok(()) } +/// Human rendering of the `/files/{id}/content` response: the text itself with +/// a truncation note when the route cut it (`totalCharacters` > what came +/// back), or the route's `reason` when there is no readable text — that is an +/// answer (the file exists, nobody has extracted text yet), not an error. +fn content_text(resp: &serde_json::Value) -> String { + let str_field = |k: &str| resp.get(k).and_then(|v| v.as_str()); + let name = str_field("name").unwrap_or("this file"); + match str_field("content").filter(|c| !c.trim().is_empty()) { + None => str_field("reason").map_or_else( + || format!("\"{name}\" has no readable text. This is a confirmed read, not a read failure."), + std::string::ToString::to_string, + ), + Some(content) => { + let chars = content.chars().count() as u64; + let total = resp.get("totalCharacters").and_then(serde_json::Value::as_u64).unwrap_or(chars); + if total > chars { + format!("Contents of \"{name}\" — first {chars} of {total} characters (TRUNCATED by the API):\n\n{content}") + } else { + format!("Contents of \"{name}\" ({chars} characters):\n\n{content}") + } + } + } +} + /// `root` / empty → move to org root (null); anything else → that folder id. fn dest_folder_value(dest: &str) -> serde_json::Value { if dest.is_empty() || dest == "root" { @@ -569,7 +672,7 @@ fn print_listing(folders: &serde_json::Value, files: &serde_json::Value) { mod tests { use serde_json::json; - use super::{dest_folder_value, find_file_name, guess_mime}; + use super::{content_text, dest_folder_value, find_file_name, guess_mime}; #[test] fn dest_root_and_empty_map_to_null() { @@ -618,4 +721,63 @@ mod tests { let off = Wrap::try_parse_from(["t", "ls"]).expect("bare ls must still parse"); assert!(matches!(off.cmd, Cmd::Ls { json: false, .. }), "--json must default to off"); } + + /// MCP parity (pearl th-088c93): `search` and `summarize` mirror the + /// hosted `files_search` / `files_summarize` tools. + #[test] + fn search_and_summarize_parse() { + use clap::Parser; + + use super::Cmd; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + let s = Wrap::try_parse_from(["t", "search", "Q3 report", "--type", "pdf", "--folder", "f1", "--limit", "5", "--json"]).expect("search must parse"); + match s.cmd { + Cmd::Search { + query, + mime_type, + folder, + limit, + json, + .. + } => { + assert_eq!(query, "Q3 report"); + assert_eq!(mime_type.as_deref(), Some("pdf")); + assert_eq!(folder.as_deref(), Some("f1")); + assert_eq!(limit, Some(5)); + assert!(json); + } + _ => panic!("parsed the wrong variant"), + } + assert!(Wrap::try_parse_from(["t", "search"]).is_err(), "search requires a query"); + + let m = Wrap::try_parse_from(["t", "summarize", "file-123"]).expect("summarize must parse"); + assert!(matches!(m.cmd, Cmd::Summarize { ref file_id, json: false, .. } if file_id == "file-123")); + assert!(Wrap::try_parse_from(["t", "summarize"]).is_err(), "summarize requires a file id"); + } + + #[test] + fn content_text_reports_truncation_and_reasons() { + // Exact read: no truncation note. + let full = json!({ "name": "a.txt", "content": "hello", "totalCharacters": 5 }); + assert_eq!(content_text(&full), "Contents of \"a.txt\" (5 characters):\n\nhello"); + + // Route cut the file: the note names both numbers. + let cut = json!({ "name": "big.pdf", "content": "abc", "totalCharacters": 999 }); + let text = content_text(&cut); + assert!(text.contains("first 3 of 999 characters"), "got: {text}"); + assert!(text.contains("TRUNCATED")); + + // No text yet: the route's reason IS the answer. + let none = json!({ "name": "scan.pdf", "content": "", "reason": "ingest it into knowledge first" }); + assert_eq!(content_text(&none), "ingest it into knowledge first"); + + // No text and no reason: still an answer, not an error. + let bare = json!({ "name": "blob.bin" }); + assert!(content_text(&bare).contains("no readable text")); + } } diff --git a/crates/smooth-cli/src/smooai/forms.rs b/crates/smooth-cli/src/smooai/forms.rs index 9ae7e6d5..1ce5e8c2 100644 --- a/crates/smooth-cli/src/smooai/forms.rs +++ b/crates/smooth-cli/src/smooai/forms.rs @@ -1,18 +1,194 @@ -//! `smoo forms …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo forms …` — Google Forms this org created, and their responses. +//! CLI twin of the hosted MCP `forms_list` tool (pearl th-a5d991). -use anyhow::Result; +use anstream::println; +use anyhow::{Context, Result}; use clap::Subcommand; +use owo_colors::OwoColorize; + +use super::{print_json, require_active_org, require_authed}; + +/// Responses rendered per call — a popular form is thousands of rows, and the +/// cut is always stated in the output. Mirrors the MCP tool's cap. +const MAX_FORM_RESPONSES: usize = 50; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// List the Google Forms this org created through Smoo (title, question + /// count, share link), or pass a `formId` from the list to read that + /// form's submitted responses with the question titles filled in. + /// Requires a signed-in user session (not an org API key). + List { + /// A Google `formId` from the list — reads that form's responses. + form_id: Option, + /// Print raw JSON instead of the compact listing. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::List { form_id, json, org } => { + let o = require_active_org(&client, org)?; + let Some(form_id) = form_id else { + let resp = client.get(&format!("/organizations/{o}/google-forms")).await.context("GET google-forms")?; + if json { + print_json(&resp); + } else { + print_forms(&resp); + } + return Ok(()); + }; + let resp = client + .get(&format!("/organizations/{o}/google-forms/{}/responses", urlencoding::encode(&form_id))) + .await + .context("GET form responses")?; + if json { + print_json(&resp); + } else { + print_responses(&resp); + } + } + } + Ok(()) +} + +fn print_forms(body: &serde_json::Value) { + // Enveloped `{items: […]}` or a bare top-level array — the routes answer + // with either (same contract as the MCP server's `rows()`). + let Some(items) = body.as_array().or_else(|| body.get("items").and_then(|v| v.as_array())) else { + print_json(body); + return; + }; + println!(); + if items.is_empty() { + println!(" {} {}", "●".dimmed(), "No forms exist yet.".dimmed()); + println!(); + return; + } + for f in items { + let id = f.get("formId").and_then(|v| v.as_str()).unwrap_or("?"); + let title = f.get("title").and_then(|v| v.as_str()).unwrap_or("(untitled)"); + let questions = f.get("questionCount").and_then(serde_json::Value::as_u64).unwrap_or(0); + println!( + " {} {} {} {}", + "○".dimmed(), + id.cyan(), + title.bold(), + format!("({questions} questions)").dimmed() + ); + if let Some(uri) = f.get("responderUri").and_then(|v| v.as_str()) { + println!(" {}", uri.dimmed()); + } + } + if let Some(total) = body.get("total").and_then(serde_json::Value::as_u64) { + if total > items.len() as u64 { + println!(); + println!(" Showing {} of {total} forms.", items.len()); + } + } + println!(); +} + +fn print_responses(body: &serde_json::Value) { + let title = body.get("title").and_then(|v| v.as_str()).unwrap_or("(untitled)"); + let titles = body.get("questionTitles").and_then(|t| t.as_object()); + let responses = body.get("responses").and_then(|r| r.as_array()).map(Vec::as_slice).unwrap_or_default(); + println!(); + if responses.is_empty() { + println!(" {} — no responses submitted yet.", title.bold()); + println!(); + return; + } + println!(" {} — {} response(s)", title.bold(), responses.len()); + for (i, response) in responses.iter().take(MAX_FORM_RESPONSES).enumerate() { + let submitted = response.get("submittedAt").and_then(|v| v.as_str()).unwrap_or(""); + println!(); + println!(" {}. {}", i + 1, submitted.dimmed()); + let Some(answers) = response.get("answers").and_then(|a| a.as_object()) else { + continue; + }; + for (question_id, values) in answers { + // Fall back to the raw id rather than dropping the answer: an + // unmapped question (added after form creation) still carries a reply. + let label = titles + .and_then(|t| t.get(question_id)) + .and_then(serde_json::Value::as_str) + .filter(|t| !t.is_empty()) + .unwrap_or(question_id); + let text = answer_text(values); + println!(" {label}: {text}"); + } + } + if responses.len() > MAX_FORM_RESPONSES { + println!(); + println!(" Showing {MAX_FORM_RESPONSES} of {} responses.", responses.len()); + } + println!(); +} + +/// One answer's values joined — the route sends each answer as an array of strings. +fn answer_text(values: &serde_json::Value) -> String { + values + .as_array() + .map(|vs| vs.iter().filter_map(serde_json::Value::as_str).collect::>().join(", ")) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use serde_json::json; + + use super::{answer_text, Cmd}; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + #[test] + fn list_parses_bare() { + let w = Wrap::try_parse_from(["t", "list"]).expect("bare list must parse"); + assert!(matches!( + w.cmd, + Cmd::List { + form_id: None, + json: false, + org: None + } + )); + } + + #[test] + fn list_parses_form_id_positional() { + let w = Wrap::try_parse_from(["t", "list", "abc123"]).expect("form id must parse"); + match w.cmd { + Cmd::List { form_id, .. } => assert_eq!(form_id.as_deref(), Some("abc123")), + } + } + + #[test] + fn list_parses_json_and_org_flags() { + let w = Wrap::try_parse_from(["t", "list", "--json", "--org-id", "o1"]).expect("flags must parse"); + match w.cmd { + Cmd::List { json, org, .. } => { + assert!(json); + assert_eq!(org.as_deref(), Some("o1")); + } + } + } + + #[test] + fn answer_text_joins_values() { + assert_eq!(answer_text(&json!(["a", "b"])), "a, b"); + assert_eq!(answer_text(&json!([])), ""); + assert_eq!(answer_text(&json!("not-an-array")), ""); } } diff --git a/crates/smooth-cli/src/smooai/gbp.rs b/crates/smooth-cli/src/smooai/gbp.rs index 3bc28f08..d6337f3e 100644 --- a/crates/smooth-cli/src/smooai/gbp.rs +++ b/crates/smooth-cli/src/smooai/gbp.rs @@ -1,18 +1,229 @@ -//! `smoo gbp …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo gbp …` — Google Business Profile reviews. +//! CLI twin of the hosted MCP `gbp_reviews` tool (pearl th-a5d991). +//! +//! A discovery ladder, same as the MCP tool: no `--account` lists the +//! connected accounts, `--account` alone lists its locations, and +//! `--account` + `--location` reads the reviews. -use anyhow::Result; +use anstream::println; +use anyhow::{bail, Context, Result}; use clap::Subcommand; +use owo_colors::OwoColorize; + +use super::{print_json, require_active_org, require_authed}; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// Read the customer reviews on this org's Google Business Profile. + /// Run with no flags to list the connected GBP accounts, then with + /// `--account` to list that account's locations, then with both + /// `--account` and `--location` for the reviews. Pass the `name` + /// (resource name) field from each step, not the display title. + /// Requires a signed-in user session (not an org API key). + Reviews { + /// GBP account resource name (`accounts/123…`), from the account list. + #[arg(long, value_name = "ACCOUNTS/ID")] + account: Option, + /// Location resource name (`locations/456…`), from the location list. + #[arg(long, value_name = "LOCATIONS/ID")] + location: Option, + /// Print raw JSON instead of the compact listing. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::Reviews { account, location, json, org } => { + let o = require_active_org(&client, org)?; + let account = account.filter(|v| !v.trim().is_empty()); + let location = location.filter(|v| !v.trim().is_empty()); + let Some(account) = account else { + if location.is_some() { + bail!("--location needs --account too — run `smoo gbp reviews` with no flags to list the accounts first"); + } + let resp = client + .get(&format!("/organizations/{o}/business/google/accounts")) + .await + .context("GET GBP accounts")?; + if json { + print_json(&resp); + } else { + print_accounts(&resp); + } + return Ok(()); + }; + let Some(location) = location else { + let resp = client + .get(&format!( + "/organizations/{o}/business/google/locations?accountName={}", + urlencoding::encode(&account) + )) + .await + .context("GET GBP locations")?; + if json { + print_json(&resp); + } else { + print_locations(&resp); + } + return Ok(()); + }; + let resp = client + .get(&format!( + "/organizations/{o}/business/google/reviews?accountName={}&locationName={}", + urlencoding::encode(&account), + urlencoding::encode(&location) + )) + .await + .context("GET GBP reviews")?; + if json { + print_json(&resp); + } else { + print_reviews(&resp); + } + } + } + Ok(()) +} + +/// Rows under `key`, accepting the enveloped shape or a bare top-level array — +/// the routes answer with either (same contract as the MCP server's `rows()`). +fn rows<'a>(body: &'a serde_json::Value, key: &str) -> Option<&'a Vec> { + body.as_array().or_else(|| body.get(key).and_then(|v| v.as_array())) +} + +fn print_accounts(body: &serde_json::Value) { + let Some(rows) = rows(body, "accounts") else { + print_json(body); + return; + }; + println!(); + if rows.is_empty() { + println!(" {} {}", "●".dimmed(), "No Business Profile accounts connected yet.".dimmed()); + println!(); + return; + } + for a in rows { + // `name` is the resource name (`accounts/123`) the next step wants; + // `accountName` is the human-readable business name. Both, in that order. + let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let title = a.get("accountName").and_then(|v| v.as_str()).unwrap_or(""); + let kind = a.get("type").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {} {} {} {}", "○".dimmed(), name.cyan(), title.bold(), format!("[{kind}]").dimmed()); + } + println!(); + println!(" Next: smoo gbp reviews --account "); + println!(); +} + +fn print_locations(body: &serde_json::Value) { + let Some(rows) = rows(body, "locations") else { + print_json(body); + return; + }; + println!(); + if rows.is_empty() { + println!(" {} {}", "●".dimmed(), "No locations on that account.".dimmed()); + println!(); + return; + } + for l in rows { + let name = l.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let title = l.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let site = l.get("websiteUri").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {} {} {} {}", "○".dimmed(), name.cyan(), title.bold(), site.dimmed()); + } + println!(); + println!(" Next: smoo gbp reviews --account --location "); + println!(); +} + +fn print_reviews(body: &serde_json::Value) { + let Some(rows) = rows(body, "reviews") else { + print_json(body); + return; + }; + println!(); + if rows.is_empty() { + println!(" {} {}", "●".dimmed(), "No reviews on that location yet.".dimmed()); + println!(); + return; + } + for r in rows { + let stars = r.get("starRating").and_then(|v| v.as_str()).unwrap_or("?"); + let reviewer = r + .get("reviewer") + .and_then(|v| v.get("displayName")) + .and_then(|v| v.as_str()) + .unwrap_or("(anonymous)"); + let when = r.get("createTime").and_then(|v| v.as_str()).unwrap_or(""); + // Whether the org already answered is the one fact you need before + // acting — surfaced so nobody drafts a second reply to the same review. + let replied = if r.get("reviewReply").is_some_and(|v| !v.is_null()) { + " [replied]" + } else { + "" + }; + println!(" {} {} — {} {}{}", "○".dimmed(), stars.bold(), reviewer, when.dimmed(), replied.dimmed()); + if let Some(comment) = r.get("comment").and_then(|v| v.as_str()) { + for line in comment.lines() { + println!(" {line}"); + } + } + } + println!(); +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::Cmd; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + #[test] + fn reviews_parses_bare() { + let w = Wrap::try_parse_from(["t", "reviews"]).expect("bare reviews must parse"); + assert!(matches!( + w.cmd, + Cmd::Reviews { + account: None, + location: None, + json: false, + org: None + } + )); + } + + #[test] + fn reviews_parses_account_and_location() { + let w = Wrap::try_parse_from(["t", "reviews", "--account", "accounts/1", "--location", "locations/2"]).expect("flags must parse"); + match w.cmd { + Cmd::Reviews { account, location, .. } => { + assert_eq!(account.as_deref(), Some("accounts/1")); + assert_eq!(location.as_deref(), Some("locations/2")); + } + } + } + + #[test] + fn reviews_parses_json_and_org() { + let w = Wrap::try_parse_from(["t", "reviews", "--json", "--org-id", "o1"]).expect("flags must parse"); + match w.cmd { + Cmd::Reviews { json, org, .. } => { + assert!(json); + assert_eq!(org.as_deref(), Some("o1")); + } + } } } diff --git a/crates/smooth-cli/src/smooai/heypage.rs b/crates/smooth-cli/src/smooai/heypage.rs index ed55fd69..a67f1f5c 100644 --- a/crates/smooth-cli/src/smooai/heypage.rs +++ b/crates/smooth-cli/src/smooai/heypage.rs @@ -93,6 +93,110 @@ pub enum Cmd { #[arg(long = "org-id", visible_alias = "org")] org: Option, }, + /// Publish history, newest first — publishIds (for `rollback`) + view links. + Versions { + /// Site id. Or use `--slug`. + #[arg(long, conflicts_with = "slug")] + site: Option, + /// Site slug (resolved to an id via `list`). + #[arg(long)] + slug: Option, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Put the LIVE site back to a previous publish. Appends a new publish + /// rather than erasing history, so a rollback can itself be rolled back. + Rollback { + /// Site id. Or use `--slug`. + #[arg(long, conflicts_with = "slug")] + site: Option, + /// Site slug (resolved to an id via `list`). + #[arg(long)] + slug: Option, + /// A specific publishId from `versions`. Omit for the one before the current live publish. + #[arg(long = "publish-id")] + publish_id: Option, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Read/write a site's SOURCE: component HTML/CSS/JS, stylesheet rules, page SEO text. + Source { + #[command(subcommand)] + cmd: SourceCmd, + }, + /// Read/write a site's editable slot CONTENT (text, images, links) — no regeneration. + Content { + #[command(subcommand)] + cmd: ContentCmd, + }, +} + +#[derive(Subcommand)] +pub enum SourceCmd { + /// Every component's HTML/CSS/JS + slots + the site's design brief. + Get { + /// Site id. Or use `--slug`. + #[arg(long, conflicts_with = "slug")] + site: Option, + /// Site slug (resolved to an id via `list`). + #[arg(long)] + slug: Option, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Write source back: body JSON `{components?, siteCssEdits?, pages?}`. + /// Components are replaced WHOLE — never a fragment. Creates a DRAFT; + /// nothing is public until `publish`. + Set { + /// Site id. Or use `--slug`. + #[arg(long, conflicts_with = "slug")] + site: Option, + /// Site slug (resolved to an id via `list`). + #[arg(long)] + slug: Option, + /// Body JSON (file path, or `-` for stdin). + #[arg(long)] + body: String, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, +} + +#[derive(Subcommand)] +pub enum ContentCmd { + /// Every editable slot, addressed by page path + instanceIndex + slot name. + /// Read this before `content set` — its slot names are the only valid targets. + Get { + /// Site id. Or use `--slug`. + #[arg(long, conflicts_with = "slug")] + site: Option, + /// Site slug (resolved to an id via `list`). + #[arg(long)] + slug: Option, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Edit slot values: body JSON `{path, updates: [{instanceIndex, content: {slot: value}}]}`. + /// A direct edit, not a regeneration. Creates a DRAFT; nothing is public until `publish`. + Set { + /// Site id. Or use `--slug`. + #[arg(long, conflicts_with = "slug")] + site: Option, + /// Site slug (resolved to an id via `list`). + #[arg(long)] + slug: Option, + /// Body JSON (file path, or `-` for stdin). + #[arg(long)] + body: String, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } /// Read the `--brief` file and shape it into the generate request body. @@ -159,6 +263,28 @@ fn find_slug_id(list: &serde_json::Value, slug: &str) -> Option { .and_then(|s| s.get("id").and_then(|v| v.as_str()).map(str::to_string)) } +/// Mirror the MCP guard: refuse an empty source-edit body before the roundtrip. +fn require_source_edits(body: &serde_json::Value) -> Result<()> { + let has = |k: &str| body.get(k).and_then(|v| v.as_array()).is_some_and(|a| !a.is_empty()); + if has("components") || has("siteCssEdits") || has("pages") { + Ok(()) + } else { + anyhow::bail!("no edits — the body needs a non-empty `components`, `siteCssEdits`, or `pages` array") + } +} + +/// Mirror the MCP guard: a content edit needs a `path` (`""` = home) and at +/// least one entry in `updates`. +fn require_content_edits(body: &serde_json::Value) -> Result<()> { + if body.get("path").and_then(|v| v.as_str()).is_none() { + anyhow::bail!("body needs a `path` string (\"\" = home page)"); + } + if body.get("updates").and_then(|v| v.as_array()).is_none_or(Vec::is_empty) { + anyhow::bail!("no edits — the body needs a non-empty `updates` array"); + } + Ok(()) +} + pub async fn cmd(cmd: Cmd) -> Result<()> { let client = require_authed().await?; match cmd { @@ -276,6 +402,75 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { .context("GET heypage/sites/{id}")?, ); } + Cmd::Versions { site, slug, org } => { + let o = require_active_org(&client, org)?; + let site = resolve_site_id(&client, &o, site, slug).await?; + print_json( + &client + .get(&format!("/organizations/{o}/heypage/sites/{site}/versions")) + .await + .context("GET heypage/sites/{id}/versions")?, + ); + } + Cmd::Rollback { site, slug, publish_id, org } => { + let o = require_active_org(&client, org)?; + let site = resolve_site_id(&client, &o, site, slug).await?; + let body = publish_id.map_or_else(|| json!({}), |id| json!({ "publishId": id })); + print_json( + &client + .post(&format!("/organizations/{o}/heypage/sites/{site}/rollback"), Some(&body)) + .await + .context("POST heypage/sites/{id}/rollback")?, + ); + } + Cmd::Source { cmd } => match cmd { + SourceCmd::Get { site, slug, org } => { + let o = require_active_org(&client, org)?; + let site = resolve_site_id(&client, &o, site, slug).await?; + print_json( + &client + .get(&format!("/organizations/{o}/heypage/sites/{site}/source")) + .await + .context("GET heypage/sites/{id}/source")?, + ); + } + SourceCmd::Set { site, slug, body, org } => { + let o = require_active_org(&client, org)?; + let site = resolve_site_id(&client, &o, site, slug).await?; + let payload = read_body(&body)?; + require_source_edits(&payload)?; + print_json( + &client + .put(&format!("/organizations/{o}/heypage/sites/{site}/source"), &payload) + .await + .context("PUT heypage/sites/{id}/source")?, + ); + } + }, + Cmd::Content { cmd } => match cmd { + ContentCmd::Get { site, slug, org } => { + let o = require_active_org(&client, org)?; + let site = resolve_site_id(&client, &o, site, slug).await?; + print_json( + &client + .get(&format!("/organizations/{o}/heypage/sites/{site}/editable-content")) + .await + .context("GET heypage/sites/{id}/editable-content")?, + ); + } + ContentCmd::Set { site, slug, body, org } => { + let o = require_active_org(&client, org)?; + let site = resolve_site_id(&client, &o, site, slug).await?; + let payload = read_body(&body)?; + require_content_edits(&payload)?; + print_json( + &client + .patch(&format!("/organizations/{o}/heypage/sites/{site}/content"), &payload) + .await + .context("PATCH heypage/sites/{id}/content")?, + ); + } + }, } Ok(()) } @@ -330,4 +525,70 @@ mod tests { let bare = json!({ "slug": "beta" }); assert_eq!(live_url(&bare).unwrap(), "https://heypage.ai/p/beta"); } + + /// MCP parity (pearl th-088c93): `versions`/`rollback`/`source`/`content` + /// mirror the hosted `site_*` tools. + #[test] + fn parity_verbs_parse() { + use clap::Parser; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + let v = Wrap::try_parse_from(["t", "versions", "--site", "s1"]).expect("versions must parse"); + assert!(matches!(v.cmd, Cmd::Versions { site: Some(ref s), .. } if s == "s1")); + + let r = Wrap::try_parse_from(["t", "rollback", "--slug", "acme", "--publish-id", "p9"]).expect("rollback must parse"); + match r.cmd { + Cmd::Rollback { site, slug, publish_id, .. } => { + assert_eq!(site, None); + assert_eq!(slug.as_deref(), Some("acme")); + assert_eq!(publish_id.as_deref(), Some("p9")); + } + _ => panic!("parsed the wrong variant"), + } + // publish-id is optional: bare rollback restores the previous publish. + let bare = Wrap::try_parse_from(["t", "rollback", "--site", "s1"]).expect("rollback without --publish-id must parse"); + assert!(matches!(bare.cmd, Cmd::Rollback { publish_id: None, .. })); + // --site and --slug are mutually exclusive. + assert!(Wrap::try_parse_from(["t", "rollback", "--site", "s1", "--slug", "acme"]).is_err()); + + let sg = Wrap::try_parse_from(["t", "source", "get", "--site", "s1"]).expect("source get must parse"); + assert!(matches!(sg.cmd, Cmd::Source { cmd: SourceCmd::Get { .. } })); + let ss = Wrap::try_parse_from(["t", "source", "set", "--site", "s1", "--body", "-"]).expect("source set must parse"); + assert!(matches!(ss.cmd, Cmd::Source { cmd: SourceCmd::Set { ref body, .. } } if body == "-")); + assert!( + Wrap::try_parse_from(["t", "source", "set", "--site", "s1"]).is_err(), + "source set requires --body" + ); + + let cg = Wrap::try_parse_from(["t", "content", "get", "--slug", "acme"]).expect("content get must parse"); + assert!(matches!(cg.cmd, Cmd::Content { cmd: ContentCmd::Get { .. } })); + let cs = Wrap::try_parse_from(["t", "content", "set", "--site", "s1", "--body", "edits.json"]).expect("content set must parse"); + assert!(matches!(cs.cmd, Cmd::Content { cmd: ContentCmd::Set { .. } })); + } + + #[test] + fn source_edit_guard_requires_a_nonempty_edit_array() { + assert!(require_source_edits(&json!({ "components": [{ "id": "hero", "html": "
" }] })).is_ok()); + assert!(require_source_edits(&json!({ "siteCssEdits": [{ "op": "upsert", "selector": ".x" }] })).is_ok()); + assert!(require_source_edits(&json!({ "pages": [{ "path": "", "title": "T" }] })).is_ok()); + assert!(require_source_edits(&json!({})).is_err()); + assert!(require_source_edits(&json!({ "components": [] })).is_err(), "empty arrays are not edits"); + assert!(require_source_edits(&json!({ "components": "hero" })).is_err(), "non-array shapes are refused"); + } + + #[test] + fn content_edit_guard_requires_path_and_updates() { + let ok = json!({ "path": "", "updates": [{ "instanceIndex": 0, "content": { "heading": "Hi" } }] }); + assert!(require_content_edits(&ok).is_ok(), "\"\" is the home page, a valid path"); + assert!(require_content_edits(&json!({ "updates": [{}] })).is_err(), "path is required"); + assert!(require_content_edits(&json!({ "path": "about" })).is_err(), "updates is required"); + assert!( + require_content_edits(&json!({ "path": "about", "updates": [] })).is_err(), + "empty updates are not edits" + ); + } } diff --git a/crates/smooth-cli/src/smooai/observability.rs b/crates/smooth-cli/src/smooai/observability.rs index d7bd1f98..1b50c6b2 100644 --- a/crates/smooth-cli/src/smooai/observability.rs +++ b/crates/smooth-cli/src/smooai/observability.rs @@ -137,6 +137,26 @@ pub enum Cmd { #[command(flatten)] common: Common, }, + /// Custom metrics — the catalog the org emits, one metric charted over + /// time, and the attribute keys it can be broken down by. + #[command(visible_alias = "metric")] + Metrics { + #[command(subcommand)] + cmd: MetricsCmd, + }, + /// Real-user Core Web Vitals (LCP, FCP, INP, TTFB, CLS) at p75, overall + /// and per route — measured in visitors' browsers, not synthetic tests. + WebVitals { + /// Look-back window: `90s`, `45m`, `6h`, `7d`. Defaults to 24h — an + /// hour of real traffic is usually too thin a p75 sample. + #[arg(long, default_value = "24h")] + since: String, + /// Only this environment, e.g. `production`. + #[arg(long)] + environment: Option, + #[command(flatten)] + common: Common, + }, /// Platform audit trail — who did what in the org, and whether it worked. /// /// This is the tamper-evident org record, not the local `th audit` tool @@ -353,6 +373,63 @@ pub enum LlmCmd { }, } +#[derive(Subcommand)] +pub enum MetricsCmd { + /// The metrics this org actually emits — name, kind, unit, service, and + /// when each was last seen. Start here: `query` needs an exact name. + List { + /// Only metrics emitted by this service. + #[arg(long)] + service: Option, + /// Only metrics from this environment, e.g. `production`. + #[arg(long)] + environment: Option, + /// Max metrics (1–500). + #[arg(long, default_value_t = 200)] + limit: u32, + #[command(flatten)] + common: Common, + }, + /// Chart one metric over a window as time buckets — mean per bucket, or + /// p50/p95/p99 with `--mode percentiles` (histogram metrics only). + Query { + /// Exact metric name, from `metrics list`. + metric_name: String, + /// Look-back window: `90s`, `45m`, `6h`, `7d`. + #[arg(long, default_value = DEFAULT_SINCE)] + since: String, + /// Bucket width in minutes (1–1440). + #[arg(long, default_value_t = 1)] + bucket_minutes: u64, + /// `mean` or `percentiles`. + #[arg(long, default_value = "mean")] + mode: String, + /// Attribute key to break the series down by (repeatable) — see + /// `metrics attributes`. + #[arg(long)] + group_by: Vec, + /// Only points from this service. + #[arg(long)] + service: Option, + /// Only points from this environment. + #[arg(long)] + environment: Option, + #[command(flatten)] + common: Common, + }, + /// The attribute keys recorded on one metric — the dimensions `query + /// --group-by` accepts. + Attributes { + /// Exact metric name, from `metrics list`. + metric_name: String, + /// How far back to sample for keys, in hours (1–168). + #[arg(long, default_value_t = 24)] + lookback_hours: u32, + #[command(flatten)] + common: Common, + }, +} + pub async fn cmd(cmd: Cmd) -> Result<()> { let client = require_authed().await?; match cmd { @@ -514,6 +591,12 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { let resp = pipeline_health(&client, &org).await?; emit(&resp, json, render_health); } + Cmd::Metrics { cmd } => metrics(cmd, &client).await?, + Cmd::WebVitals { since, environment, common } => { + let org = require_active_org(&client, common.org)?; + let resp = web_vitals(&client, &org, &since, environment.as_deref()).await?; + emit(&resp, common.json, |r| render_web_vitals(r, &since)); + } Cmd::Monitors { common } => { let org = require_active_org(&client, common.org)?; let resp = open_incidents(&client, &org).await?; @@ -691,6 +774,56 @@ async fn llm(cmd: LlmCmd, client: &SmoothApiClient) -> Result<()> { Ok(()) } +async fn metrics(cmd: MetricsCmd, client: &SmoothApiClient) -> Result<()> { + match cmd { + MetricsCmd::List { + service, + environment, + limit, + common, + } => { + let org = require_active_org(client, common.org)?; + let resp = metrics_list(client, &org, service.as_deref(), environment.as_deref(), limit).await?; + emit(&resp, common.json, |r| render_metrics(r, limit as usize)); + } + MetricsCmd::Query { + metric_name, + since, + bucket_minutes, + mode, + group_by, + service, + environment, + common, + } => { + let org = require_active_org(client, common.org)?; + let mode = parse_mode(&mode)?; + let filters = MetricSeriesFilters { + metric_name: metric_name.clone(), + since, + bucket_minutes, + mode: mode.to_string(), + group_by, + service, + environment, + }; + let resp = metrics_timeseries(client, &org, &filters).await?; + emit(&resp, common.json, |r| render_metric_points(r, &metric_name, mode, &filters.since)); + } + MetricsCmd::Attributes { + metric_name, + lookback_hours, + common, + } => { + let org = require_active_org(client, common.org)?; + let hours = lookback_hours.clamp(1, 168); + let resp = metric_attributes(client, &org, &metric_name, hours).await?; + emit(&resp, common.json, |r| render_metric_attributes(r, &metric_name, hours)); + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // Windows + query-string plumbing // --------------------------------------------------------------------------- @@ -1029,6 +1162,122 @@ pub async fn pipeline_health(client: &SmoothApiClient, org: &str) -> Result, environment: Option<&str>, limit: u32) -> Result { + let query = qs(&[ + ("serviceName", opt(service)), + ("environment", opt(environment)), + ("limit", Some(limit.clamp(1, 500).to_string())), + ]); + client + .get(&format!("/organizations/{org}/observability/metrics{query}")) + .await + .context("GET observability/metrics") +} + +/// Filters for [`metrics_timeseries`]. +#[derive(Debug, Default)] +pub struct MetricSeriesFilters { + pub metric_name: String, + /// Relative window (see [`parse_since`]); empty falls back to [`DEFAULT_SINCE`]. + pub since: String, + /// Bucket width in minutes; clamped to 1–1440. + pub bucket_minutes: u64, + /// `mean` or `percentiles` — validate with [`parse_mode`] first. + pub mode: String, + pub group_by: Vec, + pub service: Option, + pub environment: Option, +} + +/// `GET /observability/metrics/timeseries` → `{ points: [...] }`. +/// +/// # Errors +/// Bad `since`, or a non-2xx from the API. +pub async fn metrics_timeseries(client: &SmoothApiClient, org: &str, f: &MetricSeriesFilters) -> Result { + let since = if f.since.trim().is_empty() { DEFAULT_SINCE } else { &f.since }; + let (since_ms, until_ms) = window_ms(since)?; + let group_by: Vec<&str> = f.group_by.iter().map(|k| k.trim()).filter(|k| !k.is_empty()).collect(); + let query = qs(&[ + ("metricName", Some(f.metric_name.clone())), + ("sinceMs", Some(since_ms.to_string())), + ("untilMs", Some(until_ms.to_string())), + ("bucketMs", Some((f.bucket_minutes.clamp(1, 1440) * 60_000).to_string())), + ("mode", Some(f.mode.clone())), + ("groupBy", opt(Some(&group_by.join(",")))), + ("serviceName", opt(f.service.as_deref())), + ("environment", opt(f.environment.as_deref())), + ]); + client + .get(&format!("/organizations/{org}/observability/metrics/timeseries{query}")) + .await + .context("GET observability/metrics/timeseries") +} + +/// `GET /observability/metrics/attributes` → `{ keys: [...] }`. +/// +/// # Errors +/// Non-2xx from the API. +pub async fn metric_attributes(client: &SmoothApiClient, org: &str, metric: &str, lookback_hours: u32) -> Result { + let query = qs(&[("metricName", Some(metric.to_string())), ("lookbackHours", Some(lookback_hours.to_string()))]); + client + .get(&format!("/organizations/{org}/observability/metrics/attributes{query}")) + .await + .context("GET observability/metrics/attributes") +} + +/// `GET /observability/metrics/web-vitals` → `{ points: [...] }` — p75 Core +/// Web Vitals per route. +/// +/// # Errors +/// Bad `since`, or a non-2xx from the API. +pub async fn web_vitals(client: &SmoothApiClient, org: &str, since: &str, environment: Option<&str>) -> Result { + let (since_ms, until_ms) = window_ms(if since.trim().is_empty() { "24h" } else { since })?; + let query = qs(&[ + ("sinceMs", Some(since_ms.to_string())), + ("untilMs", Some(until_ms.to_string())), + ("environment", opt(environment)), + ]); + client + .get(&format!("/organizations/{org}/observability/metrics/web-vitals{query}")) + .await + .context("GET observability/metrics/web-vitals") +} + +/// `mean` or `percentiles`, case-insensitively — refused otherwise, because +/// the rendering keys depend on it and a typo silently charting means as +/// percentiles would be a wrong answer that looks right. +/// +/// (`heatmap` is a real upstream mode, deliberately not offered: it returns +/// raw bucket arrays that are a chart's input, not an answer.) +/// +/// # Errors +/// Anything that is not `mean` or `percentiles`. +pub fn parse_mode(mode: &str) -> Result<&'static str> { + let m = mode.trim(); + if m.is_empty() || m.eq_ignore_ascii_case("mean") { + Ok("mean") + } else if m.eq_ignore_ascii_case("percentiles") { + Ok("percentiles") + } else { + bail!("--mode must be mean or percentiles (got `{mode}`)") + } +} + +/// `since` as a `(sinceMs, untilMs)` epoch-milliseconds pair ending now — the +/// shape the metrics routes want, unlike the RFC3339 pair from [`window`]. +/// +/// # Errors +/// Propagates [`parse_since`]. +pub fn window_ms(since: &str) -> Result<(i64, i64)> { + let end = Utc::now(); + let start = end - parse_since(since)?; + Ok((start.timestamp_millis(), end.timestamp_millis())) +} + /// `GET /website-monitors/incidents` → `{ incidents: [...] }` — every OPEN /// uptime incident org-wide. /// @@ -1393,6 +1642,105 @@ pub fn render_llm_cost(body: &Value, limit: usize) -> String { out.trim_end().to_string() } +/// The metric catalog. +pub fn render_metrics(body: &Value, limit: usize) -> String { + let metrics = rows(body, "metrics"); + if metrics.is_empty() { + return "No metrics recorded for this organization. (The query ran and returned zero rows — a metric only appears once a service exports it, so an empty catalog can also mean telemetry is not flowing yet.)".to_string(); + } + let mut out = format!("{} metric(s):\n", metrics.len()); + for m in metrics { + let _ = writeln!( + out, + "{} [{}] unit={} {} samples={} last {}", + field(m, &["metricName", "metric_name"]), + field(m, &["kind"]), + field(m, &["unit"]), + field(m, &["serviceName", "service_name"]), + field(m, &["sampleCount", "sample_count"]), + field(m, &["lastSeenAt", "last_seen_at"]), + ); + } + out.push_str(truncation_note(metrics.len(), limit, None).trim_end_matches('\n')); + out.trim_end().to_string() +} + +/// One metric's time buckets. `mode` picks the value columns; `since` is only +/// for the empty-result wording. +pub fn render_metric_points(body: &Value, metric: &str, mode: &str, since: &str) -> String { + let points = rows(body, "points"); + if points.is_empty() { + let percentile_note = if mode == "percentiles" { + " Note: only histogram metrics have percentiles — check the metric's kind with `metrics list`." + } else { + "" + }; + return format!("No points recorded for `{metric}` in the last {since}. (The query ran and returned zero rows.){percentile_note}"); + } + let value_keys: &[&str] = if mode == "percentiles" { + &["p50", "p95", "p99", "count"] + } else { + &["value", "count"] + }; + let mut out = format!("`{metric}` over the last {since} ({mode}), {} bucket(s):\n", points.len()); + for p in points { + // A raw epoch-ms column is unreadable when the question is WHEN + // something spiked — render it as a timestamp. + let at = p + .get("bucketMs") + .and_then(Value::as_i64) + .and_then(chrono::DateTime::from_timestamp_millis) + .map_or_else(|| "(unknown time)".to_string(), |d| d.to_rfc3339_opts(SecondsFormat::Secs, true)); + let group = p + .get("groupKey") + .and_then(Value::as_str) + .filter(|g| !g.is_empty() && *g != "_default") + .map(|g| format!(" [{g}]")) + .unwrap_or_default(); + let values: Vec = value_keys.iter().map(|k| format!("{k}={}", field(p, &[k]))).collect(); + let _ = writeln!(out, "{at}{group} {}", values.join(" ")); + } + out.trim_end().to_string() +} + +/// The attribute keys on one metric. +pub fn render_metric_attributes(body: &Value, metric: &str, hours: u32) -> String { + let keys: Vec<&str> = body + .get("keys") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + if keys.is_empty() { + return format!("`{metric}` carries no attributes in the last {hours}h, so there is nothing to group it by. (The query ran and returned zero keys.)"); + } + format!( + "Attributes on `{metric}` (last {hours}h) — pass to `metrics query --group-by`: {}", + keys.join(", ") + ) +} + +/// Core Web Vitals, p75 per route. +pub fn render_web_vitals(body: &Value, since: &str) -> String { + let points = rows(body, "points"); + if points.is_empty() { + return format!( + "No Core Web Vitals recorded in the last {since}. (The query ran and returned zero rows — vitals only appear if the site ships the browser SDK, so an empty result can also mean it is not instrumented.)" + ); + } + let mut out = format!("Core Web Vitals (p75) over the last {since} — {} row(s):\n", points.len()); + for p in points { + let route = p.get("route").and_then(Value::as_str).filter(|r| !r.is_empty()).unwrap_or("(all routes)"); + let _ = writeln!( + out, + "{:<5} {route} p75={} samples={}", + field(p, &["metricName", "metric_name"]), + field(p, &["p75"]), + field(p, &["count"]), + ); + } + out.trim_end().to_string() +} + /// Pipeline freshness. This is the one renderer where "empty" is genuinely /// alarming: no pipes means the health endpoint answered without describing a /// single pipe, which is a broken answer, not a healthy system. @@ -1806,11 +2154,127 @@ mod tests { ], vec!["th", "audit"], vec!["th", "sourcemaps-list", "--release", "v1", "--environment", "production", "--json"], + vec!["th", "metrics", "list", "--service", "api-prime", "--limit", "50", "--json"], + vec!["th", "metric", "list"], + vec![ + "th", + "metrics", + "query", + "http.server.duration", + "--since", + "6h", + "--bucket-minutes", + "5", + "--mode", + "percentiles", + "--group-by", + "route", + "--group-by", + "status", + "--json", + ], + vec!["th", "metrics", "attributes", "http.server.duration", "--lookback-hours", "48", "--json"], + vec!["th", "web-vitals", "--since", "7d", "--environment", "production", "--json"], + vec!["th", "web-vitals"], ] { Harness::try_parse_from(&argv).unwrap_or_else(|e| panic!("{argv:?} must parse: {e}")); } } + // ── Metrics + web vitals ──────────────────────────────────────────────── + + /// The rendering keys depend on the mode, so a typo must be refused — a + /// mean series silently labeled percentiles is wrong without looking wrong. + #[test] + fn mode_is_mean_or_percentiles_only() { + assert_eq!(parse_mode("mean").expect("mean"), "mean"); + assert_eq!(parse_mode("PERCENTILES").expect("case-insensitive"), "percentiles"); + assert_eq!(parse_mode("").expect("empty falls back"), "mean"); + assert!(parse_mode("heatmap").is_err(), "heatmap is deliberately not offered"); + assert!(parse_mode("p95").is_err()); + } + + #[test] + fn window_ms_brackets_now_in_epoch_millis() { + let (start, end) = window_ms("1h").expect("window"); + assert_eq!(end - start, 3_600_000); + let now = Utc::now().timestamp_millis(); + assert!((now - end).abs() < 5_000, "end must be ~now"); + assert!(window_ms("24").is_err(), "bare numbers stay ambiguous here too"); + } + + #[test] + fn empty_metrics_results_state_that_the_query_ran() { + let cases = vec![ + render_metrics(&json!({ "metrics": [] }), 200), + render_metric_points(&json!({ "points": [] }), "queue.depth", "mean", "1h"), + render_metric_attributes(&json!({ "keys": [] }), "queue.depth", 24), + render_web_vitals(&json!({ "points": [] }), "24h"), + ]; + for text in cases { + let lower = text.to_lowercase(); + assert!(lower.contains("no ") || lower.contains("nothing"), "must say it is empty: {text}"); + assert!( + lower.contains("ran") || lower.contains("returned"), + "must make clear the query SUCCEEDED: {text}" + ); + } + } + + /// Percentiles on a non-histogram metric come back empty — the wording + /// must point at the real cause instead of implying the system is quiet. + #[test] + fn empty_percentiles_mention_the_histogram_caveat() { + let text = render_metric_points(&json!({ "points": [] }), "m", "percentiles", "1h"); + assert!(text.contains("histogram"), "{text}"); + let mean = render_metric_points(&json!({ "points": [] }), "m", "mean", "1h"); + assert!(!mean.contains("histogram"), "the caveat is percentiles-only: {mean}"); + } + + #[test] + fn metric_points_render_timestamps_groups_and_mode_keys() { + let body = json!({ "points": [ + { "bucketMs": 1_755_600_000_000_i64, "groupKey": "route:/api", "value": 12.5, "count": 4 }, + { "bucketMs": 1_755_600_060_000_i64, "groupKey": "_default", "value": 3.0, "count": 1 }, + ]}); + let mean = render_metric_points(&body, "http.server.duration", "mean", "1h"); + assert!(mean.contains("2025") || mean.contains("2026"), "epoch ms must render as a timestamp: {mean}"); + assert!(mean.contains("[route:/api]"), "{mean}"); + assert!(!mean.contains("_default"), "the default group is noise: {mean}"); + assert!(mean.contains("value=12.5"), "{mean}"); + + let pct = json!({ "points": [{ "bucketMs": 1_755_600_000_000_i64, "p50": 1, "p95": 2, "p99": 3, "count": 9 }] }); + let text = render_metric_points(&pct, "m", "percentiles", "1h"); + assert!(text.contains("p95=2") && text.contains("count=9"), "{text}"); + assert!(!text.contains("value="), "percentile rows have no mean value column: {text}"); + } + + #[test] + fn metric_attributes_render_as_a_group_by_hint() { + let text = render_metric_attributes(&json!({ "keys": ["route", "status"] }), "http.server.duration", 24); + assert!(text.contains("route, status") && text.contains("--group-by"), "{text}"); + } + + #[test] + fn web_vitals_render_routes_and_default_the_overall_row() { + let text = render_web_vitals( + &json!({ "points": [ + { "metricName": "LCP", "route": "/pricing", "p75": 2400, "count": 120 }, + { "metricName": "CLS", "route": null, "p75": 0.02, "count": 300 }, + ]}), + "24h", + ); + assert!(text.contains("/pricing") && text.contains("p75=2400"), "{text}"); + assert!(text.contains("(all routes)"), "a null route is the site-wide row: {text}"); + } + + #[test] + fn a_full_metrics_page_reports_that_there_may_be_more() { + let body = json!({ "metrics": [{ "metricName": "a" }, { "metricName": "b" }] }); + assert!(render_metrics(&body, 2).contains("there may be more")); + assert!(!render_metrics(&body, 200).contains("there may be more")); + } + /// The audit window default is load-bearing: a silently wider or narrower /// one makes "nothing happened" mean something different than it says. #[test] diff --git a/crates/smooth-cli/src/smooai/search_console.rs b/crates/smooth-cli/src/smooai/search_console.rs index 41680cac..b928a67a 100644 --- a/crates/smooth-cli/src/smooai/search_console.rs +++ b/crates/smooth-cli/src/smooai/search_console.rs @@ -1,18 +1,187 @@ -//! `smoo search-console …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo search-console …` — Google Search Console sites and top queries. +//! CLI twin of the hosted MCP `search_console_queries` tool (pearl th-a5d991). -use anyhow::Result; +use anstream::println; +use anyhow::{Context, Result}; use clap::Subcommand; +use owo_colors::OwoColorize; + +use super::{print_json, require_active_org, require_authed}; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// Top search queries (clicks, impressions, CTR, average position) for a + /// verified Search Console property. Omit the site to list the verified + /// sites first — pass `siteUrl` exactly as it reads there (e.g. + /// `sc-domain:smoo.ai`). Requires a signed-in user session (not an org + /// API key). + Queries { + /// A verified property's `siteUrl`; omit to list the verified sites. + site_url: Option, + /// Trailing window in days (1-90, default 28). + #[arg(long, value_name = "N")] + days: Option, + /// Max query rows (1-500, default 25). + #[arg(long, value_name = "N")] + limit: Option, + /// Print raw JSON instead of the compact listing. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::Queries { + site_url, + days, + limit, + json, + org, + } => { + let o = require_active_org(&client, org)?; + let Some(site_url) = site_url.filter(|v| !v.trim().is_empty()) else { + let resp = client + .get(&format!("/organizations/{o}/websites/google/search-console/sites")) + .await + .context("GET Search Console sites")?; + if json { + print_json(&resp); + } else { + print_sites(&resp); + } + return Ok(()); + }; + // Clamped here rather than relying on the route's own clamp, so the + // window in the answer is the window that was actually served. + let days = days.unwrap_or(28).clamp(1, 90); + let limit = limit.unwrap_or(25).clamp(1, 500); + let resp = client + .get(&format!( + "/organizations/{o}/websites/google/search-console/queries?siteUrl={}&days={days}&limit={limit}", + urlencoding::encode(&site_url) + )) + .await + .context("GET Search Console queries")?; + if json { + print_json(&resp); + } else { + print_queries(&resp, days); + } + } + } + Ok(()) +} + +/// Rows under `key`, accepting the enveloped shape or a bare top-level array — +/// the routes answer with either (same contract as the MCP server's `rows()`). +fn rows<'a>(body: &'a serde_json::Value, key: &str) -> Option<&'a Vec> { + body.as_array().or_else(|| body.get(key).and_then(|v| v.as_array())) +} + +fn print_sites(body: &serde_json::Value) { + let Some(rows) = rows(body, "sites") else { + print_json(body); + return; + }; + println!(); + if rows.is_empty() { + println!(" {} {}", "●".dimmed(), "No verified Search Console sites yet.".dimmed()); + println!(); + return; + } + for s in rows { + let url = s.get("siteUrl").and_then(|v| v.as_str()).unwrap_or("?"); + let perm = s.get("permissionLevel").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {} {} {}", "○".dimmed(), url.cyan(), format!("[{perm}]").dimmed()); + } + println!(); + println!(" Next: smoo search-console queries "); + println!(); +} + +fn print_queries(body: &serde_json::Value, days: u64) { + let Some(rows) = rows(body, "rows") else { + print_json(body); + return; + }; + println!(); + if rows.is_empty() { + println!(" {} {}", "●".dimmed(), format!("No query data in the last {days} days.").dimmed()); + println!(); + return; + } + println!( + " {}", + format!("top queries, last {days} days (clicks / impressions / ctr / position)").dimmed() + ); + println!(); + for r in rows { + let query = r.get("query").and_then(|v| v.as_str()).unwrap_or("?"); + let clicks = r.get("clicks").and_then(serde_json::Value::as_u64).unwrap_or(0); + let impressions = r.get("impressions").and_then(serde_json::Value::as_u64).unwrap_or(0); + let ctr = r.get("ctr").and_then(serde_json::Value::as_f64).unwrap_or(0.0); + let position = r.get("position").and_then(serde_json::Value::as_f64).unwrap_or(0.0); + println!( + " {} {} {}", + "○".dimmed(), + query.bold(), + format!("{clicks} / {impressions} / {:.1}% / {position:.1}", ctr * 100.0).dimmed() + ); + } + println!(); +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::Cmd; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + #[test] + fn queries_parses_bare() { + let w = Wrap::try_parse_from(["t", "queries"]).expect("bare queries must parse"); + assert!(matches!( + w.cmd, + Cmd::Queries { + site_url: None, + days: None, + limit: None, + json: false, + org: None + } + )); + } + + #[test] + fn queries_parses_site_url_positional() { + let w = Wrap::try_parse_from(["t", "queries", "sc-domain:smoo.ai"]).expect("site url must parse"); + match w.cmd { + Cmd::Queries { site_url, .. } => assert_eq!(site_url.as_deref(), Some("sc-domain:smoo.ai")), + } + } + + #[test] + fn queries_parses_days_limit_json_org() { + let w = + Wrap::try_parse_from(["t", "queries", "sc-domain:smoo.ai", "--days", "7", "--limit", "100", "--json", "--org-id", "o1"]).expect("flags must parse"); + match w.cmd { + Cmd::Queries { days, limit, json, org, .. } => { + assert_eq!(days, Some(7)); + assert_eq!(limit, Some(100)); + assert!(json); + assert_eq!(org.as_deref(), Some("o1")); + } + } } } diff --git a/crates/smooth-cli/src/smooai/sheets.rs b/crates/smooth-cli/src/smooai/sheets.rs index c56d048a..b4e69d12 100644 --- a/crates/smooth-cli/src/smooai/sheets.rs +++ b/crates/smooth-cli/src/smooai/sheets.rs @@ -1,18 +1,192 @@ -//! `smoo sheets …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo sheets …` — Google Sheets snapshots captured into the org. +//! CLI twin of the hosted MCP `sheets_snapshots` tool (pearl th-a5d991). +//! +//! Snapshots are point-in-time captures, not a live sheet read. -use anyhow::Result; +use anstream::println; +use anyhow::{Context, Result}; use clap::Subcommand; +use owo_colors::OwoColorize; + +use super::{print_json, require_active_org, require_authed}; #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// List the Google Sheets snapshots captured into this org (spreadsheet, + /// tab, range, row count, when), newest first — or pass a snapshot id to + /// read that one with its column headers. Requires a signed-in user + /// session (not an org API key). + Snapshots { + /// A snapshot id from the list — shows that snapshot with its columns. + snapshot_id: Option, + /// Page size for the list (1-100, default 25). + #[arg(long, value_name = "N")] + limit: Option, + /// Rows to skip, for paging (default 0). + #[arg(long, value_name = "N")] + offset: Option, + /// Print raw JSON instead of the compact listing. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::Snapshots { + snapshot_id, + limit, + offset, + json, + org, + } => { + let o = require_active_org(&client, org)?; + if let Some(id) = snapshot_id { + let resp = client + .get(&format!("/organizations/{o}/sheets/google/snapshots/{}", urlencoding::encode(&id))) + .await + .context("GET sheet snapshot")?; + if json { + print_json(&resp); + } else { + print_snapshot(&resp); + } + return Ok(()); + } + // 100, not more: the route caps at 100, and a clamp that disagrees + // with the route's own would report a page size the caller never got. + let limit = limit.unwrap_or(25).clamp(1, 100); + let offset = offset.unwrap_or(0); + let resp = client + .get(&format!("/organizations/{o}/sheets/google/snapshots?limit={limit}&offset={offset}")) + .await + .context("GET sheet snapshots")?; + if json { + print_json(&resp); + } else { + print_snapshots(&resp, offset); + } + } + } + Ok(()) +} + +fn snapshot_line(s: &serde_json::Value) -> String { + let title = s.get("spreadsheetTitle").and_then(|v| v.as_str()).unwrap_or("(untitled)"); + let sheet = s.get("sheetName").and_then(|v| v.as_str()).unwrap_or(""); + let range = s.get("range").and_then(|v| v.as_str()).unwrap_or(""); + let rows = s.get("rowCount").and_then(serde_json::Value::as_u64).unwrap_or(0); + let captured = s.get("capturedAt").and_then(|v| v.as_str()).unwrap_or(""); + format!("{title} / {sheet} {range} ({rows} rows) {captured}") +} + +fn print_snapshots(body: &serde_json::Value, offset: u64) { + // Enveloped `{items: […]}` or a bare top-level array — the routes answer + // with either (same contract as the MCP server's `rows()`). + let Some(items) = body.as_array().or_else(|| body.get("items").and_then(|v| v.as_array())) else { + print_json(body); + return; + }; + println!(); + if items.is_empty() { + println!(" {} {}", "●".dimmed(), "No sheet snapshots captured yet.".dimmed()); + println!(); + return; + } + for s in items { + let id = s.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {} {} {}", "○".dimmed(), id.cyan(), snapshot_line(s).bold()); + } + if let Some(total) = body.get("total").and_then(serde_json::Value::as_u64) { + if total > offset + items.len() as u64 { + println!(); + println!( + " Showing {}-{} of {total} snapshots (--offset to page).", + offset + 1, + offset + items.len() as u64 + ); + } + } + println!(); +} + +fn print_snapshot(body: &serde_json::Value) { + println!(); + let id = body.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {} {}", id.cyan(), snapshot_line(body).bold()); + if let Some(url) = body.get("sourceUrl").and_then(|v| v.as_str()) { + println!(" {}", url.dimmed()); + } + if let Some(headers) = body.get("headers").and_then(|h| h.as_array()) { + let names: Vec<&str> = headers.iter().filter_map(serde_json::Value::as_str).collect(); + println!(); + println!(" Columns: {}", names.join(", ")); + } + println!(); +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use serde_json::json; + + use super::{snapshot_line, Cmd}; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + #[test] + fn snapshots_parses_bare() { + let w = Wrap::try_parse_from(["t", "snapshots"]).expect("bare snapshots must parse"); + assert!(matches!( + w.cmd, + Cmd::Snapshots { + snapshot_id: None, + limit: None, + offset: None, + json: false, + org: None + } + )); + } + + #[test] + fn snapshots_parses_id_positional() { + let w = Wrap::try_parse_from(["t", "snapshots", "snap-1"]).expect("id must parse"); + match w.cmd { + Cmd::Snapshots { snapshot_id, .. } => assert_eq!(snapshot_id.as_deref(), Some("snap-1")), + } + } + + #[test] + fn snapshots_parses_limit_offset_json_org() { + let w = Wrap::try_parse_from(["t", "snapshots", "--limit", "50", "--offset", "25", "--json", "--org-id", "o1"]).expect("flags must parse"); + match w.cmd { + Cmd::Snapshots { limit, offset, json, org, .. } => { + assert_eq!(limit, Some(50)); + assert_eq!(offset, Some(25)); + assert!(json); + assert_eq!(org.as_deref(), Some("o1")); + } + } + } + + #[test] + fn snapshot_line_renders_fields() { + let s = json!({ + "spreadsheetTitle": "Leads", + "sheetName": "Q3", + "range": "A1:F200", + "rowCount": 199, + "capturedAt": "2026-08-01T00:00:00Z" + }); + assert_eq!(snapshot_line(&s), "Leads / Q3 A1:F200 (199 rows) 2026-08-01T00:00:00Z"); } } diff --git a/crates/smooth-cli/src/smooai/workforce.rs b/crates/smooth-cli/src/smooai/workforce.rs index 9522407a..300477e3 100644 --- a/crates/smooth-cli/src/smooai/workforce.rs +++ b/crates/smooth-cli/src/smooai/workforce.rs @@ -1,18 +1,189 @@ -//! `smoo workforce …` — scaffold stub; implementation lands in this PR -//! (MCP→CLI parity fan-out, EPIC pearls th-739bb1/th-b1f09c/th-a5d991). +//! `smoo workforce …` — the org's people directory. +//! CLI twin of the hosted MCP `workforce_directory` tool (pearl th-a5d991). -use anyhow::Result; -use clap::Subcommand; +use anstream::println; +use anyhow::{Context, Result}; +use clap::{Subcommand, ValueEnum}; +use owo_colors::OwoColorize; + +use super::{print_json, require_active_org, require_authed}; + +/// Which slice of the directory to read. Mirrors the MCP tool's `view` arg. +#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, ValueEnum)] +pub enum View { + /// Name, email, title, department, manager. + #[default] + Employees, + /// The department tree. + OrgUnits, + /// Employees + units + the manager → report edge count. + OrgChart, +} #[derive(Subcommand)] pub enum Cmd { - /// Placeholder — replaced by the implementing lane in this PR. - #[command(hide = true)] - Todo, + /// Read this org's people directory: `employees` (default), `org-units` + /// (the department tree), or `org-chart` (both plus the manager → report + /// edges). Use for "who works here", "who reports to X". + Directory { + /// Which view to read. + #[arg(value_enum, default_value_t = View::Employees)] + view: View, + /// Print raw JSON instead of the compact listing. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; match cmd { - Cmd::Todo => anyhow::bail!("not implemented yet"), + Cmd::Directory { view, json, org } => { + let o = require_active_org(&client, org)?; + match view { + View::Employees => { + let resp = client + .get(&format!("/organizations/{o}/workforce/employees")) + .await + .context("GET workforce employees")?; + if json { + print_json(&resp); + } else { + print_employees(&resp, "data"); + println!(); + } + } + View::OrgUnits => { + let resp = client + .get(&format!("/organizations/{o}/workforce/org-units")) + .await + .context("GET workforce org-units")?; + if json { + print_json(&resp); + } else { + print_org_units(&resp, "data"); + println!(); + } + } + View::OrgChart => { + let resp = client + .get(&format!("/organizations/{o}/workforce/org-chart")) + .await + .context("GET workforce org-chart")?; + if json { + print_json(&resp); + } else { + print_employees(&resp, "employees"); + print_org_units(&resp, "units"); + // The edges are implied by each employee's managerEmployeeId, + // so report the count rather than repeating every pair. + let edges = resp.get("edges").and_then(|v| v.as_array()).map_or(0, Vec::len); + println!(); + println!(" {edges} manager → report edge(s)."); + println!(); + } + } + } + } + } + Ok(()) +} + +/// Rows under `key`, accepting the enveloped shape or a bare top-level array — +/// the routes answer with either (same contract as the MCP server's `rows()`). +fn rows<'a>(body: &'a serde_json::Value, key: &str) -> Option<&'a Vec> { + body.as_array().or_else(|| body.get(key).and_then(|v| v.as_array())) +} + +fn print_employees(body: &serde_json::Value, key: &str) { + let Some(rows) = rows(body, key) else { + return; + }; + println!(); + if rows.is_empty() { + println!(" {} {}", "●".dimmed(), "No employees in the directory yet.".dimmed()); + return; + } + for e in rows { + let name = e.get("fullName").and_then(|v| v.as_str()).unwrap_or("(unnamed)"); + let email = e.get("primaryEmail").and_then(|v| v.as_str()).unwrap_or(""); + let title = e.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let dept = e.get("department").and_then(|v| v.as_str()).unwrap_or(""); + let role = [title, dept].iter().filter(|s| !s.is_empty()).copied().collect::>().join(", "); + let status = e.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let suffix = if status.is_empty() { String::new() } else { format!(" [{status}]") }; + println!(" {} {} {} {}{}", "○".dimmed(), name.bold(), email.cyan(), role.dimmed(), suffix.dimmed()); + } +} + +fn print_org_units(body: &serde_json::Value, key: &str) { + let Some(rows) = rows(body, key) else { + return; + }; + println!(); + if rows.is_empty() { + println!(" {} {}", "●".dimmed(), "No org units defined yet.".dimmed()); + return; + } + for u in rows { + let name = u.get("name").and_then(|v| v.as_str()).unwrap_or("(unnamed)"); + let path = u.get("orgUnitPath").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {} {} {}", "▸".cyan(), name.bold(), path.dimmed()); + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cmd, View}; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + + #[test] + fn directory_defaults_to_employees() { + let w = Wrap::try_parse_from(["t", "directory"]).expect("bare directory must parse"); + assert!(matches!( + w.cmd, + Cmd::Directory { + view: View::Employees, + json: false, + org: None + } + )); + } + + #[test] + fn directory_parses_each_view() { + for (arg, want) in [("employees", View::Employees), ("org-units", View::OrgUnits), ("org-chart", View::OrgChart)] { + let w = Wrap::try_parse_from(["t", "directory", arg]).expect("view must parse"); + match w.cmd { + Cmd::Directory { view, .. } => assert_eq!(view, want), + } + } + } + + #[test] + fn directory_rejects_unknown_view() { + assert!(Wrap::try_parse_from(["t", "directory", "robots"]).is_err()); + } + + #[test] + fn directory_parses_json_and_org() { + let w = Wrap::try_parse_from(["t", "directory", "org-chart", "--json", "--org-id", "o1"]).expect("flags must parse"); + match w.cmd { + Cmd::Directory { view, json, org } => { + assert_eq!(view, View::OrgChart); + assert!(json); + assert_eq!(org.as_deref(), Some("o1")); + } + } } } diff --git a/docs/Engineering/Using-th-CLI.md b/docs/Engineering/Using-th-CLI.md index 27d3c875..ca21c037 100644 --- a/docs/Engineering/Using-th-CLI.md +++ b/docs/Engineering/Using-th-CLI.md @@ -43,6 +43,13 @@ explicit in the command tree: memory keep working, but `--help` shows the clean split. New docs and skills should use the `smoo …` spelling; this document was swept to it (pearl th-845c06). The old `th ` spellings remain hidden compat aliases. +- **CLI ↔ hosted-MCP parity**: every read surface on mcp.smoo.ai has a CLI + twin — `smoo analytics`, `smoo campaigns` (send is preview-first; real send + needs `--confirm`, suppression stays server-side), `smoo drip`, + `smoo audiences`, `smoo files search|summarize`, `smoo heypage + versions|rollback|source|content`, `smoo api observability metrics` + + `web-vitals`, and the one-offs (`forms`, `gbp`, `search-console`, `sheets`, + `workforce`). Pearl trail: th-739bb1 / th-b1f09c / th-088c93 / th-a5d991. - **The `th agent`/`th agents` collision is gone**: the machine-local mailbox registry owns bare `th agent`, the platform agents live at `smoo agents` (where the singular `smoo agent` aliases the plural, per the normalize rule).