Skip to main content

indodax_cli/
lib.rs

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