schwab-api-cli 0.1.5

schwab-api-cli — agent-first CLI for Charles Schwab Trader API (experimental — use at your own risk)
Documentation
use anyhow::{Context, Result};
use console::Style;
use schwab_api::ClientConfig;
use serde_json::json;
use std::time::Duration;

use crate::auth_callback::capture_redirect_code;
use crate::auth_reminder::assess_refresh_token;
use crate::cli::AuthCommands;
use crate::config::RuntimeConfig;
use crate::output::ResponseEnvelope;

pub async fn run(runtime: &RuntimeConfig, command: AuthCommands) -> Result<()> {
    match command {
        AuthCommands::Login { code } => login(runtime, code).await,
        AuthCommands::Status => status(runtime).await,
        AuthCommands::Refresh => refresh(runtime).await,
        AuthCommands::Logout => logout(runtime).await,
    }
}

async fn login(runtime: &RuntimeConfig, code: Option<String>) -> Result<()> {
    if runtime.dry_run {
        let envelope = ResponseEnvelope::ok(
            "auth login",
            json!({ "dry_run": true, "message": "Would run OAuth login flow" }),
        );
        runtime.emit(envelope);
        return Ok(());
    }

    let config = ClientConfig::from_env().context("Missing SCHWAB_APP_KEY / SCHWAB_APP_SECRET")?;
    let oauth = schwab_api::OAuthClient::new(config.clone());
    let authorize_url = oauth.authorize_url();

    let auth_code = if let Some(c) = code {
        c
    } else {
        let redirect_uri = config.redirect_uri.clone();
        let use_https = redirect_uri.starts_with("https://");

        println!(
            "{}",
            Style::new()
                .cyan()
                .apply_to("Starting local OAuth callback listener…")
        );
        if use_https {
            println!(
                "{}",
                Style::new().yellow().apply_to(
                    "Listening on https://127.0.0.1:8182. \
                     When redirected, accept the self-signed certificate (Advanced → Proceed)."
                )
            );
        }

        let capture = tokio::spawn(capture_redirect_code(
            redirect_uri,
            Duration::from_secs(120),
        ));

        if runtime.is_tty() {
            inquire::Confirm::new("Ready to open Schwab login in your browser?")
                .with_default(true)
                .with_help_message("Authorization codes expire in ~30 seconds after redirect")
                .prompt()?;
        }

        let _ = webbrowser::open(&authorize_url);
        println!(
            "{}",
            Style::new().dim().apply_to(
                "Complete login in the browser. The CLI will capture the redirect automatically."
            )
        );

        match capture.await {
            Ok(Ok(Some(code))) => code,
            Ok(Ok(None)) | Ok(Err(_)) | Err(_) if runtime.is_tty() => {
                println!(
                    "{}",
                    Style::new().yellow().apply_to(
                        "Auto-capture did not finish. After accepting the certificate in the browser, \
                         paste the redirect URL immediately (codes expire in ~30 seconds)."
                    )
                );
                inquire::Text::new("Paste redirect URL or authorization code")
                    .with_help_message("From https://127.0.0.1:8182/?code=… in the address bar")
                    .prompt()?
            }
            Ok(Ok(None)) => anyhow::bail!(
                "OAuth callback timed out. Run: schwab auth login --code '<redirect-url>'"
            ),
            Ok(Err(e)) => return Err(e),
            Err(e) => return Err(e.into()),
        }
    };

    let parsed_code = extract_auth_code(&auth_code);
    let tokens = oauth.exchange_code(&parsed_code).await.map_err(|e| {
        anyhow::anyhow!(
            "Token exchange failed: {e}. \
             Authorization codes expire in ~30 seconds — run `schwab auth login` again and complete quickly. \
             Also verify SCHWAB_REDIRECT_URI is exactly https://127.0.0.1:8182"
        )
    })?;

    let envelope = ResponseEnvelope::ok(
        "auth login",
        json!({
            "authenticated": true,
            "expires_at": tokens.expires_at,
            "expires_in_seconds": tokens.expires_in_seconds(),
            "token_path": oauth.store().path(),
        }),
    )
    .with_next_actions(vec![
        "schwab auth status --json".into(),
        "schwab accounts numbers --json".into(),
    ]);
    runtime.emit(envelope);
    Ok(())
}

