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