Skip to main content

chipzen_sdk/
cli.rs

1//! Argument parsing + command dispatch for `chipzen-sdk`.
2
3use crate::scaffold::{scaffold_bot, ScaffoldOptions};
4use crate::validate::{validate_bot, Severity, ValidateOptions, DEFAULT_MAX_UPLOAD_BYTES};
5use anyhow::{bail, Context, Result};
6use chipzen_bot::{connect_to_chipzen, load_chipzen_config, resolve_token, ChipzenConfig, EnvName};
7use clap::{Parser, Subcommand};
8use std::path::PathBuf;
9
10const ABOUT: &str = "Chipzen poker bot SDK — scaffold and validate bot projects";
11
12#[derive(Debug, Parser)]
13#[command(name = "chipzen-sdk", version, about = ABOUT, long_about = None)]
14pub struct Cli {
15    #[command(subcommand)]
16    pub command: Command,
17}
18
19#[derive(Debug, Subcommand)]
20pub enum Command {
21    /// Scaffold a new bot project from the starter template.
22    Init {
23        /// Project name (used as the directory name and Cargo.toml `name`).
24        name: String,
25        /// Parent directory to create the project in (default: current dir).
26        #[arg(long)]
27        dir: Option<PathBuf>,
28    },
29    /// Run pre-upload checks: size, file_structure, cargo_metadata,
30    /// imports, bot_impl, decide_method.
31    ///
32    /// Note: there is no `--check-connectivity` flag (cf. the Python
33    /// and JavaScript SDKs). In Rust the protocol-conformance harness
34    /// ships as a library function — `chipzen_bot::run_conformance_checks`
35    /// — which runs 4 canned scenarios:
36    ///
37    /// * `connectivity_full_match` — handshake + 1 hand + match_end
38    /// * `multi_turn_request_id_echo` — 3 turn_requests, request_id echo
39    /// * `action_rejected_recovery` — safe-fallback retry on rejection
40    /// * `retry_storm_bounded` — reactive response to 3 back-to-back
41    ///   action_rejected messages
42    ///
43    /// Drive your bot through these from `tests/conformance.rs` and run
44    /// via `cargo test`. The scaffolded starter at
45    /// `packages/rust/starters/rust/` includes a working template.
46    ///
47    /// The validator is a courtesy linter — the authoritative gate is
48    /// server-side seccomp + cap-drop on the bot container.
49    Validate {
50        /// Path to the bot project directory.
51        path: PathBuf,
52        /// Override max upload size in MB (default: 500).
53        #[arg(long)]
54        max_size_mb: Option<u64>,
55        /// Disable colored output.
56        #[arg(long)]
57        no_color: bool,
58    },
59    /// Resolve + print the external-API remote-play connection (lobby URL,
60    /// env, token presence) from chipzen.toml + flags, without connecting.
61    ///
62    /// Unlike Python's `chipzen run-external my_bot.py`, the Rust `chipzen-sdk`
63    /// binary cannot dynamically load and run a bot from a file — a Rust bot
64    /// is compiled into its own binary. So this subcommand is a config doctor:
65    /// it does the same chipzen.toml discovery + env-aware URL resolution
66    /// `run_external_bot` does and reports what it found, so you can verify
67    /// your setup before wiring `chipzen_bot::run_external_cli` into your bot
68    /// binary's `main` (the scaffolded starter ships a `run-external` mode
69    /// that calls it). The token is never printed.
70    RunExternal {
71        /// Target environment for the lobby URL. Defaults to $CHIPZEN_ENV if
72        /// set, otherwise 'prod'.
73        #[arg(long, value_parser = ["prod", "staging", "local"])]
74        env: Option<String>,
75        /// External-API token (cz_extbot_...). Overrides [external_api].token
76        /// in chipzen.toml. Only its presence is reported, never its value.
77        #[arg(long)]
78        token: Option<String>,
79        /// External-API bot UUID. Overrides [external_api].bot_id in
80        /// chipzen.toml. Required when no [external_api].url is set.
81        #[arg(long)]
82        bot_id: Option<String>,
83    },
84}
85
86pub fn run(cli: Cli) -> Result<()> {
87    match cli.command {
88        Command::Init { name, dir } => run_init(&name, dir),
89        Command::Validate {
90            path,
91            max_size_mb,
92            no_color,
93        } => run_validate(&path, max_size_mb, no_color),
94        Command::RunExternal { env, token, bot_id } => {
95            run_external(env.as_deref(), token.as_deref(), bot_id.as_deref())
96        }
97    }
98}
99
100/// Resolve the external-API connection from chipzen.toml + flags and print a
101/// summary. Mirrors the resolution `run_external_bot` does so a dev can
102/// confirm their setup before wiring the bot binary. Never prints the token.
103fn run_external(
104    env: Option<&str>,
105    explicit_token: Option<&str>,
106    explicit_bot_id: Option<&str>,
107) -> Result<()> {
108    let config: Option<ChipzenConfig> =
109        load_chipzen_config(None).context("reading chipzen.toml")?;
110
111    // Branch 1: a verbatim url in chipzen.toml wins outright.
112    let config_url = config.as_ref().and_then(|c| c.url.clone());
113    let (url, resolved_env) = if let Some(url) = config_url {
114        (url, None)
115    } else {
116        // Branch 2: env-derived url. Need a bot_id.
117        let bot_id = explicit_bot_id
118            .map(str::to_string)
119            .or_else(|| config.as_ref().and_then(|c| c.bot_id.clone()));
120        let Some(bot_id) = bot_id.filter(|s| !s.is_empty()) else {
121            bail!(
122                "No lobby URL is configured. Either:\n  \
123                 - Pass --bot-id <id>, or\n  \
124                 - Set [external_api].bot_id in chipzen.toml, or\n  \
125                 - Set [external_api].url in chipzen.toml for a verbatim URL."
126            );
127        };
128        let env_name = env
129            .map(|e| EnvName::parse(e).ok_or_else(|| anyhow::anyhow!("unknown env {e:?}")))
130            .transpose()?;
131        let conn = connect_to_chipzen(&bot_id, env_name, None, config.clone())
132            .map_err(|e| anyhow::anyhow!("{e}"))?;
133        (conn.url, conn.env.map(|e| e.as_str().to_string()))
134    };
135
136    let token = resolve_token(explicit_token, config.as_ref());
137
138    println!("Chipzen external-API connection");
139    println!("{}", "=".repeat(50));
140    println!("  lobby URL : {url}");
141    if let Some(env) = resolved_env {
142        println!("  env       : {env}");
143    }
144    println!(
145        "  token     : {}",
146        if token.is_some() {
147            "present (cz_extbot_…)"
148        } else {
149            "MISSING — pass --token or set [external_api].token in chipzen.toml"
150        }
151    );
152    if let Some(cfg) = config.as_ref().and_then(|c| c.path.as_ref()) {
153        println!("  config    : {}", cfg.display());
154    } else {
155        println!("  config    : none found on the search path");
156    }
157    println!();
158    if token.is_none() {
159        bail!("no external-API token resolved — cannot run a remote-play session");
160    }
161    println!(
162        "Setup looks good. Wire chipzen_bot::run_external_cli(|| MyBot, args) into your bot\n\
163         binary's main to play (the scaffolded starter's `run-external` mode does this)."
164    );
165    Ok(())
166}
167
168fn run_init(name: &str, parent_dir: Option<PathBuf>) -> Result<()> {
169    let opts = ScaffoldOptions { parent_dir };
170    let created =
171        scaffold_bot(name, &opts).with_context(|| format!("scaffolding project {name:?}"))?;
172    println!("Created bot project: {}", created.display());
173    println!();
174    println!("Next steps:");
175    println!("  cd {name}");
176    println!("  cargo build");
177    println!("  # Edit src/main.rs to implement your strategy");
178    println!("  chipzen-sdk validate .");
179    Ok(())
180}
181
182fn run_validate(path: &std::path::Path, max_size_mb: Option<u64>, no_color: bool) -> Result<()> {
183    let opts = ValidateOptions {
184        max_upload_bytes: max_size_mb
185            .map(|mb| mb * 1024 * 1024)
186            .unwrap_or(DEFAULT_MAX_UPLOAD_BYTES),
187    };
188    let results = validate_bot(path, &opts)?;
189    print_results(&results, !no_color);
190
191    let fails = results
192        .iter()
193        .filter(|r| matches!(r.severity, Severity::Fail))
194        .count();
195    if fails > 0 {
196        std::process::exit(1);
197    }
198    Ok(())
199}
200
201fn print_results(results: &[crate::validate::ValidationResult], color: bool) {
202    let supports_color = color && std::io::IsTerminal::is_terminal(&std::io::stdout());
203    let green = if supports_color { "\x1b[92m" } else { "" };
204    let yellow = if supports_color { "\x1b[93m" } else { "" };
205    let red = if supports_color { "\x1b[91m" } else { "" };
206    let reset = if supports_color { "\x1b[0m" } else { "" };
207
208    println!();
209    println!("Chipzen Bot Validation");
210    println!("{}", "=".repeat(50));
211    for r in results {
212        let icon = match r.severity {
213            Severity::Pass => format!("{green}PASS{reset}"),
214            Severity::Warn => format!("{yellow}WARN{reset}"),
215            Severity::Fail => format!("{red}FAIL{reset}"),
216        };
217        println!("  [{icon}] {}: {}", r.name, r.message);
218    }
219
220    println!();
221    let fails = results
222        .iter()
223        .filter(|r| matches!(r.severity, Severity::Fail))
224        .count();
225    if fails > 0 {
226        let plural = if fails == 1 { "" } else { "s" };
227        println!("{red}{fails} check{plural} failed.{reset}");
228    } else {
229        println!("{green}All checks passed! Your bot is ready to upload.{reset}");
230    }
231}