Skip to main content

indodax_cli/
lib.rs

1#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
2use output::{CommandOutput, OutputFormat};
3
4pub mod auth;
5pub mod client;
6#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
7pub mod alerts;
8#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
9pub mod commands;
10pub mod config;
11pub mod errors;
12pub mod integration;
13#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
14pub mod mcp;
15pub mod output;
16
17#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
18use client::IndodaxClient;
19#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
20use errors::IndodaxError;
21
22pub use integration::prelude;
23
24pub(crate) fn now_millis() -> u64 {
25    #[cfg(target_arch = "wasm32")]
26    {
27        js_sys::Date::now() as u64
28    }
29
30    #[cfg(not(target_arch = "wasm32"))]
31    {
32        std::time::SystemTime::now()
33            .duration_since(std::time::UNIX_EPOCH)
34            .unwrap_or_default()
35            .as_millis() as u64
36    }
37}
38
39#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
40use clap::{Parser, Subcommand};
41
42#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
43#[derive(Debug, Parser)]
44#[command(
45    name = "indodax",
46    version,
47    about = "Command-line interface for the Indodax cryptocurrency exchange",
48    long_about = None
49)]
50pub struct Cli {
51    #[command(subcommand)]
52    pub command: Command,
53
54    #[arg(short = 'o', long = "output", default_value = "table", help = "Output format: table or json", global = true)]
55    pub output: OutputFormat,
56
57    #[arg(long = "api-key", help = "API key (overrides config file and env var)", global = true)]
58    pub api_key: Option<String>,
59
60    #[arg(long = "api-secret", help = "API secret (overrides config file and env var)", global = true)]
61    pub api_secret: Option<String>,
62
63    #[arg(long = "api-secret-stdin", help = "Read API secret from stdin (more secure than --api-secret)", global = true)]
64    pub api_secret_stdin: bool,
65
66    #[arg(short = 'v', long = "verbose", help = "Enable verbose output", global = true)]
67    pub verbose: bool,
68
69    #[arg(long = "yes", alias = "force", help = "Skip confirmation prompts for destructive operations", global = true)]
70    pub yes: bool,
71}
72
73#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
74#[derive(Debug, Subcommand)]
75pub enum Command {
76    // === Legacy Hidden Commands for Backward Compatibility ===
77    #[command(hide = true)]
78    #[command(subcommand)]
79    Market(commands::market::MarketCommand),
80    #[command(hide = true)]
81    #[command(subcommand)]
82    Account(commands::account::AccountCommand),
83    #[command(hide = true)]
84    #[command(subcommand)]
85    Trade(commands::trade::TradeCommand),
86    #[command(hide = true)]
87    #[command(subcommand)]
88    Funding(commands::funding::FundingCommand),
89
90    // === Flat Public Market Commands (originally nested under Market) ===
91    /// Get server time
92    ServerTime,
93
94    /// List available trading pairs
95    Pairs,
96
97    /// Get ticker for a pair
98    Ticker {
99        #[arg(default_value = "btc_idr")]
100        pair: String,
101    },
102
103    /// Get OHLCV history for a pair
104    History {
105        #[arg(default_value = "btc_idr")]
106        pair: String,
107        #[arg(short, long, default_value = "60")]
108        timeframe: String,
109        #[arg(short, long, help = "Start timestamp in seconds (default: 24h ago)")]
110        from: Option<u64>,
111        #[arg(long, help = "End timestamp in seconds (default: now)")]
112        to: Option<u64>,
113    },
114
115    /// Get tickers for all pairs
116    TickerAll,
117
118    /// Get 24h and 7d summaries for all pairs
119    Summaries,
120
121    /// Get order book for a pair
122    Orderbook {
123        #[arg(default_value = "btc_idr")]
124        pair: String,
125        #[arg(long, default_value = "20", help = "Number of bid/ask levels to show")]
126        count: usize,
127    },
128
129    /// Get recent trades for a pair
130    Trades {
131        #[arg(default_value = "btc_idr")]
132        pair: String,
133    },
134
135    /// Get OHLCV candle data (default --from is 24h ago)
136    Ohlc {
137        #[arg(short, long, default_value = "btc_idr")]
138        pair: String,
139        #[arg(long, default_value = "60")]
140        interval: String,
141        #[arg(short, long, help = "Start timestamp in seconds (default: 24h ago)")]
142        since: Option<u64>,
143        #[arg(long, help = "End timestamp in seconds (default: now)")]
144        to: Option<u64>,
145    },
146
147    /// Get market webdata for a pair
148    Webdata {
149        #[arg(default_value = "btc_idr")]
150        pair: String,
151    },
152
153    /// Get chatroom history
154    ChatHistory,
155
156    /// Get detailed pairs info (V2)
157    PairsV2 {
158        #[arg(short, long)]
159        pair: Option<String>,
160    },
161
162    /// Search markets (TradingView Search V2)
163    SearchV2,
164
165    /// Get terminal trading data
166    TerminalTrade {
167        #[arg(default_value = "btc_idr")]
168        pair: String,
169    },
170
171    /// Get terminal market data
172    TerminalMarket {
173        #[arg(default_value = "btc_idr")]
174        pair: String,
175    },
176
177    /// Get terminal market categories
178    TerminalCategories,
179
180    /// Get onramp config for a pair
181    OnrampConfig {
182        #[arg(default_value = "usdt_idr")]
183        pair: String,
184    },
185
186    /// Get news for an asset
187    News {
188        #[arg(default_value = "btc")]
189        asset: String,
190        #[arg(short, long, default_value = "1")]
191        page: u32,
192    },
193
194    /// Get price increments (tick sizes)
195    PriceIncrements,
196
197    // === Flat Private Account Commands (originally nested under Account) ===
198    /// Get current account information (balances, permissions, etc.)
199    AccountInfo,
200
201    /// Get non-zero account balances
202    Balance,
203
204    /// Get your deposit/withdrawal transactions
205    Transactions,
206
207    /// Get your trade history for a specific symbol
208    TradesHistory {
209        /// Trading pair symbol (e.g., btc_idr)
210        pair: String,
211
212        /// Number of trades to return (default: 500)
213        #[arg(short, long, default_value = "500")]
214        limit: usize,
215
216        /// Start from this trade ID (optional)
217        #[arg(long)]
218        from_id: Option<u64>,
219    },
220
221    // === Flat Trading Command (originally nested under Trade) ===
222    /// Place and manage orders
223    #[command(subcommand)]
224    Order(commands::trade::TradeCommand),
225
226    // === Flat Funding / Withdrawal Commands (originally nested under Funding) ===
227    /// Withdraw cryptocurrency
228    Withdraw {
229        #[arg(short, long)]
230        asset: String,
231        #[arg(short = 'v', long, help = "Amount to withdraw")]
232        volume: f64,
233        #[arg(long, help = "Crypto destination address (or Indodax username if --username is set)")]
234        address: String,
235        #[arg(long, help = "Withdraw to Indodax username instead of blockchain")]
236        username: bool,
237        #[arg(long, help = "Memo/tag (for currencies that require it)")]
238        memo: Option<String>,
239        #[arg(long, help = "Blockchain network")]
240        network: Option<String>,
241        #[arg(long, help = "Callback URL for withdrawal confirmation")]
242        callback_url: Option<String>,
243    },
244
245    /// Manage withdrawal fees and servers
246    #[command(subcommand)]
247    Withdrawal(WithdrawalSubcommand),
248
249    // === Flat WebSocket streaming ===
250    /// WebSocket streaming
251    #[command(subcommand)]
252    Ws(commands::websocket::WebSocketCommand),
253
254    // === Flat Paper Trading ===
255    /// Paper trading (simulated)
256    #[command(subcommand)]
257    Paper(commands::paper::PaperCommand),
258
259    // === Flat API Credentials ===
260    /// Manage API credentials
261    #[command(subcommand)]
262    Auth(commands::auth::AuthCommand),
263
264    // === Flat Price Alert Management ===
265    /// Price alert management
266    #[command(subcommand)]
267    Alert(commands::alert::AlertCommand),
268
269    // === Direct Tools ===
270    /// Interactive setup wizard
271    Setup,
272
273    /// Start interactive REPL
274    Shell,
275
276    /// Start MCP stdio server for AI agent integration
277    Mcp {
278        #[arg(short = 's', long = "groups", default_value = "market,account,paper,auth", help = "Comma-separated service groups: market, account, trade, funding, paper, auth")]
279        groups: String,
280        #[arg(long, help = "Allow dangerous operations (trade, funding) without acknowledged flag")]
281        allow_dangerous: bool,
282    },
283}
284
285#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
286#[derive(Debug, Subcommand)]
287pub enum WithdrawalSubcommand {
288    /// Check withdrawal fee for a currency
289    Fee {
290        #[arg(short, long)]
291        asset: String,
292        #[arg(short, long, help = "Blockchain network (optional)")]
293        network: Option<String>,
294    },
295
296    /// Start a temporary HTTP server to handle Indodax withdrawal callback
297    ServeCallback {
298        #[arg(short, long, default_value = "8080")]
299        port: u16,
300        #[arg(short, long, help = "When true, auto-confirms all callback requests. When false, prompts for each request.", default_value = "false")]
301        auto_ok: bool,
302        #[arg(long, help = "Listen address (default: 127.0.0.1). Use 0.0.0.0 for network access")]
303        listen: Option<String>,
304    },
305}
306
307#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
308pub async fn dispatch(
309    cli: Cli,
310    client: &IndodaxClient,
311    config: &mut config::IndodaxConfig,
312) -> Result<CommandOutput, IndodaxError> {
313    let output = match cli.command {
314        // === Legacy Hidden Commands ===
315        Command::Market(ref cmd) => commands::market::execute(client, cmd).await
316            .map_err(map_anyhow_error)?,
317        Command::Account(ref cmd) => commands::account::execute(client, cmd).await
318            .map_err(map_anyhow_error)?,
319        Command::Trade(ref cmd) => commands::trade::execute(client, cmd, cli.yes).await
320            .map_err(map_anyhow_error)?,
321        Command::Funding(ref cmd) => commands::funding::execute(client, config, cmd, cli.output).await
322            .map_err(map_anyhow_error)?,
323
324        // === Public Market Commands ===
325        Command::ServerTime => commands::market::execute(client, &commands::market::MarketCommand::ServerTime).await
326            .map_err(map_anyhow_error)?,
327        Command::Pairs => commands::market::execute(client, &commands::market::MarketCommand::Pairs).await
328            .map_err(map_anyhow_error)?,
329        Command::Ticker { pair } => commands::market::execute(client, &commands::market::MarketCommand::Ticker { pair }).await
330            .map_err(map_anyhow_error)?,
331        Command::History { pair, timeframe, from, to } => commands::market::execute(client, &commands::market::MarketCommand::Ohlc {
332            symbol: pair,
333            timeframe,
334            from,
335            to,
336        }).await
337            .map_err(map_anyhow_error)?,
338        Command::TickerAll => commands::market::execute(client, &commands::market::MarketCommand::TickerAll).await
339            .map_err(map_anyhow_error)?,
340        Command::Summaries => commands::market::execute(client, &commands::market::MarketCommand::Summaries).await
341            .map_err(map_anyhow_error)?,
342        Command::Orderbook { pair, count } => commands::market::execute(client, &commands::market::MarketCommand::Orderbook { pair, levels: count }).await
343            .map_err(map_anyhow_error)?,
344        Command::Trades { pair } => commands::market::execute(client, &commands::market::MarketCommand::Trades { pair }).await
345            .map_err(map_anyhow_error)?,
346        Command::Ohlc { pair, interval, since, to } => commands::market::execute(client, &commands::market::MarketCommand::Ohlc {
347            symbol: pair,
348            timeframe: interval,
349            from: since,
350            to,
351        }).await
352            .map_err(map_anyhow_error)?,
353        Command::Webdata { pair } => commands::market::execute(client, &commands::market::MarketCommand::WebData { pair }).await
354            .map_err(map_anyhow_error)?,
355        Command::ChatHistory => commands::market::execute(client, &commands::market::MarketCommand::ChatHistory).await
356            .map_err(map_anyhow_error)?,
357        Command::PairsV2 { pair } => commands::market::execute(client, &commands::market::MarketCommand::PairsV2 { pair }).await
358            .map_err(map_anyhow_error)?,
359        Command::SearchV2 => commands::market::execute(client, &commands::market::MarketCommand::SearchV2).await
360            .map_err(map_anyhow_error)?,
361        Command::TerminalTrade { pair } => commands::market::execute(client, &commands::market::MarketCommand::TerminalTrade { pair }).await
362            .map_err(map_anyhow_error)?,
363        Command::TerminalMarket { pair } => commands::market::execute(client, &commands::market::MarketCommand::TerminalMarket { pair }).await
364            .map_err(map_anyhow_error)?,
365        Command::TerminalCategories => commands::market::execute(client, &commands::market::MarketCommand::TerminalCategories).await
366            .map_err(map_anyhow_error)?,
367        Command::OnrampConfig { pair } => commands::market::execute(client, &commands::market::MarketCommand::OnrampConfig { pair }).await
368            .map_err(map_anyhow_error)?,
369        Command::News { asset, page } => commands::market::execute(client, &commands::market::MarketCommand::News { asset, page }).await
370            .map_err(map_anyhow_error)?,
371        Command::PriceIncrements => commands::market::execute(client, &commands::market::MarketCommand::PriceIncrements).await
372            .map_err(map_anyhow_error)?,
373
374        // === Account & Balances Commands ===
375        Command::AccountInfo => commands::account::execute(client, &commands::account::AccountCommand::Info).await
376            .map_err(map_anyhow_error)?,
377        Command::Balance => commands::account::execute(client, &commands::account::AccountCommand::Balance).await
378            .map_err(map_anyhow_error)?,
379        Command::Transactions => commands::account::execute(client, &commands::account::AccountCommand::TransHistory).await
380            .map_err(map_anyhow_error)?,
381        Command::TradesHistory { pair, limit, from_id: _ } => commands::account::execute(client, &commands::account::AccountCommand::TradeHistory {
382            symbol: pair,
383            limit: limit as u32,
384        }).await
385            .map_err(map_anyhow_error)?,
386
387        // === Order Execution ===
388        Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes).await
389            .map_err(map_anyhow_error)?,
390
391        // === Funding / Withdrawal Operations ===
392        Command::Withdraw { asset, volume, address, username, memo, network, callback_url } => {
393            let funding_cmd = commands::funding::FundingCommand::Withdraw {
394                currency: asset,
395                amount: volume,
396                address,
397                username,
398                memo,
399                network,
400                callback_url,
401            };
402            commands::funding::execute(client, config, &funding_cmd, cli.output).await
403                .map_err(map_anyhow_error)?
404        }
405        Command::Withdrawal(ref sub) => {
406            let funding_cmd = match sub {
407                WithdrawalSubcommand::Fee { asset, network } => {
408                    commands::funding::FundingCommand::WithdrawFee {
409                        currency: asset.clone(),
410                        network: network.clone(),
411                    }
412                }
413                WithdrawalSubcommand::ServeCallback { port, auto_ok, listen } => {
414                    commands::funding::FundingCommand::ServeCallback {
415                        port: *port,
416                        auto_ok: *auto_ok,
417                        listen: listen.clone(),
418                    }
419                }
420            };
421            commands::funding::execute(client, config, &funding_cmd, cli.output).await
422                .map_err(map_anyhow_error)?
423        }
424
425        // === WS, Paper, Auth, Alert ===
426        Command::Ws(ref cmd) => commands::websocket::execute(client, cmd, cli.output).await
427            .map_err(map_anyhow_error)?,
428        Command::Paper(ref cmd) => commands::paper::execute(client, config, cmd).await
429            .map_err(map_anyhow_error)?,
430        Command::Auth(ref cmd) => commands::auth::execute(client, config, cmd).await
431            .map_err(map_anyhow_error)?,
432        Command::Alert(ref cmd) => commands::alert::execute(client, &None, cmd).await
433            .map_err(map_anyhow_error)?,
434
435        Command::Setup | Command::Shell | Command::Mcp { .. } => {
436            return Err(IndodaxError::Other("This command is handled separately".into()));
437        }
438    };
439
440    Ok(output.with_format(cli.output))
441}
442
443#[cfg(all(feature = "cli", not(target_arch = "wasm32")))]
444pub fn map_anyhow_error(e: anyhow::Error) -> IndodaxError {
445    e.downcast::<IndodaxError>()
446        .unwrap_or_else(|e| IndodaxError::Other(e.to_string()))
447}
448
449#[cfg(all(test, feature = "cli", not(target_arch = "wasm32")))]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_cli_parse_ticker() {
455        let args = vec!["indodax", "ticker", "btc_idr"];
456        let cli = Cli::try_parse_from(args).unwrap();
457        match cli.command {
458            Command::Ticker { pair: _ } => {
459                // Just verify it parsed
460            }
461            _ => panic!("Expected Ticker command, got {:?}", cli.command),
462        }
463    }
464
465    #[test]
466    fn test_cli_parse_output_json() {
467        let args = vec!["indodax", "-o", "json", "ticker"];
468        let cli = Cli::try_parse_from(args).unwrap();
469        assert_eq!(cli.output, OutputFormat::Json);
470    }
471
472    #[test]
473    fn test_cli_parse_api_key() {
474        let args = vec!["indodax", "--api-key", "mykey", "ticker"];
475        let cli = Cli::try_parse_from(args).unwrap();
476        assert_eq!(cli.api_key, Some("mykey".into()));
477    }
478
479    #[test]
480    fn test_cli_parse_api_secret() {
481        let args = vec!["indodax", "--api-secret", "mysecret", "ticker"];
482        let cli = Cli::try_parse_from(args).unwrap();
483        assert_eq!(cli.api_secret, Some("mysecret".into()));
484    }
485
486    #[test]
487    fn test_cli_parse_verbose() {
488        let args = vec!["indodax", "-v", "ticker"];
489        let cli = Cli::try_parse_from(args).unwrap();
490        assert!(cli.verbose);
491    }
492
493    #[test]
494    fn test_command_variants() {
495        let _cmd1 = Command::ServerTime;
496        let _cmd2 = Command::AccountInfo;
497        let _cmd3 = Command::Order(crate::commands::trade::TradeCommand::Buy { 
498            pair: "btc_idr".into(), 
499            idr: 100_000.0, 
500            price: None,
501            order_type: None,
502        });
503        let _cmd4 = Command::Withdrawal(WithdrawalSubcommand::Fee { 
504            asset: "btc".into(), 
505            network: None 
506        });
507        let _cmd5 = Command::Ws(crate::commands::websocket::WebSocketCommand::Ticker { 
508            pair: "btc_idr".into() 
509        });
510        let _cmd6 = Command::Paper(crate::commands::paper::PaperCommand::Balance);
511        let _cmd7 = Command::Auth(crate::commands::auth::AuthCommand::Show);
512        let _cmd8 = Command::Setup;
513        let _cmd9 = Command::Shell;
514        let _cmd10 = Command::Mcp { groups: "market,paper".into(), allow_dangerous: false };
515    }
516
517    #[test]
518    fn test_output_format_clap() {
519        let args = vec!["indodax", "-o", "table", "ticker"];
520        let cli = Cli::try_parse_from(args).unwrap();
521        assert_eq!(cli.output, OutputFormat::Table);
522    }
523
524    #[test]
525    fn test_cli_parse_default_output() {
526        let args = vec!["indodax", "ticker"];
527        let cli = Cli::try_parse_from(args).unwrap();
528        assert_eq!(cli.output, OutputFormat::Table);
529    }
530
531    #[test]
532    fn test_command_display() {
533        let cli = Cli::try_parse_from(vec!["indodax", "ticker"]).unwrap();
534        let _ = format!("{:?}", cli);
535    }
536}