async fn status(runtime: &RuntimeConfig) -> Result<()> {
    let config = ClientConfig::from_env().context("Missing Schwab app credentials")?;
    let oauth = schwab_api::OAuthClient::new(config);
    let tokens = oauth.status().await?;

    let data = match tokens {
        Some(t) => {
            let reminder = assess_refresh_token(&t);
            json!({
                "authenticated": true,
                "expires_at": t.expires_at,
                "expires_in_seconds": t.expires_in_seconds(),
                "expired": t.is_expired(),
                "obtained_at": t.obtained_at,
                "refresh_expires_in_seconds": t.refresh_expires_in_seconds(),
                "auth_reminder": {
                    "level": reminder.level.as_str(),
                    "message": reminder.message,
                },
                "token_path": oauth.store().path(),
            })
        }
        None => json!({
            "authenticated": false,
            "token_path": oauth.store().path(),
            "hint": "Browser login alone is not enough — run `schwab auth login` and paste the redirect URL containing code="
        }),
    };

    let mut envelope = ResponseEnvelope::ok("auth status", data);
    let auth_needs_login = !envelope.data["authenticated"].as_bool().unwrap_or(false)
        || envelope.data["auth_reminder"]["level"]
            .as_str()
            .is_some_and(|l| l == "urgent" || l == "expired");
    if auth_needs_login {
        envelope.next_actions = vec!["schwab auth login".into()];
    }
    runtime.emit(envelope);
    Ok(())
}

async fn refresh(runtime: &RuntimeConfig) -> Result<()> {
    use crate::safety::require_mutation_approval;
    require_mutation_approval(runtime, "auth refresh", "Refresh OAuth access token.")?;
    if runtime.dry_run {
        runtime.emit(ResponseEnvelope::ok(
            "auth refresh",
            json!({ "dry_run": true }),
        ));
        return Ok(());
    }

    let config = ClientConfig::from_env()?;
    let oauth = schwab_api::OAuthClient::new(config);
    let tokens = oauth.refresh().await?;

    runtime.emit(ResponseEnvelope::ok(
        "auth refresh",
        json!({
            "expires_at": tokens.expires_at,
            "expires_in_seconds": tokens.expires_in_seconds(),
        }),
    ));
    Ok(())
}

async fn logout(runtime: &RuntimeConfig) -> Result<()> {
    use crate::safety::require_mutation_approval;
    require_mutation_approval(runtime, "auth logout", "Delete stored OAuth tokens.")?;
    if runtime.dry_run {
        runtime.emit(ResponseEnvelope::ok(
            "auth logout",
            json!({ "dry_run": true }),
        ));
        return Ok(());
    }

    let config = ClientConfig::from_env()?;
    let oauth = schwab_api::OAuthClient::new(config);
    oauth.logout().await?;
    runtime.emit(ResponseEnvelope::ok(
        "auth logout",
        json!({ "cleared": true, "token_path": oauth.store().path() }),
    ));
    Ok(())
}

fn extract_auth_code(raw: &str) -> String {
    let normalized: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
    let code = if let Some(idx) = normalized.find("code=") {
        let rest = &normalized[idx + 5..];
        rest.split('&').next().unwrap_or(rest).trim_end_matches('/')
    } else {
        normalized.as_str()
    };
    urlencoding::decode(code)
        .map(|c| c.into_owned())
        .unwrap_or_else(|_| code.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extracts_code_from_redirect_url() {
        let url = "https://127.0.0.1:8182/?code=ABC123&session=xyz";
        assert_eq!(extract_auth_code(url), "ABC123");
    }

    #[test]
    fn extracts_code_with_line_breaks() {
        let url = "https://127.0.0.1:8182/?code=ABC%40123&session=abc-\ndef";
        assert_eq!(extract_auth_code(url), "ABC@123");
    }
}