tranc-cli 0.1.1

Tranc CLI — trade indicator queries from the command line.
//! `tranc auth` subcommands: login, whoami, logout, key create/list/revoke.
//!
//! Auth flow:
//!   `tranc auth login` → opens the Clerk-hosted OAuth page in the browser,
//!   then waits for the user to paste the API key (or device-code callback).
//!
//! For MVP the simplest flow is:
//!   1. Open https://app.tranc.ai/keys in the browser.
//!   2. Prompt the user to paste the API key.
//!   3. Store it in the OS keyring.
//!
//! This avoids needing a local HTTP callback server for the OAuth PKCE flow
//! (deferred to post-MVP). The device-code approach works for CLI agents too.

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};

use crate::client::{build_client, get_json};
use crate::config::canonical_base_url;
use crate::keyring_store;
use crate::output::print_json;

/// `tranc auth` — manage authentication and API keys.
#[derive(Debug, Parser)]
pub struct AuthCmd {
    #[command(subcommand)]
    sub: AuthSub,
}

#[derive(Debug, Subcommand)]
enum AuthSub {
    /// Log in by opening the Tranc dashboard and storing an API key.
    Login,

    /// Show the authenticated user / key info.
    Whoami,

    /// Log out (remove the stored token from the OS keyring).
    Logout,

    /// Manage API keys.
    Key(KeyCmd),
}

#[derive(Debug, Parser)]
struct KeyCmd {
    #[command(subcommand)]
    sub: KeySub,
}

#[derive(Debug, Subcommand)]
enum KeySub {
    /// Create a new API key.
    Create {
        /// Human-readable label for the key (e.g. "my-agent").
        #[arg(long, short)]
        name: String,
    },
    /// List all active API keys for the account.
    List,
    /// Revoke an API key by its ID.
    Revoke {
        /// The key ID to revoke.
        id: String,
    },
}

impl AuthCmd {
    pub async fn run(self, api_url: &str, pretty: bool) -> Result<()> {
        let base = canonical_base_url(api_url);
        match self.sub {
            AuthSub::Login => run_login(&base).await,
            AuthSub::Whoami => run_whoami(&base, pretty).await,
            AuthSub::Logout => run_logout(),
            AuthSub::Key(k) => match k.sub {
                KeySub::Create { name } => run_key_create(&base, &name, pretty).await,
                KeySub::List => run_key_list(&base, pretty).await,
                KeySub::Revoke { id } => run_key_revoke(&base, &id, pretty).await,
            },
        }
    }
}

async fn run_login(_base: &str) -> Result<()> {
    println!("Opening the Tranc dashboard to get an API key…");
    println!();
    println!("  https://app.tranc.ai/dashboard/keys");
    println!();

    // Attempt to open the browser. Silently ignore errors (e.g. headless CI).
    let _ = open::that("https://app.tranc.ai/dashboard/keys");

    println!("Paste your API key below and press Enter:");
    print!("> ");
    use std::io::Write;
    std::io::stdout().flush().ok();

    let mut key = String::new();
    std::io::stdin()
        .read_line(&mut key)
        .context("failed to read API key from stdin")?;
    let key = key.trim().to_string();

    if key.is_empty() {
        anyhow::bail!("no API key provided");
    }

    keyring_store::set_token(&key).context("failed to store API key in OS keyring")?;
    println!("Token stored in OS keyring. You're logged in.");
    Ok(())
}

async fn run_whoami(base: &str, pretty: bool) -> Result<()> {
    let (client, token) = build_client()?;
    // /v1/auth/me is the API-key-authed endpoint (returns ApiKeyRecord fields).
    // /v1/auth/whoami is Clerk-JWT-only (dashboard sessions).
    let url = format!("{base}/v1/auth/me");
    let rb = client.get(&url).bearer_auth(&token);
    let json = get_json(rb).await?;
    print_json(&json, pretty)
}

fn run_logout() -> Result<()> {
    keyring_store::delete_token().context("failed to remove token from OS keyring")?;
    println!("Logged out. Token removed from OS keyring.");
    Ok(())
}

async fn run_key_create(base: &str, name: &str, pretty: bool) -> Result<()> {
    let (client, token) = build_client()?;
    let url = format!("{base}/v1/keys");
    let body = serde_json::json!({ "name": name });
    let rb = client.post(&url).bearer_auth(&token).json(&body);
    let json = crate::client::post_json(rb).await?;
    print_json(&json, pretty)
}

async fn run_key_list(base: &str, pretty: bool) -> Result<()> {
    let (client, token) = build_client()?;
    let url = format!("{base}/v1/keys");
    let rb = client.get(&url).bearer_auth(&token);
    let json = get_json(rb).await?;
    print_json(&json, pretty)
}

async fn run_key_revoke(base: &str, id: &str, pretty: bool) -> Result<()> {
    let (client, token) = build_client()?;
    let url = format!("{base}/v1/keys/{id}");
    let rb = client.delete(&url).bearer_auth(&token);
    let json = get_json(rb).await?;
    print_json(&json, pretty)
}

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

    #[test]
    fn parse_auth_login() {
        let cli = AuthCmd::try_parse_from(["auth", "login"]).unwrap();
        assert!(matches!(cli.sub, AuthSub::Login));
    }

    #[test]
    fn parse_auth_whoami() {
        let cli = AuthCmd::try_parse_from(["auth", "whoami"]).unwrap();
        assert!(matches!(cli.sub, AuthSub::Whoami));
    }

    #[test]
    fn parse_auth_logout() {
        let cli = AuthCmd::try_parse_from(["auth", "logout"]).unwrap();
        assert!(matches!(cli.sub, AuthSub::Logout));
    }

    #[test]
    fn parse_key_create() {
        let cli = AuthCmd::try_parse_from(["auth", "key", "create", "--name", "my-agent"]).unwrap();
        assert!(matches!(
            cli.sub,
            AuthSub::Key(KeyCmd {
                sub: KeySub::Create { name: _ }
            })
        ));
    }

    #[test]
    fn parse_key_list() {
        let cli = AuthCmd::try_parse_from(["auth", "key", "list"]).unwrap();
        assert!(matches!(
            cli.sub,
            AuthSub::Key(KeyCmd { sub: KeySub::List })
        ));
    }

    #[test]
    fn parse_key_revoke() {
        let cli = AuthCmd::try_parse_from(["auth", "key", "revoke", "abc-123"]).unwrap();
        assert!(matches!(
            cli.sub,
            AuthSub::Key(KeyCmd {
                sub: KeySub::Revoke { .. }
            })
        ));
    }
}