Files
foxhunt/bin/fxt/src/main.rs
jgrusewski 078771bf95 feat(fxt): auto-refresh tokens and FXT_PASSWORD env var for non-interactive login
- Auto-refresh: before non-auth commands, if the access token is expired
  but a refresh token exists, silently regenerate tokens. Prints
  "Token refreshed." to stderr so users know what happened.
- FXT_PASSWORD: login reads password from this env var when set,
  skipping the interactive prompt. Safer than a --password flag
  (env vars don't appear in ps output or shell history).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:34:34 +01:00

219 lines
7.0 KiB
Rust

#![deny(clippy::unwrap_used, clippy::expect_used)]
#![allow(clippy::cognitive_complexity)]
#![allow(clippy::redundant_pattern_matching)]
//! FXT -- Foxhunt Operations Platform CLI
//!
//! Thin gRPC client that talks to the API Gateway for all operations:
//! trading, training, tuning, risk, services, cluster, data, and more.
use anyhow::Result;
use clap::{Parser, Subcommand};
use fxt::auth::token_manager::{AuthTokenManager, FileTokenStorage};
use fxt::auth::LoginClient;
use fxt::commands::{
agent, auth, backtest, broker, cluster, config, data, mcp, model, risk, service, trade,
train, tune, watch,
};
use fxt::grpc::FoxhuntClient;
use fxt::output::OutputFormat;
/// Foxhunt Operations Platform
#[derive(Parser)]
#[command(name = "fxt", about = "Foxhunt Operations Platform", version)]
struct Cli {
/// API Gateway endpoint URL
#[arg(
long = "api-url",
env = "FXT_API_URL",
default_value = "https://api.fxhnt.ai"
)]
api_url: String,
/// Output as JSON (for CI/LLM integration)
#[arg(long, short)]
json: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Authentication
Auth(auth::AuthCommand),
/// Order management
Trade(trade::TradeCommand),
/// ML training lifecycle
Train(train::TrainCommand),
/// Hyperparameter optimization
Tune(tune::TuneCommand),
/// Model management
Model(model::ModelCommand),
/// Trading agent control
Agent(agent::AgentCommand),
/// Backtesting
Backtest(backtest::BacktestCommand),
/// Broker connectivity
Broker(broker::BrokerCommand),
/// Data pipeline management
Data(data::DataCommand),
/// Service operations
Service(service::ServiceCommand),
/// Cluster operations
Cluster(cluster::ClusterCommand),
/// Risk management
Risk(risk::RiskCommand),
/// System configuration
Config(config::ConfigCommand),
/// TUI cockpit dashboard
Watch(watch::WatchCommand),
/// MCP server mode
Mcp(mcp::McpCommand),
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let format = if cli.json {
OutputFormat::Json
} else {
OutputFormat::Human
};
// Commands that do not need a gRPC connection.
match &cli.command {
Commands::Watch(cmd) => return cmd.execute().await,
Commands::Mcp(cmd) => return cmd.execute().await,
Commands::Auth(_)
| Commands::Trade(_)
| Commands::Train(_)
| Commands::Tune(_)
| Commands::Model(_)
| Commands::Agent(_)
| Commands::Backtest(_)
| Commands::Broker(_)
| Commands::Data(_)
| Commands::Service(_)
| Commands::Cluster(_)
| Commands::Risk(_)
| Commands::Config(_) => {}
}
// Connect to API Gateway (lazy -- no TCP until first RPC).
let client = FoxhuntClient::connect(&cli.api_url).await?;
// Auto-refresh: if the access token is expired but a refresh token exists,
// silently regenerate tokens before dispatching the command.
// Skip for auth commands (login/logout handle their own token lifecycle).
if !matches!(cli.command, Commands::Auth(_)) {
let storage = FileTokenStorage::new()?;
let manager = AuthTokenManager::new(storage);
if manager.needs_refresh().await {
let login_client = LoginClient::new(client.channel());
if login_client.silent_login(&manager).await? {
eprintln!("Token refreshed.");
}
}
}
match &cli.command {
Commands::Auth(cmd) => cmd.execute(&client, format).await,
Commands::Trade(cmd) => cmd.execute(&client, format).await,
Commands::Train(cmd) => cmd.execute(&client, format).await,
Commands::Tune(cmd) => cmd.execute(&client, format).await,
Commands::Model(cmd) => cmd.execute(&client, format).await,
Commands::Agent(cmd) => cmd.execute(&client, format).await,
Commands::Backtest(cmd) => cmd.execute(&client, format).await,
Commands::Broker(cmd) => cmd.execute(&client, format).await,
Commands::Data(cmd) => cmd.execute(&client, format).await,
Commands::Service(cmd) => cmd.execute(&client, format).await,
Commands::Cluster(cmd) => cmd.execute(&client, format).await,
Commands::Risk(cmd) => cmd.execute(&client, format).await,
Commands::Config(cmd) => cmd.execute(&client, format).await,
// Already handled above -- unreachable.
Commands::Watch(_) | Commands::Mcp(_) => Ok(()),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_cli_parse_auth_status() {
let cli = Cli::parse_from(["fxt", "auth", "status"]);
assert!(!cli.json);
assert_eq!(cli.api_url, "https://api.fxhnt.ai");
}
#[test]
fn test_cli_parse_json_flag() {
let cli = Cli::parse_from(["fxt", "--json", "auth", "status"]);
assert!(cli.json);
}
#[test]
fn test_cli_parse_api_url_override() {
let cli = Cli::parse_from(["fxt", "--api-url", "http://localhost:9090", "auth", "login"]);
assert_eq!(cli.api_url, "http://localhost:9090");
}
#[test]
fn test_cli_parse_trade_submit() {
let cli = Cli::parse_from([
"fxt", "trade", "submit", "--symbol", "ES.FUT", "--side", "buy", "--qty", "1",
]);
assert!(matches!(cli.command, Commands::Trade(_)));
}
#[test]
fn test_cli_parse_service_list() {
let cli = Cli::parse_from(["fxt", "service", "list"]);
assert!(matches!(cli.command, Commands::Service(_)));
}
#[test]
fn test_cli_parse_watch() {
let cli = Cli::parse_from(["fxt", "watch"]);
assert!(matches!(cli.command, Commands::Watch(_)));
}
#[test]
fn test_cli_parse_all_subcommands() {
// Verify every top-level subcommand parses without error.
let commands = [
vec!["fxt", "auth", "status"],
vec!["fxt", "trade", "positions"],
vec!["fxt", "train", "list"],
vec!["fxt", "tune", "status", "some-id"],
vec!["fxt", "model", "list"],
vec!["fxt", "agent", "status"],
vec![
"fxt", "backtest", "run", "--strategy", "s", "--symbol", "ES", "--from",
"2025-01-01", "--to", "2025-12-31",
],
vec!["fxt", "broker", "status"],
vec!["fxt", "data", "status"],
vec!["fxt", "service", "health"],
vec!["fxt", "cluster", "status"],
vec!["fxt", "risk", "status"],
vec!["fxt", "config", "env"],
vec!["fxt", "watch"],
vec!["fxt", "mcp"],
];
for args in &commands {
let result = Cli::try_parse_from(args.iter());
assert!(
result.is_ok(),
"Failed to parse: {:?} -- {}",
args,
result.err().map_or_else(String::new, |e| e.to_string())
);
}
}
}