diff --git a/.changeset/cli-glow-up.md b/.changeset/cli-glow-up.md new file mode 100644 index 00000000..4e6a9aa9 --- /dev/null +++ b/.changeset/cli-glow-up.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 19dec090..7b8e1af6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --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 --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, diff --git a/crates/smooth-cli/src/admin/config.rs b/crates/smooth-cli/src/admin/config.rs index 2bf6e860..7b1e1835 100644 --- a/crates/smooth-cli/src/admin/config.rs +++ b/crates/smooth-cli/src/admin/config.rs @@ -70,6 +70,9 @@ pub enum SchemasCmd { List { #[arg(long, visible_alias = "org-id")] org: Option, + /// Print raw JSON instead of the list. + #[arg(long)] + json: bool, }, /// Show a schema by id. Show { @@ -118,6 +121,9 @@ pub enum EnvironmentsCmd { List { #[arg(long, visible_alias = "org-id")] org: Option, + /// 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: @@ -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)?; @@ -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)?; @@ -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, .. } + } + )); + } +} diff --git a/crates/smooth-cli/src/auth/mod.rs b/crates/smooth-cli/src/auth/mod.rs index 8687b573..d5659e18 100644 --- a/crates/smooth-cli/src/auth/mod.rs +++ b/crates/smooth-cli/src/auth/mod.rs @@ -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 `/active`). Use { /// Profile name. @@ -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(); diff --git a/crates/smooth-cli/src/auth/profile.rs b/crates/smooth-cli/src/auth/profile.rs index e3dd1e00..b2e6aa0f 100644 --- a/crates/smooth-cli/src/auth/profile.rs +++ b/crates/smooth-cli/src/auth/profile.rs @@ -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!(); @@ -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 `".dimmed()); diff --git a/crates/smooth-cli/src/help.rs b/crates/smooth-cli/src/help.rs new file mode 100644 index 00000000..37f06269 --- /dev/null +++ b/crates/smooth-cli/src/help.rs @@ -0,0 +1,356 @@ +//! The glowed-up `th` help surface + the universal `ai` explainer. +//! Pearl th-7f1da8; design language: Presence (.claude/skills/smooth-glow-up). +//! +//! Two things live here: +//! +//! 1. **The custom top-level help** — bare `th --help` renders a grouped, +//! branded screen instead of clap's flat 40-command wall. The groups are a +//! hand-curated table, and a test cross-checks it against the real clap +//! tree in BOTH directions, so it can never silently drift when a command +//! is added or removed. `th --help-full` still prints the native clap help. +//! +//! 2. **The `ai` explainer** — append `ai` to any command path +//! (`th smoo org ai`, `th pearls ai`, `th ai`) to get a plain-markdown +//! guide generated from the clap tree (about, usage, subcommands, flags) +//! plus curated examples. Zero per-module maintenance: new subcommands are +//! picked up automatically. Output is deliberately unstyled markdown — it +//! is written to be pasted into (or read by) another AI as much as a human. +//! +//! Styling rules (Presence): the teal→blue gradient is the `th` wordmark's +//! and appears exactly once, on the wordmark; section headers are bold; the +//! single accent color is teal on command names; descriptions are dimmed. +//! Everything routes through `anstream`, so a pipe or NO_COLOR gets plain +//! text. + +use anstream::println; +use clap::CommandFactory; +use owo_colors::OwoColorize; + +use crate::gradient; + +/// The curated top-level help: (section, [(command, one-liner)]). +/// +/// The one-liners are intentionally SHORTER than the clap `about` strings — +/// this screen is a map, not a manual. `help_sync` asserts every visible +/// clap subcommand appears here and every row here exists in clap. +const SECTIONS: &[(&str, &[(&str, &str)])] = &[ + ( + "Smoo AI platform", + &[( + "smoo", + "everything that talks to smoo.ai — also installed as the `smoo` binary (auth, crm, config, agents, analytics, …)", + )], + ), + ( + "Big Smooth — the always-on agent", + &[ + ("up", "start Big Smooth on this host"), + ("down", "stop it"), + ("status", "system health at a glance"), + ("daemon", "run / drive the daemon directly"), + ("operator", "dogfood the polyglot engine servers"), + ("web", "open the dashboard in your browser"), + ("inbox", "reviews + notifications needing you"), + ("run", "run a pearl through an operative"), + ("pause", "halt a running operative"), + ("resume", "resume a paused operative"), + ("steer", "send mid-run guidance"), + ("cancel", "stop a run"), + ("approve", "approve a pending review gate"), + ("operatives", "list / kill operatives"), + ("access", "operative access control"), + ], + ), + ( + "Work — pearls, repos, CI", + &[ + ("pearls", "the built-in work-item tracker"), + ("project", "pearl projects in the global registry"), + ("db", "this repo's pearl store: status / backup / path"), + ("jira", "sync pearls with Jira"), + ("attest", "run CI checks locally and credit the passes"), + ("worktree", "git worktree management"), + ("hooks", "git hook management"), + ("prime", "print the workflow-rules context block"), + ], + ), + ( + "Agent mail — the machine-local bus", + &[ + ("agent", "register / claim this session's handle"), + ("msg", "send and read agent-to-agent mail"), + ], + ), + ( + "Coding", + &[ + ("code", "the interactive coding TUI (also bare `th`)"), + ("claude", "supervise Claude Code sessions in tmux"), + ("skills", "list skills in this workspace"), + ("harness", "set up Claude Code / Codex / OpenCode with the smooth toolbox"), + ("mcp", "MCP servers — including `th mcp serve`, the shared agent bus"), + ("plugin", "file-based CLI-wrapper plugins"), + ("ext", "SEP extensions"), + ], + ), + ( + "LLM providers", + &[ + ("model", "provider credentials (Anthropic, Smoo AI gateway, …)"), + ("providers", "bring-your-own OpenAI-compatible servers"), + ("cast", "live model groups the provider exposes"), + ("routing", "which model handles thinking / coding / …"), + ], + ), + ( + "System", + &[ + ("doctor", "health check, auto-fix, guided setup"), + ("service", "run Smooth as a background service"), + ("audit", "tool-usage audit logs"), + ("tailscale", "tailnet devices Smooth can see"), + ], + ), +]; + +/// Curated examples for the `ai` explainer, keyed by the space-joined +/// command path (`""` = root). Everything else in the explainer is generated +/// from the clap tree, so only add rows here when a worked example genuinely +/// helps. +const EXAMPLES: &[(&str, &[&str])] = &[ + ( + "", + &[ + "th pearls ready # what should I work on?", + "smoo auth login # sign in to the Smoo AI platform", + "smoo crm contacts list --json # any platform read, machine-readable", + "th harness enable all # wire Claude Code / Codex / OpenCode to the toolbox", + ], + ), + ( + "smoo", + &[ + "smoo auth login", + "smoo org list && smoo org switch ", + "smoo config get databaseUrl --environment=production", + "smoo analytics query --preset total_contacts", + "smoo campaigns send # preview; add --confirm to really send", + ], + ), + ("smoo org", &["smoo org list", "smoo org switch smoo", "smoo org show"]), + ( + "smoo config", + &[ + "smoo config list --environment=production", + "smoo config set myKey 'value' --environment=development", + "smoo config diff", + ], + ), + ( + "smoo crm", + &["smoo crm contacts list --json", "smoo crm deals list", "smoo crm pipeline forecast"], + ), + ( + "pearls", + &[ + "th pearls ready", + "th pearls create --title=\"Fix X\" --type=bug --priority=2", + "th pearls update th-xxxxxx --status=in_progress", + "th pearls close th-xxxxxx && th pearls push", + ], + ), + ("msg", &["th msg send \"body\"", "th msg inbox", "th msg watch --once --json"]), + ("agent", &["th agent whoami", "th agent claim my-task-name", "th agent list"]), + ("attest", &["th attest --all", "th attest rust --remote smoo-hub", "th attest --status"]), + ("harness", &["th harness enable all", "th harness status"]), +]; + +/// Render the custom top-level help. Called for bare `th --help` / `-h` / +/// `th help`; `th --help-full` bypasses this for the native clap tree. +pub fn print_top_level() { + let version = env!("TH_VERSION"); + println!("{} {}", gradient::smooth(), format!("v{version}").dimmed()); + println!("{}", "your local agent toolbox + the Smoo AI platform CLI".dimmed()); + + let width = SECTIONS + .iter() + .flat_map(|(_, rows)| rows.iter()) + .map(|(name, _)| name.len()) + .max() + .unwrap_or(10); + + for (section, rows) in SECTIONS { + println!(); + println!("{}", section.bold()); + for (name, blurb) in *rows { + println!(" {:width$} {}", name.cyan(), blurb.dimmed(), width = width); + } + } + + println!(); + println!("{}", "Getting around".bold()); + println!(" {}", "th --help details for any command (or -h for a summary)".dimmed()); + println!( + " {}", + "th ai a markdown guide to that command, written for humans and AIs".dimmed() + ); + println!(" {}", "th --help-full the full flat command tree (native help)".dimmed()); + println!(" {}", "smoo the platform half under its own name (same binary)".dimmed()); +} + +/// The `ai` explainer: `path` is the command chain WITHOUT the trailing +/// `ai` (empty = root). Returns false when the path names no real command, +/// so the caller can fall through to clap's own error. +pub fn print_ai_explainer(path: &[String]) -> bool { + let root = crate::Cli::command(); + let mut node = &root; + for part in path { + match node + .get_subcommands() + .find(|c| c.get_name() == part.as_str() || c.get_all_aliases().any(|a| a == part.as_str())) + { + Some(next) => node = next, + None => return false, + } + } + + let full: String = std::iter::once("th").chain(path.iter().map(String::as_str)).collect::>().join(" "); + println!("# {full}"); + println!(); + let about = node + .get_long_about() + .or_else(|| node.get_about()) + .map_or_else(|| "Smoo AI CLI.".to_string(), std::string::ToString::to_string); + println!("{about}"); + + let subs: Vec<_> = node.get_subcommands().filter(|c| !c.is_hide_set() && c.get_name() != "help").collect(); + if !subs.is_empty() { + println!(); + println!("## Subcommands"); + println!(); + for c in &subs { + let one_liner = c + .get_about() + .map_or_else(String::new, |a| a.to_string().lines().next().unwrap_or("").to_string()); + let aliases: Vec<_> = c.get_visible_aliases().collect(); + let alias_note = if aliases.is_empty() { + String::new() + } else { + format!(" (alias: {})", aliases.join(", ")) + }; + println!("- `{full} {}`{} — {}", c.get_name(), alias_note, one_liner); + } + } + + let args: Vec<_> = node + .get_arguments() + .filter(|a| !a.is_hide_set() && a.get_id() != "help" && a.get_id() != "version") + .collect(); + if !args.is_empty() { + println!(); + println!("## Flags"); + println!(); + for a in &args { + let name = a.get_long().map_or_else(|| a.get_id().to_string(), |l| format!("--{l}")); + let hint = a + .get_help() + .map_or_else(String::new, |h| h.to_string().lines().next().unwrap_or("").to_string()); + println!("- `{name}` — {hint}"); + } + } + + let key = path.join(" "); + if let Some((_, examples)) = EXAMPLES.iter().find(|(k, _)| *k == key) { + println!(); + println!("## Examples"); + println!(); + println!("```bash"); + for e in *examples { + println!("{e}"); + } + println!("```"); + } + + println!(); + println!("## Conventions"); + println!(); + println!("- `smoo ` == `th smoo …` — the platform half of the binary; everything under it authenticates via `smoo auth login`, everything outside it works offline."); + println!( + "- Read verbs take `--json` for stable machine-readable output; empty results are stated as confirmed answers, and truncation is always reported." + ); + println!("- Destructive or spend actions preview first and require an explicit flag (e.g. `smoo campaigns send … --confirm`)."); + println!("- Append `ai` to any command path for this view; `--help` on any command for the native reference."); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn visible_top_level() -> Vec { + crate::Cli::command() + .get_subcommands() + .filter(|c| !c.is_hide_set() && c.get_name() != "help") + .map(|c| c.get_name().to_string()) + .collect() + } + + /// The curated help and the real clap tree may never drift: every visible + /// command appears in exactly one section, and every row names a real + /// command. + #[test] + fn help_sync_both_directions() { + let tree = visible_top_level(); + let mut curated: Vec<&str> = SECTIONS.iter().flat_map(|(_, rows)| rows.iter().map(|(n, _)| *n)).collect(); + curated.sort_unstable(); + let dup = curated.windows(2).find(|w| w[0] == w[1]); + assert!(dup.is_none(), "command listed twice in SECTIONS: {dup:?}"); + + for name in &tree { + assert!( + curated.contains(&name.as_str()), + "`{name}` is a visible command but missing from the curated help — add it to a section in help.rs" + ); + } + for name in &curated { + assert!( + tree.iter().any(|t| t == name), + "curated help lists `{name}` but no such visible command exists — remove or fix it" + ); + } + } + + #[test] + fn ai_explainer_resolves_paths_and_aliases() { + assert!(print_ai_explainer(&[])); + assert!(print_ai_explainer(&["smoo".into()])); + assert!(print_ai_explainer(&["smoo".into(), "org".into()])); + // visible alias resolves too + assert!(print_ai_explainer(&["smoo".into(), "orgs".into()])); + assert!(!print_ai_explainer(&["smoo".into(), "nonsense".into()])); + } + + /// CLI-Spec conformance: inside the `smoo` platform tree, every `list` + /// verb must offer `--json`. (The local-tool tree is tracked as spec debt + /// in the exemption list — shrink it, never grow it.) + #[test] + fn spec_every_platform_list_has_json() { + fn walk(cmd: &clap::Command, path: String, violations: &mut Vec) { + for sub in cmd.get_subcommands() { + let p = format!("{path} {}", sub.get_name()); + if sub.get_name() == "list" && !sub.get_arguments().any(|a| a.get_id() == "json") { + violations.push(p.clone()); + } + walk(sub, p, violations); + } + } + let root = crate::Cli::command(); + let smoo = root.get_subcommands().find(|c| c.get_name() == "smoo").expect("smoo node"); + let mut violations = Vec::new(); + walk(smoo, "smoo".to_string(), &mut violations); + // Known debt goes here WITH a pearl reference, and only ever shrinks. + let exempt: &[&str] = &[]; + violations.retain(|v| !exempt.contains(&v.as_str())); + assert!(violations.is_empty(), "platform `list` verbs without --json (CLI-Spec §flags): {violations:?}"); + } +} diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index 666a5222..c5cd8f00 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -27,6 +27,8 @@ mod imessage_setup; use smooth_tools::mcp_config; /// th-19dac1: `th harness` — per-provider toolbox setup (EPIC th-1945b9). mod harness; +/// th-7f1da8: the branded top-level help + the universal `ai` explainer. +mod help; /// th-374f85: `th agent` / `th msg` / `th inbox` on the machine-level /// SQLite mail store (ADR-010), off the per-repo Dolt pearl store. mod mail; @@ -55,7 +57,7 @@ use owo_colors::OwoColorize; /// Smooth — AI agent orchestration platform. /// Run with no arguments to launch the interactive coding assistant. #[derive(Parser)] -#[command(name = "th", version = env!("TH_VERSION"), about, long_about = None)] +#[command(name = "th", version = env!("TH_VERSION"), about, long_about = None, styles = brand_styles())] struct Cli { #[command(subcommand)] command: Option, @@ -1062,7 +1064,11 @@ enum OperativesCommands { #[derive(Subcommand)] enum OrgsCommands { /// List organizations the logged-in user belongs to. - List, + List { + /// Emit the raw response JSON instead of the rendered list. + #[arg(long)] + json: bool, + }, /// Show details of an organization. Defaults to the active org. Show { /// Org id (UUID). Omit to use the active org from @@ -1853,6 +1859,21 @@ async fn run_smoo(cmd: SmooCommands) -> Result<()> { } } +/// Presence palette for clap's native help (pearl th-7f1da8): bold warm +/// headers, the teal accent on literals, dimmed placeholders. clap drops the +/// styling automatically when stdout isn't a terminal, so piped help stays +/// plain. The teal→blue gradient itself is reserved for the wordmark and +/// never applied here. +fn brand_styles() -> clap::builder::Styles { + use clap::builder::styling::{Color, RgbColor, Style}; + let teal = Style::new().fg_color(Some(Color::Rgb(RgbColor(0x00, 0xa6, 0xa6)))); + clap::builder::Styles::styled() + .header(Style::new().bold()) + .usage(Style::new().bold()) + .literal(teal.bold()) + .placeholder(Style::new().dimmed()) +} + /// When the binary is invoked as `smoo` (the symlink installed next to `th`), /// behave as `th smoo …`. Pearl th-fc32d9. fn smoo_argv(mut args: Vec) -> Vec { @@ -1875,7 +1896,29 @@ fn smoo_argv(mut args: Vec) -> Vec { #[tokio::main] async fn main() -> Result<()> { - let cli = Cli::parse_from(smoo_argv(std::env::args_os().collect())); + let mut argv = smoo_argv(std::env::args_os().collect()); + // th-7f1da8: bare `th --help`/`-h`/`help` gets the branded, grouped help; + // `--help-full` falls through to clap's native flat tree. + match argv.get(1).and_then(|a| a.to_str()) { + Some("--help" | "-h" | "help") if argv.len() == 2 => { + help::print_top_level(); + return Ok(()); + } + Some("--help-full") => argv[1] = "--help".into(), + _ => {} + } + // Universal `ai` explainer: a trailing `ai` on any command path renders a + // markdown guide from the clap tree (`th smoo org ai`, `th ai`). Only + // fires when every path segment resolves to a real (sub)command, so a + // positional VALUE spelled "ai" after a leaf command still parses + // normally unless the leaf itself takes no matching subpath. + if argv.len() >= 2 && argv.last().and_then(|a| a.to_str()) == Some("ai") { + let path: Vec = argv[1..argv.len() - 1].iter().filter_map(|a| a.to_str().map(str::to_string)).collect(); + if path.len() == argv.len() - 2 && path.iter().all(|p| !p.starts_with('-')) && help::print_ai_explainer(&path) { + return Ok(()); + } + } + let cli = Cli::parse_from(argv); // SMOODEV-1739: resolve the active auth profile (--profile flag → // SMOOAI_PROFILE → active-profile file) and export SMOOAI_USER_AUTH_FILE / @@ -8873,7 +8916,12 @@ mod org_cli_tests { #[test] fn th_org_top_level_alias_parses() { let cli = Cli::try_parse_from(["th", "org", "list"]).expect("th org list parses"); - assert!(matches!(cli.command, Some(Commands::Org { cmd: OrgsCommands::List }))); + assert!(matches!( + cli.command, + Some(Commands::Org { + cmd: OrgsCommands::List { .. } + }) + )); let cli = Cli::try_parse_from(["th", "org", "switch", "ats"]).expect("th org switch parses"); match cli.command { diff --git a/crates/smooth-cli/src/smooai/booking.rs b/crates/smooth-cli/src/smooai/booking.rs index d29028ef..582a27a6 100644 --- a/crates/smooth-cli/src/smooai/booking.rs +++ b/crates/smooth-cli/src/smooai/booking.rs @@ -163,6 +163,9 @@ pub enum TypesCmd { /// 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, + /// Print the raw types JSON instead of the rendered list. + #[arg(long)] + json: bool, }, /// Create a booking type. Create { @@ -270,6 +273,9 @@ pub enum BlockCmd { /// 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, + /// Print the raw blocks JSON instead of the rendered list. + #[arg(long)] + json: bool, }, /// Remove a manual busy block by its calendar event id. Rm { @@ -288,6 +294,9 @@ pub enum CalendarsCmd { /// 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, + /// Print the raw calendars JSON instead of the rendered list. + #[arg(long)] + json: bool, }, /// Start the Google OAuth flow to add a conflict calendar. Prints an /// authorization URL to open in the browser signed into that account. @@ -380,9 +389,15 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { let payload = build_payload(¤t, &overlay); print_json(&client.put(&format!("/booking/config/{o}"), &payload).await.context("PUT booking config")?); } - Cmd::Types { cmd: TypesCmd::List { org } } => { + Cmd::Types { + cmd: TypesCmd::List { org, json }, + } => { let o = require_active_org(&client, org)?; let body = client.get(&format!("/booking/types/{o}")).await.context("GET booking types")?; + if json { + print_json(&body); + return Ok(()); + } // Prefer the config's public handle over each type's member email // when building URLs — matches the `link` command's contract. let slug = client @@ -529,7 +544,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { print_json(&client.post(&format!("/booking/blocks/{o}"), Some(&b)).await.context("POST block")?); } Cmd::Block { - cmd: BlockCmd::List { from, to, org }, + cmd: BlockCmd::List { from, to, org, json }, } => { let o = require_active_org(&client, org)?; let mut path = format!("/booking/blocks/{o}"); @@ -545,7 +560,11 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { path.push_str(¶ms.join("&")); } let body = client.get(&path).await.context("GET blocks")?; - render_blocks(&body); + if json { + print_json(&body); + } else { + render_blocks(&body); + } } Cmd::Block { cmd: BlockCmd::Rm { event_id, org }, @@ -559,11 +578,15 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { ); } Cmd::Calendars { - cmd: CalendarsCmd::List { org }, + cmd: CalendarsCmd::List { org, json }, } => { let o = require_active_org(&client, org)?; let body = client.get(&format!("/booking/calendars/{o}")).await.context("GET calendars")?; - render_calendars(&body); + if json { + print_json(&body); + } else { + render_calendars(&body); + } } Cmd::Calendars { cmd: CalendarsCmd::Connect { tier, org }, @@ -1066,6 +1089,62 @@ mod tests { assert_eq!(build_public_url("org1", "me@x.com", None, None), "https://smoo.ai/book/org1/me%40x.com"); } + /// 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: Cmd, + } + let t = Wrap::try_parse_from(["t", "types", "list", "--json"]).expect("types list --json must parse"); + assert!(matches!( + t.cmd, + Cmd::Types { + cmd: TypesCmd::List { json: true, .. } + } + )); + let t = Wrap::try_parse_from(["t", "types", "list"]).expect("bare types list must still parse"); + assert!(matches!( + t.cmd, + Cmd::Types { + cmd: TypesCmd::List { json: false, .. } + } + )); + + let b = Wrap::try_parse_from(["t", "block", "list", "--json"]).expect("block list --json must parse"); + assert!(matches!( + b.cmd, + Cmd::Block { + cmd: BlockCmd::List { json: true, .. } + } + )); + let b = Wrap::try_parse_from(["t", "block", "list"]).expect("bare block list must still parse"); + assert!(matches!( + b.cmd, + Cmd::Block { + cmd: BlockCmd::List { json: false, .. } + } + )); + + let c = Wrap::try_parse_from(["t", "calendars", "list", "--json"]).expect("calendars list --json must parse"); + assert!(matches!( + c.cmd, + Cmd::Calendars { + cmd: CalendarsCmd::List { json: true, .. } + } + )); + let c = Wrap::try_parse_from(["t", "calendars", "list"]).expect("bare calendars list must still parse"); + assert!(matches!( + c.cmd, + Cmd::Calendars { + cmd: CalendarsCmd::List { json: false, .. } + } + )); + } + #[test] fn truncate_clips_with_ellipsis() { assert_eq!(truncate("short", 60), "short"); diff --git a/crates/smooth-cli/src/smooai/crm.rs b/crates/smooth-cli/src/smooai/crm.rs index 330c0981..553f9534 100644 --- a/crates/smooth-cli/src/smooai/crm.rs +++ b/crates/smooth-cli/src/smooai/crm.rs @@ -662,6 +662,9 @@ pub enum ContactsCmd { /// Maximum number of contacts to return. #[arg(long, default_value = "50")] limit: u32, + /// Output is already raw JSON; accepted for interface consistency (CLI-Spec §flags). + #[arg(long)] + json: bool, }, /// Get a single contact by id. Get { @@ -851,7 +854,8 @@ pub fn resolve_org(override_org: Option) -> Result { async fn contacts(cmd: ContactsCmd) -> Result<()> { let client = UserClient::from_user_session().await?; match cmd { - ContactsCmd::List { org, search, limit } => { + // `--json` is a no-op here — the output is already the raw response. + ContactsCmd::List { org, search, limit, json: _ } => { let org = resolve_org(org)?; let mut path = format!("/organizations/{org}/crm/contacts?limit={limit}"); if let Some(s) = search.filter(|s| !s.trim().is_empty()) { @@ -3326,6 +3330,24 @@ mod tests { }; use serde_json::json; + /// CLI-Spec §flags: every platform `list` verb offers `--json` + /// (a no-op here — contacts list already prints raw JSON). + #[test] + fn contacts_list_accepts_json_flag_and_defaults_to_off() { + use clap::Parser; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: super::ContactsCmd, + } + let on = Wrap::try_parse_from(["t", "list", "--json"]).expect("--json must parse"); + assert!(matches!(on.cmd, super::ContactsCmd::List { json: true, .. })); + + let off = Wrap::try_parse_from(["t", "list"]).expect("bare list must still parse"); + assert!(matches!(off.cmd, super::ContactsCmd::List { json: false, .. }), "--json must default to off"); + } + #[test] fn parent_row_points_up_only_for_source_direction() { // Querying the child, its parent row comes back as direction=="source". diff --git a/crates/smooth-cli/src/smooai/heypage.rs b/crates/smooth-cli/src/smooai/heypage.rs index a67f1f5c..22b90fb5 100644 --- a/crates/smooth-cli/src/smooai/heypage.rs +++ b/crates/smooth-cli/src/smooai/heypage.rs @@ -74,6 +74,9 @@ pub enum Cmd { /// 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, + /// Print raw JSON instead of the list. + #[arg(long)] + json: bool, }, /// Publish (moderate, then go live) an existing site. Publish { @@ -377,12 +380,14 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { "url": live_url(&detail), })); } - Cmd::List { org } => { + Cmd::List { org, json } => { let o = require_active_org(&client, org)?; - print_list_envelope( - &client.get(&format!("/organizations/{o}/heypage/sites")).await.context("GET heypage/sites")?, - "sites", - ); + let body = client.get(&format!("/organizations/{o}/heypage/sites")).await.context("GET heypage/sites")?; + if json { + print_json(&body); + } else { + print_list_envelope(&body, "sites"); + } } Cmd::Publish { site, org } => { let o = require_active_org(&client, org)?; @@ -526,6 +531,23 @@ mod tests { assert_eq!(live_url(&bare).unwrap(), "https://heypage.ai/p/beta"); } + /// CLI-Spec §flags: every platform `list` verb offers `--json`. + #[test] + fn list_accepts_json_flag_and_defaults_to_off() { + use clap::Parser; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + let on = Wrap::try_parse_from(["t", "list", "--json"]).expect("--json must parse"); + assert!(matches!(on.cmd, Cmd::List { json: true, .. })); + + let off = Wrap::try_parse_from(["t", "list"]).expect("bare list must still parse"); + assert!(matches!(off.cmd, Cmd::List { json: false, .. }), "--json must default to off"); + } + /// MCP parity (pearl th-088c93): `versions`/`rollback`/`source`/`content` /// mirror the hosted `site_*` tools. #[test] diff --git a/crates/smooth-cli/src/smooai/jobs.rs b/crates/smooth-cli/src/smooai/jobs.rs index 95d2e703..fce5efff 100644 --- a/crates/smooth-cli/src/smooai/jobs.rs +++ b/crates/smooth-cli/src/smooai/jobs.rs @@ -24,6 +24,9 @@ pub enum Cmd { /// Filter by job type. #[arg(long, name = "type", value_name = "TYPE")] type_: Option, + /// Print raw JSON instead of the list. + #[arg(long)] + json: bool, }, /// Show one job's full record (status, payload, result). Show { @@ -53,6 +56,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { organization_id, status, type_, + json, } => { let mut q: Vec<(String, String)> = Vec::new(); if let Some(v) = limit { @@ -62,7 +66,8 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { q.push(("offset".into(), v.to_string())); } if let Some(v) = organization_id { - q.push(("organization_id".into(), v)); + // The API validates the camelCase spelling and 400s on `organization_id`. + q.push(("organizationId".into(), v)); } if let Some(v) = status { q.push(("status".into(), v)); @@ -75,7 +80,12 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { } else { format!("?{}", q.into_iter().map(|(k, v)| format!("{k}={v}")).collect::>().join("&")) }; - print_list_envelope(&client.get(&format!("/jobs{query}")).await.context("GET jobs")?, "jobs"); + let body = client.get(&format!("/jobs{query}")).await.context("GET jobs")?; + if json { + print_json(&body); + } else { + print_list_envelope(&body, "jobs"); + } } Cmd::Show { job_id } => { print_json(&client.get(&format!("/jobs/{job_id}")).await.context("GET job")?); @@ -91,3 +101,25 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// CLI-Spec §flags: every platform `list` verb offers `--json`. + #[test] + fn list_accepts_json_flag_and_defaults_to_off() { + use clap::Parser; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + let on = Wrap::try_parse_from(["t", "list", "--json"]).expect("--json must parse"); + assert!(matches!(on.cmd, Cmd::List { json: true, .. })); + + let off = Wrap::try_parse_from(["t", "list"]).expect("bare list must still parse"); + assert!(matches!(off.cmd, Cmd::List { json: false, .. }), "--json must default to off"); + } +} diff --git a/crates/smooth-cli/src/smooai/knowledge.rs b/crates/smooth-cli/src/smooai/knowledge.rs index 6e8456be..3e63361d 100644 --- a/crates/smooth-cli/src/smooai/knowledge.rs +++ b/crates/smooth-cli/src/smooai/knowledge.rs @@ -31,6 +31,9 @@ pub enum Cmd { /// 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, + /// Print raw JSON instead of the list. + #[arg(long)] + json: bool, }, /// Show one knowledge document's metadata. Show { @@ -142,12 +145,14 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { print_knowledge_results(&resp); } } - Cmd::List { org } => { + Cmd::List { org, json } => { let o = require_active_org(&client, org)?; - print_list_envelope( - &client.get(&format!("/organizations/{o}/knowledge")).await.context("GET knowledge")?, - "knowledge docs", - ); + let body = client.get(&format!("/organizations/{o}/knowledge")).await.context("GET knowledge")?; + if json { + print_json(&body); + } else { + print_list_envelope(&body, "knowledge docs"); + } } Cmd::Show { doc_id, org } => { let o = require_active_org(&client, org)?; @@ -264,3 +269,25 @@ fn print_knowledge_results(resp: &serde_json::Value) { None => print_json(resp), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// CLI-Spec §flags: every platform `list` verb offers `--json`. + #[test] + fn list_accepts_json_flag_and_defaults_to_off() { + use clap::Parser; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + let on = Wrap::try_parse_from(["t", "list", "--json"]).expect("--json must parse"); + assert!(matches!(on.cmd, Cmd::List { json: true, .. })); + + let off = Wrap::try_parse_from(["t", "list"]).expect("bare list must still parse"); + assert!(matches!(off.cmd, Cmd::List { json: false, .. }), "--json must default to off"); + } +} diff --git a/crates/smooth-cli/src/smooai/members.rs b/crates/smooth-cli/src/smooai/members.rs index 850ed117..c508446c 100644 --- a/crates/smooth-cli/src/smooai/members.rs +++ b/crates/smooth-cli/src/smooai/members.rs @@ -12,6 +12,9 @@ pub enum Cmd { /// 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, + /// Print raw JSON instead of the list. + #[arg(long)] + json: bool, }, /// List the roles a member can be assigned. Roles { @@ -70,9 +73,14 @@ pub enum Cmd { pub async fn cmd(cmd: Cmd) -> Result<()> { let client = require_authed().await?; match cmd { - Cmd::List { org } => { + Cmd::List { org, json } => { let org = require_active_org(&client, org)?; - print_list_envelope(&client.get(&format!("/organizations/{org}/members")).await.context("GET members")?, "members"); + let body = client.get(&format!("/organizations/{org}/members")).await.context("GET members")?; + if json { + print_json(&body); + } else { + print_list_envelope(&body, "members"); + } } Cmd::Roles { org } => { let org = require_active_org(&client, org)?; @@ -137,3 +145,25 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// CLI-Spec §flags: every platform `list` verb offers `--json`. + #[test] + fn list_accepts_json_flag_and_defaults_to_off() { + use clap::Parser; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + let on = Wrap::try_parse_from(["t", "list", "--json"]).expect("--json must parse"); + assert!(matches!(on.cmd, Cmd::List { json: true, .. })); + + let off = Wrap::try_parse_from(["t", "list"]).expect("bare list must still parse"); + assert!(matches!(off.cmd, Cmd::List { json: false, .. }), "--json must default to off"); + } +} diff --git a/crates/smooth-cli/src/smooai/mod.rs b/crates/smooth-cli/src/smooai/mod.rs index b6dbf433..cef53146 100644 --- a/crates/smooth-cli/src/smooai/mod.rs +++ b/crates/smooth-cli/src/smooai/mod.rs @@ -279,9 +279,13 @@ pub fn print_list_envelope(body: &serde_json::Value, item_label: &str) { pub async fn cmd_orgs(cmd: super::OrgsCommands) -> Result<()> { let client = user_client::UserClient::from_user_session().await?; match cmd { - super::OrgsCommands::List => { + super::OrgsCommands::List { json } => { let body = client.get("/organizations").await.context("GET /organizations")?; - print_list_envelope(&body, "organizations"); + if json { + print_json(&body); + } else { + print_list_envelope(&body, "organizations"); + } } super::OrgsCommands::Show { org_id } => { // Use the shared resolver so `th api orgs show` honors diff --git a/crates/smooth-cli/src/smooai/products.rs b/crates/smooth-cli/src/smooai/products.rs index 8fe041b4..44578ce0 100644 --- a/crates/smooth-cli/src/smooai/products.rs +++ b/crates/smooth-cli/src/smooai/products.rs @@ -12,6 +12,9 @@ pub enum Cmd { /// 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, + /// Print raw JSON instead of the list. + #[arg(long)] + json: bool, }, /// Activate the free tier. Free { @@ -33,9 +36,14 @@ pub enum Cmd { pub async fn cmd(cmd: Cmd) -> Result<()> { let client = require_authed().await?; match cmd { - Cmd::List { org } => { + Cmd::List { org, json } => { let o = require_active_org(&client, org)?; - print_list_envelope(&client.get(&format!("/organizations/{o}/products")).await.context("GET products")?, "products"); + let body = client.get(&format!("/organizations/{o}/products")).await.context("GET products")?; + if json { + print_json(&body); + } else { + print_list_envelope(&body, "products"); + } } Cmd::Free { org } => { let o = require_active_org(&client, org)?; @@ -62,3 +70,25 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// CLI-Spec §flags: every platform `list` verb offers `--json`. + #[test] + fn list_accepts_json_flag_and_defaults_to_off() { + use clap::Parser; + + #[derive(Parser)] + struct Wrap { + #[command(subcommand)] + cmd: Cmd, + } + let on = Wrap::try_parse_from(["t", "list", "--json"]).expect("--json must parse"); + assert!(matches!(on.cmd, Cmd::List { json: true, .. })); + + let off = Wrap::try_parse_from(["t", "list"]).expect("bare list must still parse"); + assert!(matches!(off.cmd, Cmd::List { json: false, .. }), "--json must default to off"); + } +} diff --git a/docs/Engineering/CLI-Spec.md b/docs/Engineering/CLI-Spec.md new file mode 100644 index 00000000..0a19f356 --- /dev/null +++ b/docs/Engineering/CLI-Spec.md @@ -0,0 +1,129 @@ +# The `th` CLI Spec + +> The codified interface contract for the `th` binary (and its `smoo` alias). +> Pearl th-7f1da8. Enforced where possible by tests in +> `crates/smooth-cli/src/help.rs` (`help_sync_both_directions`, +> `spec_every_platform_list_has_json`); everything else is reviewed against +> this page. When a rule and the code disagree, one of them is a bug — fix or +> amend deliberately, never drift silently. + +## 1. One binary, two products + +- **`th`** is the standalone local agent toolbox: pearls, worktrees, agent + mail, the daemon, attest, the coding TUI. No account required, works + offline. +- **`smoo`** (a symlink to the same binary; argv[0] dispatch) is the Smoo AI + platform CLI: everything that talks to smoo.ai lives under this namespace, + and all of it authenticates via `smoo auth login`. `smoo X` ≡ `th smoo X`. +- Old pre-namespace spellings (`th api …`, `th config`, …) parse as **hidden + compat aliases**. They are load-bearing for existing docs; never remove one + without a sweep, never document one in new material. + +## 2. Command shape: noun, then verb + +``` +th [args] [flags] # local tool +smoo [args] [flags] # platform +``` + +- **Nouns are resource groups** (`pearls`, `crm`, `org`, `files`). Singular + and plural both parse — declare the canonical form and add the other via + `visible_alias` (the `/normalize` skill audits this). Exception: bare + `th agent` (mailbox registry) vs `smoo agents` (platform agents) — the two + trees keep the collision resolved; see Using-th-CLI §1a. +- **Standard verbs**, in this order of preference when naming a new one: + `list`, `show`, `create`, `update`, `rm`, `search`. Don't invent synonyms + (`get` for `show`, `delete` for `rm`) inside one group; when a sibling group + already shipped the synonym, alias it rather than diverging further. +- A group whose most common action is obvious may make it the **bare + default** (`smoo workforce` → `directory`), implemented as an + `Option`, never by duplicating a verb. + +## 3. Standard flags + +| Flag | Rule | +|---|---| +| `--json` | **Every read verb** (`list`/`show`/`search`/reports) offers it. Output is the raw response JSON, stable, unstyled, no truncation beyond what the server did. Enforced by test for every `list` under `smoo`. | +| `--org-id` (alias `--org`) | Every platform verb that acts on an org accepts an override; default is the active org (`smoo org switch`). | +| `--profile` | Global; selects the auth profile. Never define a per-command flag with this name. | +| `--confirm` | Required for any action that **sends, spends, or destroys** (`smoo campaigns send`). The unflagged invocation previews (server-side dry-run where the API offers one) and says exactly what `--confirm` would do. | +| `--dry-run` | For mutations where a preview needs to be explicit rather than the default. Prefer preview-by-default + `--confirm` for the dangerous class above. | + +## 4. Output contract + +- **Empty is an answer.** "No campaigns matched. This is a confirmed read of + the campaign list, not a read failure." — never exit non-zero, never print + nothing, never phrase an empty result as an error. +- **Truncation is always reported** ("showing 50 of 1,126 — …"). +- **Errors are two lines**: what failed (with the server's message verbatim + when there is one), then what to do next. Never a bare status code, never a + backtrace at a user. +- **Results on stdout, progress/notes on stderr**, so redirection works. +- Numbers/timings/money right-align in tables; state carries a **glyph** + (`●`/`○`/`◐`) as well as a color, so meaning survives `NO_COLOR`. + +## 5. Color (Presence) + +Full language: `.claude/skills/smooth-glow-up`. The CLI rules: + +- **Pipe-safe always**: every styled print goes through `anstream` (or clap's + own detection). `th … | grep` must never see an escape code; `NO_COLOR=1` + is honored. The integration test `no_ansi_when_piped` guards this. +- The **teal→blue gradient is the `th` wordmark** and Big Smooth's presence — + it appears on the wordmark and nowhere else. The orange→pink gradient is + the `Smoo` brand half. Neither is ever used for chrome, headers, or + emphasis. +- One accent: **teal** on command/flag literals (clap `brand_styles()` in + main.rs and the curated help). Headers bold. Secondary text dimmed. Amber + is reserved for "Big Smooth needs you" and nothing else. +- `--json` output is never styled. + +## 6. Help + +- **Bare `th --help`** renders the curated, grouped screen (`help.rs` + `SECTIONS`) — a map, not a manual. A two-way sync test pins it to the clap + tree: adding a command without adding it to a section fails CI. +- `th --help-full` prints clap's native flat tree. Per-command `--help` is + clap-native, themed by `brand_styles()`. +- Doc comments on commands: **first line ≤ ~70 chars** (it's the summary in + every listing), blank `///` line, then detail. Long prose belongs in the + detail block or the docs, not the summary. + +## 7. The `ai` explainer + +Append `ai` to any command path for a plain-markdown guide generated from the +clap tree plus curated examples: + +``` +th ai # the whole binary +smoo ai # the platform namespace +smoo org ai # one group: about, subcommands, flags, examples, conventions +``` + +- Output is **unstyled markdown**, written to be handed to an AI agent as + much as read by a human. +- It is generated — new subcommands appear automatically. Only + `help.rs::EXAMPLES` is hand-curated; add a row when a worked example + genuinely helps. +- The word `ai` only triggers when every preceding segment resolves to a real + command, so positional values are unaffected. + +## 8. Adding a surface (checklist) + +1. Platform resource → module under `crates/smooth-cli/src/smooai/`, + registered in `SmooCommands` (+ `ApiCommands` if it's route-shaped); + local tool → top-level `Commands`. +2. Clone the nearest sibling's shape; use the shared helpers + (`print_json`, `require_authed`, `require_active_org`, `UserClient`). +3. Verbs + flags per §2–§3; output per §4; colors per §5. +4. Add the command to a `help.rs` section (the sync test will remind you). +5. Colocated `#[cfg(test)]`: parse tests for every verb, unit tests for + rendering/logic. Live-smoke reads before shipping; never live-fire writes. +6. Docs: `docs/Engineering/Using-th-CLI.md` + help text. Changeset. + +## 9. Mirrors + +The hosted MCP server (mcp.smoo.ai) and this CLI are twins over the same +routes: every MCP read tool has a CLI verb (PR #488) and both follow §4's +empty/truncation rules. When adding to one surface, add to the other in the +same effort or file the pearl for it immediately. diff --git a/docs/Engineering/Using-th-CLI.md b/docs/Engineering/Using-th-CLI.md index bab88747..01e6b653 100644 --- a/docs/Engineering/Using-th-CLI.md +++ b/docs/Engineering/Using-th-CLI.md @@ -21,7 +21,7 @@ | **MCP / plugins / skills** | `th mcp`, `th plugin`, `th skills` | TOML manifests under `~/.smooth/` | | **Service ops** | `th service`, `th doctor`, `th cache`, `th audit` | local launchd / systemd, `~/.smooth/` | -Run `th --help` and `th --help` liberally — every subcommand is self-documenting. +Run `th --help` and `th --help` liberally — every subcommand is self-documenting. Append `ai` to any command path (`smoo org ai`, `th pearls ai`, bare `th ai`) for a generated markdown guide built for humans and AI agents alike. The full interface contract — verbs, flags, output, color — is codified in [`CLI-Spec.md`](CLI-Spec.md) and partly enforced by tests. ### 1a. The `smoo` namespace (pearl th-fc32d9)