Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-glow-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@smooai/smooth': minor
---

th-7f1da8: the Presence glow-up + codified CLI spec. Bare `th --help` now renders a branded, grouped map of the surface (wordmark gradient, sections for Platform / Big Smooth / Work / Agent mail / Coding / LLM / System, teal-accent literals, dimmed blurbs) with a two-way sync test pinning it to the clap tree; `th --help-full` keeps the native flat view, and all per-command help is themed via clap styles — everything pipe-safe and NO_COLOR-clean. Appending `ai` to any command path (`smoo org ai`, `th pearls ai`, bare `th ai`) prints a generated markdown guide (about, subcommands, flags, curated examples, house conventions) built for humans and AI agents. The interface contract is codified in docs/Engineering/CLI-Spec.md and partly test-enforced: every platform `list` verb must offer `--json` — the new conformance test found and this change backfills 19 that didn't (orgs, members, crm contacts, knowledge, jobs, products, booking, heypage, auth profiles, admin config).
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ smooth/

> **Full doc**: [`docs/Engineering/Using-th-CLI.md`](docs/Engineering/Using-th-CLI.md). The bullets below are the muscle-memory summary; everything below covers what the binary built from this repo can do for you and how to extend it.

`th` is **the** CLI we use across smooth and smooai. Reach for it before `curl`, before the web app, before Supabase Studio. Run `th --help` and `th <command> --help` liberally — every subcommand is self-documenting.
`th` is **the** CLI we use across smooth and smooai. Reach for it before `curl`, before the web app, before Supabase Studio. Run `th --help` and `th <command> --help` liberally — every subcommand is self-documenting, and appending `ai` to any command path (`smoo org ai`) prints a generated markdown guide. The interface contract lives in [`docs/Engineering/CLI-Spec.md`](docs/Engineering/CLI-Spec.md) — read it before adding or reshaping a command.

