Skip to main content

indodax_cli/
lib.rs

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", global = true)]
27    pub output: OutputFormat,
28
29    #[arg(long = "api-key", help = "API key (overrides config file and env var)", global = true)]
30    pub api_key: Option<String>,
31
32    #[arg(long = "api-secret", help = "API secret (overrides config file and env var)", global = true)]
33    pub api_secret: Option<String>,
34
35    #[arg(long = "api-secret-stdin", help = "Read API secret from stdin (more secure than --api-secret)", global = true)]
36    pub api_secret_stdin: bool,
37
38    #[arg(short = 'v', long = "verbose", help = "Enable verbose output", global = true)]
39    pub verbose: bool,
40
41    #[arg(long = "yes", alias = "force", help = "Skip confirmation prompts for destructive operations", global = true)]
42    pub yes: bool,
43}
44
45#[derive(Debug, Subcommand)]
46pub enum Command {
47    // === Flat Public Market Commands (originally nested under Market) ===
48    /// Get server time
49    ServerTime,
50
51    /// List available trading pairs
52    Pairs,
53
54    /// Get ticker for a pair
55    Ticker {
56        #[arg(default_value = "btc_idr")]
57        pair: String,
58    },
59
60    /// Get tickers for all pairs
61    TickerAll,
62
63    /// Get 24h and 7d summaries for all pairs
64    Summaries,
65
66    /// Get order book for a pair
67    Orderbook {
68        #[arg(default_value = "btc_idr")]
69        pair: String,
70        #[arg(long, default_value = "20", help = "Number of bid/ask levels to show")]
71        count: usize,
72    },
73
74    /// Get recent trades for a pair
75    Trades {
76        #[arg(default_value = "btc_idr")]
77        pair: String,
78    },
79
80    /// Get OHLCV candle data (default --since is 24h ago)
81    Ohlc {
82        #[arg(short, long, default_value = "btc_idr")]
83        pair: String,
84        #[arg(long, default_value = "60")]
85        interval: String,
86        #[arg(short, long, help = "Start timestamp in seconds (default: 24h ago)")]
87        since: Option<u64>,
88        #[arg(long, help = "End timestamp in seconds (default: now)")]
89        to: Option<u64>,
90    },
91
92    /// Get price increments (tick sizes)
93    PriceIncrements,
94
95    // === Flat Private Account Commands (originally nested under Account) ===
96    /// Get current account information (balances, permissions, etc.)
97    AccountInfo,
98
99    /// Get non-zero account balances
100    Balance,
101
102    /// Get your deposit/withdrawal transactions
103    Transactions,
104
105    /// Get your trade history for a specific symbol
106    TradesHistory {
107        /// Trading pair symbol (e.g., btc_idr)
108        pair: String,
109
110        /// Number of trades to return (default: 500)
111        #[arg(short, long, default_value = "500")]
112        limit: usize,
113
114        /// Start from this trade ID (optional)
115        #[arg(long)]
116        from_id: Option<u64>,
117    },
118
119    // === Flat Trading Command (originally nested under Trade) ===
120    /// Place and manage orders
121    #[command(subcommand)]
122    Order(commands::trade::TradeCommand),
123
124    // === Flat Funding / Withdrawal Commands (originally nested under Funding) ===
125    /// Withdraw cryptocurrency
126    Withdraw {
127        #[arg(short, long)]
128        asset: String,
129        #[arg(short = 'v', long, help = "Amount to withdraw")]
130        volume: f64,
131        #[arg(long, help = "Crypto destination address (or Indodax username if --username is set)")]
132        address: String,
133        #[arg(long, help = "Withdraw to Indodax username instead of blockchain")]
134        username: bool,
135        #[arg(long, help = "Memo/tag (for currencies that require it)")]
136        memo: Option<String>,
137        #[arg(long, help = "Blockchain network")]
138        network: Option<String>,
139        #[arg(long, help = "Callback URL for withdrawal confirmation")]
140        callback_url: Option<String>,
141    },
142
143    /// Manage withdrawal fees and servers
144    #[command(subcommand)]
145    Withdrawal(WithdrawalSubcommand),
146
147    // === Flat WebSocket streaming ===
148    /// WebSocket streaming
149    #[command(subcommand)]
150    Ws(commands::websocket::WebSocketCommand),
151
152    // === Flat Paper Trading ===
153    /// Paper trading (simulated)
154    #[command(subcommand)]
155    Paper(commands::paper::PaperCommand),
156
157    // === Flat API Credentials ===
158    /// Manage API credentials
159    #[command(subcommand)]
160    Auth(commands::auth::AuthCommand),
161
162    // === Flat Price Alert Management ===
163    /// Price alert management
164    #[command(subcommand)]
165    Alert(commands::alert::AlertCommand),
166
167    // === Direct Tools ===
168    /// Interactive setup wizard
169    Setup,
170
171    /// Start interactive REPL
172    Shell,
173
174    /// Start MCP stdio server for AI agent integration
175    Mcp {
176        #[arg(short = 's', long = "groups", default_value = "market,account,paper,auth", help = "Comma-separated service groups: market, account, trade, funding, paper, auth")]
177        groups: String,
178        #[arg(long, help = "Allow dangerous operations (trade, funding) without acknowledged flag")]
179        allow_dangerous: bool,
180    },
181}
182
183#[derive(Debug, Subcommand)]
184pub enum WithdrawalSubcommand {
185    /// Check withdrawal fee for a currency
186    Fee {
187        #[arg(short, long)]
188        asset: String,
189        #[arg(short, long, help = "Blockchain network (optional)")]
190        network: Option<String>,
191    },
192
193    /// Start a temporary HTTP server to handle Indodax withdrawal callback
194    ServeCallback {
195        #[arg(short, long, default_value = "8080")]
196        port: u16,
197        #[arg(short, long, help = "When true, auto-confirms all callback requests. When false, prompts for each request.", default_value = "false")]
198        auto_ok: bool,
199        #[arg(long, help = "Listen address (default: 127.0.0.1). Use 0.0.0.0 for network access")]
200        listen: Option<String>,
201    },
202}
203
204pub async fn dispatch(
205    cli: Cli,
206    client: &IndodaxClient,
207    config: &mut config::IndodaxConfig,
208) -> Result<CommandOutput, IndodaxError> {
209    let output = match cli.command {
210        // === Public Market Commands ===
211        Command::ServerTime => commands::market::execute(client, &commands::market::MarketCommand::ServerTime).await
212            .map_err(map_anyhow_error)?,
213        Command::Pairs => commands::market::execute(client, &commands::market::MarketCommand::Pairs).await
214            .map_err(map_anyhow_error)?,
215        Command::Ticker { pair } => commands::market::execute(client, &commands::market::MarketCommand::Ticker { pair }).await
216            .map_err(map_anyhow_error)?,
217        Command::TickerAll => commands::market::execute(client, &commands::market::MarketCommand::TickerAll).await
218            .map_err(map_anyhow_error)?,
219        Command::Summaries => commands::market::execute(client, &commands::market::MarketCommand::Summaries).await
220            .map_err(map_anyhow_error)?,
221        Command::Orderbook { pair, count } => commands::market::execute(client, &commands::market::MarketCommand::Orderbook { pair, levels: count }).await
222            .map_err(map_anyhow_error)?,
223        Command::Trades { pair } => commands::market::execute(client, &commands::market::MarketCommand::Trades { pair }).await
224            .map_err(map_anyhow_error)?,
225        Command::Ohlc { pair, interval, since, to } => commands::market::execute(client, &commands::market::MarketCommand::Ohlc {
226            symbol: pair,
227            timeframe: interval,
228            from: since,
229            to,
230        }).await
231            .map_err(map_anyhow_error)?,
232        Command::PriceIncrements => commands::market::execute(client, &commands::market::MarketCommand::PriceIncrements).await
233            .map_err(map_anyhow_error)?,
234
235        // === Account & Balances Commands ===
236        Command::AccountInfo => commands::account::execute(client, &commands::account::AccountCommand::Info).await
237            .map_err(map_anyhow_error)?,
238        Command::Balance => commands::account::execute(client, &commands::account::AccountCommand::Balance).await
239            .map_err(map_anyhow_error)?,
240        Command::Transactions => commands::account::execute(client, &commands::account::AccountCommand::TransHistory).await
241            .map_err(map_anyhow_error)?,
242        Command::TradesHistory { pair, limit, from_id: _ } => commands::account::execute(client, &commands::account::AccountCommand::TradeHistory {
243            symbol: pair,
244            limit: limit as u32,
245        }).await
246            .map_err(map_anyhow_error)?,
247
248        // === Order Execution ===
249        Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes).await
250            .map_err(map_anyhow_error)?,
251
252        // === Funding / Withdrawal Operations ===
253        Command::Withdraw { asset, volume, address, username, memo, network, callback_url } => {
254            let funding_cmd = commands::funding::FundingCommand::Withdraw {
255                currency: asset,
256                amount: volume,
257                address,
258                username,
259                memo,
260                network,
261                callback_url,
262            };
263            commands::funding::execute(client, config, &funding_cmd, cli.output).await
264                .map_err(map_anyhow_error)?
265        }
266        Command::Withdrawal(ref sub) => {
267            let funding_cmd = match sub {
268                WithdrawalSubcommand::Fee { asset, network } => {
269                    commands::funding::FundingCommand::WithdrawFee {
270                        currency: asset.clone(),
271                        network: network.clone(),
272                    }
273                }
274                WithdrawalSubcommand::ServeCallback { port, auto_ok, listen } => {
275                    commands::funding::FundingCommand::ServeCallback {
276                        port: *port,
277                        auto_ok: *auto_ok,
278                        listen: listen.clone(),
279                    }
280                }
281            };
282            commands::funding::execute(client, config, &funding_cmd, cli.output).await
283                .map_err(map_anyhow_error)?
284        }
285
286        // === WS, Paper, Auth, Alert ===
287        Command::Ws(ref cmd) => commands::websocket::execute(client, cmd, cli.output).await
288            .map_err(map_anyhow_error)?,
289        Command::Paper(ref cmd) => commands::paper::execute(client, config, cmd).await
290            .map_err(map_anyhow_error)?,
291        Command::Auth(ref cmd) => commands::auth::execute(client, config, cmd).await
292            .map_err(map_anyhow_error)?,
293        Command::Alert(ref cmd) => commands::alert::execute(client, &None, cmd).await
294            .map_err(map_anyhow_error)?,
295
296        Command::Setup | Command::Shell | Command::Mcp { .. } => {
297            return Err(IndodaxError::Other("This command is handled separately".into()));
298        }
299    };
300
301    Ok(output.with_format(cli.output))
302}
303
304pub fn map_anyhow_error(e: anyhow::Error) -> IndodaxError {
305    e.downcast::<IndodaxError>()
306        .unwrap_or_else(|e| IndodaxError::Other(e.to_string()))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_cli_parse_ticker() {
315        let args = vec!["indodax", "ticker", "btc_idr"];
316        let cli = Cli::try_parse_from(args).unwrap();
317        match cli.command {
318            Command::Ticker { pair: _ } => {
319                // Just verify it parsed
320            }
321            _ => panic!("Expected Ticker command, got {:?}", cli.command),
322        }
323    }
324
325    #[test]
326    fn test_cli_parse_output_json() {
327        let args = vec!["indodax", "-o", "json", "ticker"];
328        let cli = Cli::try_parse_from(args).unwrap();
329        assert_eq!(cli.output, OutputFormat::Json);
330    }
331
332    #[test]
333    fn test_cli_parse_api_key() {
334        let args = vec!["indodax", "--api-key", "mykey", "ticker"];
335        let cli = Cli::try_parse_from(args).unwrap();
336        assert_eq!(cli.api_key, Some("mykey".into()));
337    }
338
339    #[test]
340    fn test_cli_parse_api_secret() {
341        let args = vec!["indodax", "--api-secret", "mysecret", "ticker"];
342        let cli = Cli::try_parse_from(args).unwrap();
343        assert_eq!(cli.api_secret, Some("mysecret".into()));
344    }
345
346    #[test]
347    fn test_cli_parse_verbose() {
348        let args = vec!["indodax", "-v", "ticker"];
349        let cli = Cli::try_parse_from(args).unwrap();
350        assert!(cli.verbose);
351    }
352
353    #[test]
354    fn test_command_variants() {
355        let _cmd1 = Command::ServerTime;
356        let _cmd2 = Command::AccountInfo;
357        let _cmd3 = Command::Order(crate::commands::trade::TradeCommand::Buy { 
358            pair: "btc_idr".into(), 
359            idr: 100_000.0, 
360            price: None,
361            order_type: None,
362        });
363        let _cmd4 = Command::Withdrawal(WithdrawalSubcommand::Fee { 
364            asset: "btc".into(), 
365            network: None 
366        });
367        let _cmd5 = Command::Ws(crate::commands::websocket::WebSocketCommand::Ticker { 
368            pair: "btc_idr".into() 
369        });
370        let _cmd6 = Command::Paper(crate::commands::paper::PaperCommand::Balance);
371        let _cmd7 = Command::Auth(crate::commands::auth::AuthCommand::Show);
372        let _cmd8 = Command::Setup;
373        let _cmd9 = Command::Shell;
374        let _cmd10 = Command::Mcp { groups: "market,paper".into(), allow_dangerous: false };
375    }
376
377    #[test]
378    fn test_output_format_clap() {
379        let args = vec!["indodax", "-o", "table", "ticker"];
380        let cli = Cli::try_parse_from(args).unwrap();
381        assert_eq!(cli.output, OutputFormat::Table);
382    }
383
384    #[test]
385    fn test_cli_parse_default_output() {
386        let args = vec!["indodax", "ticker"];
387        let cli = Cli::try_parse_from(args).unwrap();
388        assert_eq!(cli.output, OutputFormat::Table);
389    }
390
391    #[test]
392    fn test_command_display() {
393        let cli = Cli::try_parse_from(vec!["indodax", "ticker"]).unwrap();
394        let _ = format!("{:?}", cli);
395    }
396}