tail-fin-cli 0.2.0

Multi-site browser automation CLI — Twitter, Reddit, Bloomberg, Coupang, PCC, Instagram, YouTube, Grok, SeekingAlpha, Xiaohongshu, 591
mod adapter;
mod cli;
mod session;

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

fn build_cli() -> (Command, Vec<Box<dyn adapter::CliAdapter>>) {
    let adapters = cli::adapters();

    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),
        );

    // Register gen's "run" command separately (it's not a standard adapter)
    #[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, adapters)
}

#[tokio::main]
async fn main() -> Result<(), TailFinError> {
    let (cmd, adapters) = build_cli();
    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");

    if connect.is_some() && cookies.is_some() {
        eprintln!("Error: --connect and --cookies cannot be used together.");
        eprintln!("  Use --connect for browser mode, or --cookies for cookie mode.");
        std::process::exit(1);
    }

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

    let (subcommand, sub_matches) = matches.subcommand().ok_or_else(|| {
        TailFinError::Api("No command provided. Use --help to see available commands.".into())
    })?;

    // Handle "run" specially (gen's dynamic command)
    #[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;
    }

    // Find and dispatch to the matching adapter
    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
    )))
}