tail-fin-cli 0.5.1

Multi-site browser automation CLI — attaches to Chrome or auto-launches a stealth browser to drive 15+ sites
mod adapter;
mod cli;
mod repl;
mod session;

use clap::Command;
use session::Ctx;
use tail_fin_common::TailFinError;

fn build_command(adapters: &[Box<dyn adapter::CliAdapter>]) -> Command {
    let mut cmd = Command::new("tail-fin")
        .about("Multi-site browser automation CLI")
        .arg(
            clap::Arg::new("connect")
                .long("connect")
                .value_name("HOST:PORT")
                .help("Connect to Chrome browser at <HOST:PORT> (e.g. 127.0.0.1:9222)")
                .global(true),
        )
        .arg(
            clap::Arg::new("cookies")
                .long("cookies")
                .value_name("PATH")
                .help("Use saved cookies instead of browser")
                .num_args(0..=1)
                .default_missing_value("auto")
                .require_equals(true)
                .global(true),
        )
        .arg(
            clap::Arg::new("headed")
                .long("headed")
                .help("Run browser in headed mode")
                .action(clap::ArgAction::SetTrue)
                .global(true),
        )
        .arg(
            clap::Arg::new("repl")
                .long("repl")
                .help("Start an interactive REPL (default when no subcommand given)")
                .action(clap::ArgAction::SetTrue),
        );

    #[cfg(feature = "gen")]
    {
        cmd = cmd.subcommand(
            Command::new("run")
                .about("Run a dynamically generated site command")
                .arg(clap::Arg::new("site").required(true).help("Site name"))
                .arg(
                    clap::Arg::new("command")
                        .required(true)
                        .help("Command name"),
                )
                .arg(
                    clap::Arg::new("args")
                        .num_args(..)
                        .trailing_var_arg(true)
                        .help("Extra arguments"),
                ),
        );
    }

    for adapter in adapters {
        cmd = cmd.subcommand(adapter.command());
    }

    cmd
}

#[tokio::main]
async fn main() -> Result<(), TailFinError> {
    let adapters = cli::adapters();
    let cmd = build_command(&adapters);
    let matches = cmd.get_matches();

    let connect = matches.get_one::<String>("connect").cloned();
    let cookies = matches.get_one::<String>("cookies").cloned();
    let headed = matches.get_flag("headed");
    let repl_flag = matches.get_flag("repl");

    if connect.is_some() && cookies.is_some() {
        return Err(TailFinError::Api(
            "--connect and --cookies cannot be used together. Use --connect for browser mode, or --cookies for cookie mode.".into(),
        ));
    }

    let ctx = Ctx {
        connect,
        cookies,
        headed,
    };

    // Explicit conflict: --repl cannot be combined with a subcommand.
    if repl_flag && matches.subcommand().is_some() {
        return Err(TailFinError::Api(
            "--repl cannot be combined with a subcommand".into(),
        ));
    }

    // Enter REPL if --repl was passed, or if no subcommand was given at all.
    if repl_flag || matches.subcommand().is_none() {
        return repl::run_repl(ctx, adapters).await;
    }

    let (subcommand, sub_matches) = matches
        .subcommand()
        .expect("subcommand presence checked above");

    #[cfg(feature = "gen")]
    if subcommand == "run" {
        let site = sub_matches
            .get_one::<String>("site")
            .cloned()
            .unwrap_or_default();
        let command = sub_matches
            .get_one::<String>("command")
            .cloned()
            .unwrap_or_default();
        let args: Vec<String> = sub_matches
            .get_many::<String>("args")
            .map(|v| v.cloned().collect())
            .unwrap_or_default();
        return cli::gen::run_dynamic(site, command, args, &ctx).await;
    }

    for adapter in &adapters {
        if adapter.name() == subcommand {
            return adapter.dispatch(sub_matches, &ctx).await;
        }
    }

    Err(TailFinError::Api(format!(
        "Unknown command '{}'. Use --help to see available commands.",
        subcommand
    )))
}