> 📣 **The `smoo` namespace (pearl th-fc32d9).** `th` is two products in one
> binary: the standalone local agent tool (pearls, worktrees, mail, daemon,
Expand Down
82 changes: 69 additions & 13 deletions crates/smooth-cli/src/admin/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ pub enum SchemasCmd {
List {
#[arg(long, visible_alias = "org-id")]
org: Option<String>,
/// Print raw JSON instead of the list.
#[arg(long)]
json: bool,
},
/// Show a schema by id.
Show {
Expand Down Expand Up @@ -118,6 +121,9 @@ pub enum EnvironmentsCmd {
List {
#[arg(long, visible_alias = "org-id")]
org: Option<String>,
/// Print raw JSON instead of the list.
#[arg(long)]
json: bool,
},
/// Create an environment. Body is a JSON document or `-` for stdin.
/// For child orgs as a parent admin, use the public path instead:
Expand Down Expand Up @@ -184,12 +190,14 @@ pub async fn dispatch(cmd: ConfigCommands) -> Result<()> {

async fn dispatch_schemas(cmd: SchemasCmd, client: &smooth_api_client::SmoothApiClient) -> Result<()> {
match cmd {
SchemasCmd::List { org } => {
SchemasCmd::List { org, json } => {
let o = require_active_org(client, org)?;
print_list_envelope(
&client.get(&format!("/organizations/{o}/config/schemas")).await.context("GET schemas")?,
"schemas",
);
let body = client.get(&format!("/organizations/{o}/config/schemas")).await.context("GET schemas")?;
if json {
print_json(&body);
} else {
print_list_envelope(&body, "schemas");
}
}
SchemasCmd::Show { schema_id, org } => {
let o = require_active_org(client, org)?;
Expand Down Expand Up @@ -254,15 +262,17 @@ async fn dispatch_schemas(cmd: SchemasCmd, client: &smooth_api_client::SmoothApi

async fn dispatch_environments(cmd: EnvironmentsCmd, client: &smooth_api_client::SmoothApiClient) -> Result<()> {
match cmd {
EnvironmentsCmd::List { org } => {
EnvironmentsCmd::List { org, json } => {
let o = require_active_org(client, org)?;
print_list_envelope(
&client
.get(&format!("/organizations/{o}/config/environments"))
.await
.context("GET environments")?,
"environments",
);
let body = client
.get(&format!("/organizations/{o}/config/environments"))
.await
.context("GET environments")?;
if json {
print_json(&body);
} else {
print_list_envelope(&body, "environments");
}
}
EnvironmentsCmd::Create { body, org } => {
let o = require_active_org(client, org)?;
Expand Down Expand Up @@ -330,3 +340,49 @@ async fn dispatch_values(cmd: ValuesCmd, client: &smooth_api_client::SmoothApiCl
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

/// CLI-Spec §flags: every platform `list` verb offers `--json`.
#[test]
fn list_verbs_accept_json_flag_and_default_to_off() {
use clap::Parser;

#[derive(Parser)]
struct Wrap {
#[command(subcommand)]
cmd: ConfigCommands,
}
let s = Wrap::try_parse_from(["t", "schemas", "list", "--json"]).expect("schemas list --json must parse");
assert!(matches!(
s.cmd,
ConfigCommands::Schemas {
cmd: SchemasCmd::List { json: true, .. }
}
));
let s = Wrap::try_parse_from(["t", "schemas", "list"]).expect("bare schemas list must still parse");
assert!(matches!(
s.cmd,
ConfigCommands::Schemas {
cmd: SchemasCmd::List { json: false, .. }
}
));

let e = Wrap::try_parse_from(["t", "environments", "list", "--json"]).expect("environments list --json must parse");
assert!(matches!(
e.cmd,
ConfigCommands::Environments {
cmd: EnvironmentsCmd::List { json: true, .. }
}
));
let e = Wrap::try_parse_from(["t", "environments", "list"]).expect("bare environments list must still parse");
assert!(matches!(
e.cmd,
ConfigCommands::Environments {
cmd: EnvironmentsCmd::List { json: false, .. }
}
));
}
}
23 changes: 22 additions & 1 deletion crates/smooth-cli/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ pub enum AuthCommands {
#[derive(Debug, Subcommand)]
pub enum ProfileCommands {
/// List profiles and show which is active.
List,
List {
/// Print profiles as JSON instead of the rendered list.
#[arg(long)]
json: bool,
},
/// Set the active profile (persisted in `<auth>/active`).
Use {
/// Profile name.
Expand Down Expand Up @@ -199,6 +203,23 @@ pub fn supabase_anon_key() -> String {
mod tests {
use super::*;

/// CLI-Spec §flags: every platform `list` verb offers `--json`.
#[test]
fn profile_list_accepts_json_flag_and_defaults_to_off() {
use clap::Parser;

#[derive(Parser)]
struct Wrap {
#[command(subcommand)]
cmd: ProfileCommands,
}
let on = Wrap::try_parse_from(["t", "list", "--json"]).expect("--json must parse");
assert!(matches!(on.cmd, ProfileCommands::List { json: true }));

let off = Wrap::try_parse_from(["t", "list"]).expect("bare list must still parse");
assert!(matches!(off.cmd, ProfileCommands::List { json: false }), "--json must default to off");
}

#[test]
fn supabase_url_honors_env_override() {
let prev = std::env::var("SMOOAI_SUPABASE_URL").ok();
Expand Down
18 changes: 16 additions & 2 deletions crates/smooth-cli/src/auth/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use super::ProfileCommands;

pub fn dispatch(cmd: ProfileCommands) -> Result<()> {
match cmd {
ProfileCommands::List => list(),
ProfileCommands::List { json } => list(json),
ProfileCommands::Use { name } => {
paths::set_active(&name)?;
println!();
Expand Down Expand Up @@ -52,10 +52,24 @@ fn identity_of(profile: Option<&str>) -> String {
}
}

fn list() -> Result<()> {
fn list(json: bool) -> Result<()> {
let active = paths::active_profile();
let named = paths::list_profiles();

if json {
// No API roundtrip here — build the same rows the rendered list shows.
let mut profiles = Vec::new();
if paths::default_profile_present() {
profiles.push(serde_json::json!({ "name": "default", "active": active.is_none(), "identity": identity_of(None) }));
}
for name in &named {
let is_active = active.as_deref() == Some(name.as_str());
profiles.push(serde_json::json!({ "name": name, "active": is_active, "identity": identity_of(Some(name)) }));
}
crate::smooai::print_json(&serde_json::json!({ "data": profiles }));
return Ok(());
}

println!();
if named.is_empty() && !paths::default_profile_present() {
println!(" {} {}", "●".dimmed(), "no profiles yet — `th auth login --profile <name>`".dimmed());
Expand Down
Loading
Loading