tail-fin-cli 0.3.0

Multi-site browser automation CLI — Twitter, Reddit, Bloomberg, Coupang, PCC, Instagram, YouTube, Grok, SeekingAlpha, Xiaohongshu, 591, Nansen
use std::collections::HashMap;

use clap::Subcommand;
use tail_fin_common::TailFinError;

use crate::session::{browser_session, print_json, Ctx};

#[derive(Subcommand)]
pub enum GenAction {
    /// Explore a website to discover API endpoints
    Explore {
        url: String,
        #[arg(long)]
        site: Option<String>,
        #[arg(long, default_value_t = 3)]
        wait: u64,
    },
    /// Detect authentication strategy for a URL
    Cascade { url: String },
    /// Generate command configs from explore data
    Synthesize { site: String },
    /// One-shot: explore + synthesize a website
    Generate {
        url: String,
        #[arg(long)]
        goal: Option<String>,
        #[arg(long)]
        site: Option<String>,
    },
    /// List generated sites and commands
    List,
}

pub async fn run(action: GenAction, ctx: &Ctx) -> Result<(), TailFinError> {
    match action {
        GenAction::Explore { url, site, wait } => {
            let chrome_host = ctx.connect.as_deref().unwrap_or("127.0.0.1:9222");
            let session = browser_session(chrome_host, ctx.headed).await?;
            let result =
                tail_fin_gen::explore_url(&session, &url, site.as_deref(), None, wait).await?;
            eprintln!("{}", tail_fin_gen::explore::render_explore_summary(&result));
            print_json(&result)?;
        }
        GenAction::Cascade { url } => {
            let chrome_host = ctx.connect.as_deref().unwrap_or("127.0.0.1:9222");
            let session = browser_session(chrome_host, ctx.headed).await?;
            // Navigate to the domain first
            let parsed = url::Url::parse(&url)
                .map_err(|e| TailFinError::Parse(format!("Invalid URL '{}': {}", url, e)))?;
            let domain = parsed
                .host_str()
                .ok_or_else(|| TailFinError::Parse("URL has no host".into()))?;
            let origin = format!("{}://{}", parsed.scheme(), domain);
            let _ = session.navigate(&origin).await;
            tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
            let result = tail_fin_gen::cascade::cascade_probe(&session, &url).await?;
            eprintln!("{}", tail_fin_gen::cascade::render_cascade_result(&result));
            print_json(&result)?;
        }
        GenAction::Synthesize { site } => {
            let result = tail_fin_gen::synthesize(&site)?;
            eprintln!(
                "{}",
                tail_fin_gen::synthesize::render_synthesize_summary(&result)
            );
            print_json(&result)?;
        }
        GenAction::Generate { url, goal, site } => {
            let chrome_host = ctx.connect.as_deref().unwrap_or("127.0.0.1:9222");
            let session = browser_session(chrome_host, ctx.headed).await?;
            let result =
                tail_fin_gen::generate_cli(&session, &url, goal.as_deref(), site.as_deref())
                    .await?;
            eprintln!(
                "{}",
                tail_fin_gen::generate::render_generate_summary(&result)
            );
            print_json(&result)?;
        }
        GenAction::List => {
            let sites = tail_fin_gen::list_dynamic_sites();
            if sites.is_empty() {
                eprintln!("No generated sites found.");
                eprintln!("  Use `tail-fin gen generate <URL>` to create one.");
            } else {
                for (site, commands) in &sites {
                    println!("{}:", site);
                    for cmd in commands {
                        println!("  - {}", cmd);
                    }
                }
            }
        }
    }
    Ok(())
}

pub async fn run_dynamic(
    site: String,
    command: String,
    args: Vec<String>,
    ctx: &Ctx,
) -> Result<(), TailFinError> {
    let config = tail_fin_gen::load_command_config(&site, &command)?;
    let chrome_host = ctx.connect.as_deref().unwrap_or("127.0.0.1:9222");
    let session = browser_session(chrome_host, ctx.headed).await?;

    // Navigate to the site domain and wait for page load
    let _ = session
        .navigate(&format!("https://{}", config.domain))
        .await;
    tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;

    // Parse extra args: support "key=value" and "--key value" formats
    let mut extra_args: HashMap<String, String> = HashMap::new();
    let mut i = 0;
    while i < args.len() {
        if let Some(eq_pos) = args[i].find('=') {
            let key = args[i][..eq_pos].trim_start_matches('-').to_string();
            let val = args[i][eq_pos + 1..].to_string();
            extra_args.insert(key, val);
            i += 1;
        } else if args[i].starts_with("--") && i + 1 < args.len() {
            let key = args[i].trim_start_matches('-').to_string();
            let val = args[i + 1].clone();
            extra_args.insert(key, val);
            i += 2;
        } else {
            eprintln!("Warning: ignoring unparsed arg '{}'", args[i]);
            i += 1;
        }
    }

    let result = tail_fin_gen::execute_command(&session, &config, &extra_args).await?;
    print_json(&serde_json::json!({
        "site": site,
        "command": command,
        "result": result,
    }))?;
    Ok(())
}

pub struct Adapter;

impl crate::adapter::CliAdapter for Adapter {
    fn name(&self) -> &'static str {
        "gen"
    }

    fn about(&self) -> &'static str {
        "Generate CLI adapters for new sites"
    }

    fn command(&self) -> clap::Command {
        <GenAction as clap::Subcommand>::augment_subcommands(
            clap::Command::new("gen").about("Generate CLI adapters for new sites"),
        )
    }

    fn dispatch<'a>(
        &'a self,
        matches: &'a clap::ArgMatches,
        ctx: &'a crate::session::Ctx,
    ) -> std::pin::Pin<
        Box<
            dyn std::future::Future<Output = Result<(), tail_fin_common::TailFinError>> + Send + 'a,
        >,
    > {
        Box::pin(async move {
            let action = <GenAction as clap::FromArgMatches>::from_arg_matches(matches)
                .map_err(|e| tail_fin_common::TailFinError::Api(e.to_string()))?;
            run(action, ctx).await
        })
    }
}