1use clap::{Parser, Subcommand};
2use output::{CommandOutput, OutputFormat};
3
4pub mod auth;
5pub mod client;
6pub mod commands;
7pub mod config;
8pub mod errors;
9pub mod mcp;
10pub mod output;
11
12use client::IndodaxClient;
13use errors::IndodaxError;
14
15#[derive(Debug, Parser)]
16#[command(
17 name = "indodax",
18 version,
19 about = "Command-line interface for the Indodax cryptocurrency exchange",
20 long_about = None
21)]
22pub struct Cli {
23 #[command(subcommand)]
24 pub command: Command,
25
26 #[arg(short = 'o', long = "output", default_value = "table", help = "Output format: table or json")]
27 pub output: OutputFormat,
28
29 #[arg(long = "api-key", help = "API key (overrides config file and env var)")]
30 pub api_key: Option<String>,
31
32 #[arg(long = "api-secret", help = "API secret (overrides config file and env var)")]
33 pub api_secret: Option<String>,
34
35 #[arg(short = 'v', long = "verbose", help = "Enable verbose output")]
36 pub verbose: bool,
37}
38
39#[derive(Debug, Subcommand)]
40pub enum Command {
41 #[command(name = "market", about = "Public market data")]
42 Market {
43 #[command(subcommand)]
44 cmd: commands::market::MarketCommand,
45 },
46
47 #[command(name = "account", about = "Account information and balances")]
48 Account {
49 #[command(subcommand)]
50 cmd: commands::account::AccountCommand,
51 },
52
53 #[command(name = "trade", about = "Place and manage orders")]
54 Trade {
55 #[command(subcommand)]
56 cmd: commands::trade::TradeCommand,
57 },
58
59 #[command(name = "funding", about = "Deposit and withdrawal operations")]
60 Funding {
61 #[command(subcommand)]
62 cmd: commands::funding::FundingCommand,
63 },
64
65 #[command(name = "ws", about = "WebSocket streaming")]
66 Ws {
67 #[command(subcommand)]
68 cmd: commands::websocket::WebSocketCommand,
69 },
70
71 #[command(name = "paper", about = "Paper trading (simulated)")]
72 Paper {
73 #[command(subcommand)]
74 cmd: commands::paper::PaperCommand,
75 },
76
77 #[command(name = "auth", about = "Manage API credentials")]
78 Auth {
79 #[command(subcommand)]
80 cmd: commands::auth::AuthCommand,
81 },
82
83 #[command(name = "alert", about = "Price alert management")]
84 Alert {
85 #[command(subcommand)]
86 cmd: commands::alert::AlertCommand,
87 },
88
89 #[command(name = "setup", about = "Interactive setup wizard")]
90 Setup,
91
92 #[command(name = "shell", about = "Start interactive REPL")]
93 Shell,
94
95 #[command(name = "mcp", about = "Start MCP stdio server for AI agent integration")]
96 Mcp {
97 #[arg(short = 's', long = "groups", default_value = "market,account,paper,auth", help = "Comma-separated service groups: market, account, trade, funding, paper, auth")]
98 groups: String,
99 #[arg(long, help = "Allow dangerous operations (trade, funding) without acknowledged flag")]
100 allow_dangerous: bool,
101 },
102}
103
104pub async fn dispatch(
105 cli: Cli,
106 client: &IndodaxClient,
107 config: &mut config::IndodaxConfig,
108) -> Result<CommandOutput, IndodaxError> {
109 let output = match cli.command {
110 Command::Market { cmd } => commands::market::execute(client, &cmd).await
111 .map_err(|e| map_anyhow_error(e))?,
112 Command::Account { cmd } => commands::account::execute(client, &cmd).await
113 .map_err(|e| map_anyhow_error(e))?,
114 Command::Trade { cmd } => commands::trade::execute(client, &cmd).await
115 .map_err(|e| map_anyhow_error(e))?,
116 Command::Funding { cmd } => commands::funding::execute(client, config, &cmd, cli.output).await
117 .map_err(|e| map_anyhow_error(e))?,
118 Command::Ws { cmd } => commands::websocket::execute(client, &cmd, cli.output).await
119 .map_err(|e| map_anyhow_error(e))?,
120 Command::Paper { cmd } => commands::paper::execute(client, config, &cmd).await?,
121 Command::Auth { cmd } => commands::auth::execute(client, config, &cmd).await
122 .map_err(|e| map_anyhow_error(e))?,
123 Command::Alert { cmd } => commands::alert::execute(client, &None, &cmd).await
124 .map_err(|e| map_anyhow_error(e))?,
125 Command::Setup | Command::Shell | Command::Mcp { .. } => {
126 return Err(IndodaxError::Other("This command is handled separately".into()));
127 }
128 };
129
130 Ok(output.with_format(cli.output))
131}
132
133pub fn map_anyhow_error(e: anyhow::Error) -> IndodaxError {
134 e.downcast::<IndodaxError>()
135 .unwrap_or_else(|e| IndodaxError::Other(e.to_string()))
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn test_cli_parse_market_ticker() {
144 let args = vec!["indodax", "market", "ticker", "btc_idr"];
145 let cli = Cli::try_parse_from(args).unwrap();
146 match cli.command {
147 Command::Market { cmd: _ } => {
148 }
150 _ => assert!(false, "Expected Market command, got {:?}", cli.command),
151 }
152 }
153
154 #[test]
155 fn test_cli_parse_output_json() {
156 let args = vec!["indodax", "-o", "json", "market", "ticker"];
157 let cli = Cli::try_parse_from(args).unwrap();
158 assert_eq!(cli.output, OutputFormat::Json);
159 }
160
161 #[test]
162 fn test_cli_parse_api_key() {
163 let args = vec!["indodax", "--api-key", "mykey", "market", "ticker"];
164 let cli = Cli::try_parse_from(args).unwrap();
165 assert_eq!(cli.api_key, Some("mykey".into()));
166 }
167
168 #[test]
169 fn test_cli_parse_api_secret() {
170 let args = vec!["indodax", "--api-secret", "mysecret", "market", "ticker"];
171 let cli = Cli::try_parse_from(args).unwrap();
172 assert_eq!(cli.api_secret, Some("mysecret".into()));
173 }
174
175 #[test]
176 fn test_cli_parse_verbose() {
177 let args = vec!["indodax", "-v", "market", "ticker"];
178 let cli = Cli::try_parse_from(args).unwrap();
179 assert!(cli.verbose);
180 }
181
182 #[test]
183 fn test_command_variants() {
184 let _cmd1 = Command::Market { cmd: crate::commands::market::MarketCommand::ServerTime };
185 let _cmd2 = Command::Account { cmd: crate::commands::account::AccountCommand::Info };
186 let _cmd3 = Command::Trade { cmd: crate::commands::trade::TradeCommand::Buy {
187 pair: "btc_idr".into(),
188 idr: 100_000.0,
189 price: None
190 }};
191 let _cmd4 = Command::Funding { cmd: crate::commands::funding::FundingCommand::WithdrawFee {
192 currency: "btc".into(),
193 network: None
194 }};
195 let _cmd5 = Command::Ws { cmd: crate::commands::websocket::WebSocketCommand::Ticker {
196 pair: "btc_idr".into()
197 }};
198 let _cmd6 = Command::Paper { cmd: crate::commands::paper::PaperCommand::Balance };
199 let _cmd7 = Command::Auth { cmd: crate::commands::auth::AuthCommand::Show };
200 let _cmd8 = Command::Setup;
201 let _cmd9 = Command::Shell;
202 let _cmd10 = Command::Mcp { groups: "market,paper".into(), allow_dangerous: false };
203 }
204
205 #[test]
206 fn test_output_format_clap() {
207 let args = vec!["indodax", "-o", "table", "market", "ticker"];
209 let cli = Cli::try_parse_from(args).unwrap();
210 assert_eq!(cli.output, OutputFormat::Table);
211 }
212
213 #[test]
214 fn test_cli_parse_default_output() {
215 let args = vec!["indodax", "market", "ticker"];
216 let cli = Cli::try_parse_from(args).unwrap();
217 assert_eq!(cli.output, OutputFormat::Table);
218 }
219
220 #[test]
221 fn test_command_display() {
222 let cli = Cli::try_parse_from(vec!["indodax", "market", "ticker"]).unwrap();
223 let _ = format!("{:?}", cli);
225 }
226}