- 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>
219 lines
7.0 KiB
Rust
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())
|
|
);
|
|
}
|
|
}
|
|
}
|