From fc082b6d12f0bf994dff0320cd75407be8096356 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 4 Jul 2026 23:33:27 -0400 Subject: [PATCH 1/3] =?UTF-8?q?SMOODEV-2272:=20Add=20`th=20heypage`=20?= =?UTF-8?q?=E2=80=94=20dogfood=20HeyPage=20through=20the=20real=20product?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the broken TS seed-heypage-examples script (which illegally read SST `Resource.SecretKeySupabaseServiceKey` and died) with a `th heypage` subcommand that drives the SAME endpoints a customer/the web builder hits: generate POST /organizations/{org}/content-items/generate (contentType=website) build generate -> POST heypage/sites -> (optional) publish -> live /p/ publish POST heypage/sites/{id}/publish get GET heypage/sites/{id} Going through the API means server-side @smooai/config owns every secret — no backdoor. Mirrors products.rs (require_authed / require_active_org / print_json / read_body). `--brief` accepts the bare SiteBrief or a {brief, brand?, assetUrls?} wrapper. No `--studio` flag: the studio pipeline is chosen server-side by the heypageStudioPipeline feature flag, not per-request. No `--url` crawl seam: the generate endpoint takes brand/assetUrls directly, there is no URL->brand extract endpoint (follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smooth-cli/src/main.rs | 8 + crates/smooth-cli/src/smooai/heypage.rs | 195 ++++++++++++++++++++++++ crates/smooth-cli/src/smooai/mod.rs | 1 + 3 files changed, 204 insertions(+) create mode 100644 crates/smooth-cli/src/smooai/heypage.rs diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index 152a02a1..5b7e08f3 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -219,6 +219,13 @@ enum Commands { #[command(subcommand)] cmd: smooai::booking::Cmd, }, + /// HeyPage — dogfood the AI website builder through the real product + /// API: `build` (generate → create → publish → live URL), plus + /// `generate` / `publish` / `get`. + Heypage { + #[command(subcommand)] + cmd: smooai::heypage::Cmd, + }, /// Run a pearl through a Smooth operative — dispatches to Big Smooth /// (`th up` must be running) and streams agent events to stdout. Run { @@ -1497,6 +1504,7 @@ async fn main() -> Result<()> { }) => smooai::notify::cmd(message, title, priority, url, org).await, Some(Commands::Testing { cmd }) => smooai::testing::cmd(cmd).await, Some(Commands::Booking { cmd }) => smooai::booking::cmd(cmd).await, + Some(Commands::Heypage { cmd }) => smooai::heypage::cmd(cmd).await, Some(Commands::Operatives { cmd }) => cmd_operatives(cmd).await, Some(Commands::Inbox) => cmd_inbox().await, Some(Commands::Run { pearl_id, model, agent }) => cmd_run(pearl_id.as_deref(), model.as_deref(), agent.as_deref()).await, diff --git a/crates/smooth-cli/src/smooai/heypage.rs b/crates/smooth-cli/src/smooai/heypage.rs new file mode 100644 index 00000000..089ac64e --- /dev/null +++ b/crates/smooth-cli/src/smooai/heypage.rs @@ -0,0 +1,195 @@ +//! `th heypage …` — dogfood HeyPage through the REAL product API. +//! +//! Drives the same endpoints a customer (or the web builder) hits: +//! generate a SiteSpec → create a site → publish it → live at +//! `heypage.ai/p/`. No backdoor: server-side `@smooai/config` +//! owns every secret, so this needs nothing but an authed `th` session +//! and an org with the `heypage` custom app enabled. +//! +//! The generate step reads the `heypageStudioPipeline` feature flag +//! server-side (ADR-065) — there is NO per-request studio toggle, so +//! there's deliberately no `--studio` flag here (flip the flag with +//! `th config set heypageStudioPipeline true --tier feature_flag`). +//! +//! There is also no URL-crawl → brand seam: the generate endpoint takes +//! `brand` (palette hexes + logo) and `assetUrls` directly, so `--url` +//! is a follow-up, not wired. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use serde_json::json; + +use super::{print_json, read_body, require_active_org, require_authed}; + +#[derive(Subcommand)] +pub enum Cmd { + /// Generate a SiteSpec from a brief (no site created). Prints spec + reasoning. + Generate { + /// Brief JSON (file path, or `-` for stdin). Either the SiteBrief + /// object itself (`{businessName, industry, description, goals, pages?}`) + /// or a wrapper `{brief, brand?, assetUrls?}` mirroring the endpoint. + #[arg(long)] + brief: 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, + }, + /// The full dogfood: generate → create site → (optionally) publish. Prints the live /p/ URL. + Build { + /// Brief JSON (file path, or `-` for stdin). See `generate --brief`. + #[arg(long)] + brief: String, + /// Site name — seeds the globally-unique slug. + #[arg(long)] + name: String, + /// Publish after creating (moderates, then goes live). + #[arg(long)] + publish: 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, + }, + /// Publish (moderate, then go live) an existing site. + Publish { + /// Site id. + #[arg(long)] + site: 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, + }, + /// Editor view of a site — pages + latest revisions + publish state. + Get { + /// Site id. + #[arg(long)] + site: 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. +/// Accepts either the bare SiteBrief object or a `{brief, brand?, assetUrls?}` +/// wrapper; injects `contentType: "website"` either way. +fn generate_body(brief_path: &str) -> Result { + let mut body = read_body(brief_path)?; + if body.get("brief").is_none() { + // Bare SiteBrief — wrap it so the endpoint's `body.brief` read finds it. + body = json!({ "brief": body }); + } + body["contentType"] = json!("website"); + Ok(body) +} + +/// Pull the live `heypage.ai/p/` URL out of a create-site response +/// (`{ site: { slug, .. } }`) or its bare-site fallback. +fn live_url(site_resp: &serde_json::Value) -> Option { + let site = site_resp.get("site").unwrap_or(site_resp); + site.get("slug").and_then(|v| v.as_str()).map(|s| format!("https://heypage.ai/p/{s}")) +} + +pub async fn cmd(cmd: Cmd) -> Result<()> { + let client = require_authed().await?; + match cmd { + Cmd::Generate { brief, org } => { + let o = require_active_org(&client, org)?; + let body = generate_body(&brief)?; + print_json( + &client + .post(&format!("/organizations/{o}/content-items/generate"), Some(&body)) + .await + .context("POST content-items/generate")?, + ); + } + Cmd::Build { brief, name, publish, org } => { + let o = require_active_org(&client, org)?; + + // 1. Generate the SiteSpec. + let gen = client + .post(&format!("/organizations/{o}/content-items/generate"), Some(&generate_body(&brief)?)) + .await + .context("POST content-items/generate")?; + let spec = gen.get("spec").context("generate response had no `spec`")?; + + // 2. Create the site from that spec. + let site_resp = client + .post(&format!("/organizations/{o}/heypage/sites"), Some(&json!({ "name": name, "spec": spec }))) + .await + .context("POST heypage/sites")?; + let site = site_resp.get("site").unwrap_or(&site_resp); + let site_id = site + .get("id") + .and_then(|v| v.as_str()) + .context("create-site response had no site.id")? + .to_string(); + + // 3. Publish, if asked. + if publish { + client + .post(&format!("/organizations/{o}/heypage/sites/{site_id}/publish"), None) + .await + .context("POST heypage/sites/{id}/publish (422 = moderation-flagged)")?; + } + + print_json(&json!({ + "siteId": site_id, + "published": publish, + "url": live_url(&site_resp), + })); + } + Cmd::Publish { site, org } => { + let o = require_active_org(&client, org)?; + print_json( + &client + .post(&format!("/organizations/{o}/heypage/sites/{site}/publish"), None) + .await + .context("POST heypage/sites/{id}/publish")?, + ); + } + Cmd::Get { site, org } => { + let o = require_active_org(&client, org)?; + print_json( + &client + .get(&format!("/organizations/{o}/heypage/sites/{site}")) + .await + .context("GET heypage/sites/{id}")?, + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_brief_gets_wrapped_and_typed() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("b.json"); + std::fs::write(&p, r#"{"businessName":"Acme","industry":"x","description":"y","goals":"z"}"#).unwrap(); + let body = generate_body(p.to_str().unwrap()).unwrap(); + assert_eq!(body["contentType"], "website"); + assert_eq!(body["brief"]["businessName"], "Acme"); + } + + #[test] + fn wrapper_brief_is_left_intact() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("b.json"); + std::fs::write(&p, r#"{"brief":{"businessName":"Acme"},"assetUrls":["u"]}"#).unwrap(); + let body = generate_body(p.to_str().unwrap()).unwrap(); + assert_eq!(body["contentType"], "website"); + assert_eq!(body["brief"]["businessName"], "Acme"); + assert_eq!(body["assetUrls"][0], "u"); + } + + #[test] + fn live_url_from_wrapped_and_bare() { + let wrapped = json!({ "site": { "slug": "acme" } }); + assert_eq!(live_url(&wrapped).unwrap(), "https://heypage.ai/p/acme"); + let bare = json!({ "slug": "beta" }); + assert_eq!(live_url(&bare).unwrap(), "https://heypage.ai/p/beta"); + } +} diff --git a/crates/smooth-cli/src/smooai/mod.rs b/crates/smooth-cli/src/smooai/mod.rs index 0a9a2779..be3ad9b1 100644 --- a/crates/smooth-cli/src/smooai/mod.rs +++ b/crates/smooth-cli/src/smooai/mod.rs @@ -13,6 +13,7 @@ pub mod agents; pub mod booking; pub mod copilot; pub mod crm; +pub mod heypage; pub mod integrations; pub mod jobs; pub mod keys; From 5c272dfd06f1659a7af907e37770701e8f36675d Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 4 Jul 2026 23:41:22 -0400 Subject: [PATCH 2/3] SMOODEV-2272: Add `th heypage regen --site` idempotent in-place refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build` creates a NEW site each run (slug de-collides → duplicates), which is wrong for refreshing the 6 LIVE showcase examples. `regen --site --brief ` mirrors the old seed `--regen`: generate a fresh spec → PUT a new revision on each page the site already has (revisable_paths intersects generated-spec paths with the site's existing paths; the PUT endpoint 404s on unknown paths) → publish. The site's slug/URL are unchanged — no duplicate. Build idempotency-by-slug and a `list` command aren't added: there is no `GET /organizations/{org}/heypage/sites` (list) endpoint, so slug→id can't be resolved via the API. Follow-up: add that route, then wire build idempotency. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smooth-cli/src/smooai/heypage.rs | 94 +++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/smooth-cli/src/smooai/heypage.rs b/crates/smooth-cli/src/smooai/heypage.rs index 089ac64e..4ce0b461 100644 --- a/crates/smooth-cli/src/smooai/heypage.rs +++ b/crates/smooth-cli/src/smooai/heypage.rs @@ -49,6 +49,21 @@ pub enum Cmd { #[arg(long = "org-id", visible_alias = "org")] org: Option, }, + /// Regenerate an EXISTING site in place: fresh spec → new revision on each + /// page it already has → publish. Idempotent — updates the live site (its + /// slug/URL are unchanged), never spawns a duplicate. This is the path that + /// refreshes the live showcase examples. + Regen { + /// Site id to regenerate. + #[arg(long)] + site: String, + /// Brief JSON (file path, or `-` for stdin). See `generate --brief`. + #[arg(long)] + brief: 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, + }, /// Publish (moderate, then go live) an existing site. Publish { /// Site id. @@ -82,13 +97,33 @@ fn generate_body(brief_path: &str) -> Result { Ok(body) } -/// Pull the live `heypage.ai/p/` URL out of a create-site response -/// (`{ site: { slug, .. } }`) or its bare-site fallback. -fn live_url(site_resp: &serde_json::Value) -> Option { - let site = site_resp.get("site").unwrap_or(site_resp); +/// Pull the live `heypage.ai/p/` URL out of a response carrying a site +/// (`{ site: { slug, .. } }` — create-site or site-detail) or its bare fallback. +fn live_url(resp: &serde_json::Value) -> Option { + let site = resp.get("site").unwrap_or(resp); site.get("slug").and_then(|v| v.as_str()).map(|s| format!("https://heypage.ai/p/{s}")) } +/// Page paths present in BOTH a freshly-generated spec (`spec.pages` object, +/// keyed by path) and the existing site detail (`detail.pages[].page.path`), +/// in the spec's order. The PUT-revision endpoint 404s on a path the site +/// doesn't have, so regen only touches paths that already exist. +fn revisable_paths(spec: &serde_json::Value, detail: &serde_json::Value) -> Vec { + let existing: std::collections::HashSet<&str> = detail + .get("pages") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|p| p.get("page").and_then(|pg| pg.get("path")).and_then(|v| v.as_str())) + .collect() + }) + .unwrap_or_default(); + spec.get("pages") + .and_then(|v| v.as_object()) + .map(|m| m.keys().filter(|k| existing.contains(k.as_str())).cloned().collect()) + .unwrap_or_default() +} + pub async fn cmd(cmd: Cmd) -> Result<()> { let client = require_authed().await?; match cmd { @@ -138,6 +173,48 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { "url": live_url(&site_resp), })); } + Cmd::Regen { site, brief, org } => { + let o = require_active_org(&client, org)?; + + // 1. Fresh spec from the brief. + let gen = client + .post(&format!("/organizations/{o}/content-items/generate"), Some(&generate_body(&brief)?)) + .await + .context("POST content-items/generate")?; + let spec = gen.get("spec").context("generate response had no `spec`")?; + + // 2. Existing pages on the site — revisions only append to paths it has. + let detail = client + .get(&format!("/organizations/{o}/heypage/sites/{site}")) + .await + .context("GET heypage/sites/{id}")?; + + // 3. New revision per shared page path. + let paths = revisable_paths(spec, &detail); + for path in &paths { + let page_spec = &spec["pages"][path]; + client + .put( + &format!("/organizations/{o}/heypage/sites/{site}/pages/revision"), + &json!({ "path": path, "spec": page_spec }), + ) + .await + .with_context(|| format!("PUT heypage/sites/{site}/pages/revision (path {path:?})"))?; + } + + // 4. Publish. + client + .post(&format!("/organizations/{o}/heypage/sites/{site}/publish"), None) + .await + .context("POST heypage/sites/{id}/publish (422 = moderation-flagged)")?; + + print_json(&json!({ + "siteId": site, + "updatedPaths": paths, + "published": true, + "url": live_url(&detail), + })); + } Cmd::Publish { site, org } => { let o = require_active_org(&client, org)?; print_json( @@ -185,6 +262,15 @@ mod tests { assert_eq!(body["assetUrls"][0], "u"); } + #[test] + fn revisable_paths_intersects_spec_and_site() { + let spec = json!({ "pages": { "": {}, "classes": {}, "new-page": {} } }); + let detail = json!({ "pages": [{ "page": { "path": "" } }, { "page": { "path": "classes" } }, { "page": { "path": "gallery" } }] }); + let got = revisable_paths(&spec, &detail); + // "new-page" (not on site) and "gallery" (not in spec) are excluded. + assert_eq!(got, vec!["".to_string(), "classes".to_string()]); + } + #[test] fn live_url_from_wrapped_and_bare() { let wrapped = json!({ "site": { "slug": "acme" } }); From 0f24844f3d7a24f18fc78d40d1b9057eab85dea2 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 4 Jul 2026 23:54:03 -0400 Subject: [PATCH 3/3] SMOODEV-2272: Add `th heypage list` + `regen --slug` (no id hand-copying) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `th heypage list` → GET /organizations/{org}/heypage/sites (new backend route, smooai #2536), printing slug + id. `regen` now takes `--slug ` as an alternative to `--site ` (mutually exclusive), resolving the id via that list. So the showcase refresh loop is `th api login → th heypage list → th heypage regen --slug --brief ` with no site ids to copy from a DB. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smooth-cli/src/smooai/heypage.rs | 60 +++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/smooth-cli/src/smooai/heypage.rs b/crates/smooth-cli/src/smooai/heypage.rs index 4ce0b461..ed55fd69 100644 --- a/crates/smooth-cli/src/smooai/heypage.rs +++ b/crates/smooth-cli/src/smooai/heypage.rs @@ -19,7 +19,8 @@ use anyhow::{Context, Result}; use clap::Subcommand; use serde_json::json; -use super::{print_json, read_body, require_active_org, require_authed}; +use super::{print_json, print_list_envelope, read_body, require_active_org, require_authed}; +use smooth_api_client::SmoothApiClient; #[derive(Subcommand)] pub enum Cmd { @@ -54,9 +55,13 @@ pub enum Cmd { /// slug/URL are unchanged), never spawns a duplicate. This is the path that /// refreshes the live showcase examples. Regen { - /// Site id to regenerate. + /// Site id to regenerate. Or use `--slug` to resolve the id via `list`. #[arg(long)] - site: String, + site: Option, + /// Site slug (resolved to an id via `list`) — the loop-friendly form, + /// e.g. `--slug claysmith-studio`. Mutually exclusive with `--site`. + #[arg(long, conflicts_with = "site")] + slug: Option, /// Brief JSON (file path, or `-` for stdin). See `generate --brief`. #[arg(long)] brief: String, @@ -64,6 +69,12 @@ pub enum Cmd { #[arg(long = "org-id", visible_alias = "org")] org: Option, }, + /// List the org's sites (id + slug + name). Slug resolution for `regen --slug`. + List { + /// 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, + }, /// Publish (moderate, then go live) an existing site. Publish { /// Site id. @@ -124,6 +135,30 @@ fn revisable_paths(spec: &serde_json::Value, detail: &serde_json::Value) -> Vec< .unwrap_or_default() } +/// Resolve a regen target to a site id: `--site` wins; otherwise `--slug` is +/// looked up against the org's site list (`GET heypage/sites`). Errors if +/// neither is given or the slug matches no site. +async fn resolve_site_id(client: &SmoothApiClient, org: &str, site: Option, slug: Option) -> Result { + if let Some(id) = site { + return Ok(id); + } + let slug = slug.context("pass --site or --slug ")?; + let list = client.get(&format!("/organizations/{org}/heypage/sites")).await.context("GET heypage/sites")?; + find_slug_id(&list, &slug).with_context(|| format!("no site with slug {slug:?} in org {org}")) +} + +/// Find the `id` of the site whose `slug` matches, in a `{data:[…]}` envelope +/// or a bare array. +fn find_slug_id(list: &serde_json::Value, slug: &str) -> Option { + list.get("data") + .and_then(|v| v.as_array()) + .or_else(|| list.as_array()) + .into_iter() + .flatten() + .find(|s| s.get("slug").and_then(|v| v.as_str()) == Some(slug)) + .and_then(|s| s.get("id").and_then(|v| v.as_str()).map(str::to_string)) +} + pub async fn cmd(cmd: Cmd) -> Result<()> { let client = require_authed().await?; match cmd { @@ -173,8 +208,9 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { "url": live_url(&site_resp), })); } - Cmd::Regen { site, brief, org } => { + Cmd::Regen { site, slug, brief, org } => { let o = require_active_org(&client, org)?; + let site = resolve_site_id(&client, &o, site, slug).await?; // 1. Fresh spec from the brief. let gen = client @@ -215,6 +251,13 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { "url": live_url(&detail), })); } + Cmd::List { org } => { + let o = require_active_org(&client, org)?; + print_list_envelope( + &client.get(&format!("/organizations/{o}/heypage/sites")).await.context("GET heypage/sites")?, + "sites", + ); + } Cmd::Publish { site, org } => { let o = require_active_org(&client, org)?; print_json( @@ -271,6 +314,15 @@ mod tests { assert_eq!(got, vec!["".to_string(), "classes".to_string()]); } + #[test] + fn find_slug_id_matches_in_envelope_and_bare() { + let env = json!({ "data": [{ "id": "a", "slug": "claysmith-studio" }, { "id": "b", "slug": "north-fork-caf" }] }); + assert_eq!(find_slug_id(&env, "north-fork-caf"), Some("b".to_string())); + assert_eq!(find_slug_id(&env, "nope"), None); + let bare = json!([{ "id": "c", "slug": "jesse-builds" }]); + assert_eq!(find_slug_id(&bare, "jesse-builds"), Some("c".to_string())); + } + #[test] fn live_url_from_wrapped_and_bare() { let wrapped = json!({ "site": { "slug": "acme" } });