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        #[arg(long, default_value = "8000", help = "Port for HTTP server")]
324        port: u16,
325        #[arg(long, help = "Start as HTTP server instead of stdio")]
326        http: bool,
327    },
328}
329
330#[cfg(feature = "cli")]
331#[derive(Debug, Subcommand)]
332pub enum WithdrawalSubcommand {
333    /// Check withdrawal fee for a currency
334    Fee {
335        #[arg(short, long)]
336        asset: String,
337        #[arg(short, long, help = "Blockchain network (optional)")]
338        network: Option<String>,
339    },
340
341    /// Start a temporary HTTP server to handle Indodax withdrawal callback
342    #[cfg(feature = "server")]
343    ServeCallback {
344        #[arg(short, long, default_value = "8080")]
345        port: u16,
346        #[arg(
347            short,
348            long,
349            help = "When true, auto-confirms all callback requests. When false, prompts for each request.",
350            default_value = "false"
351        )]
352        auto_ok: bool,
353        #[arg(
354            long,
355            help = "Listen address (default: 127.0.0.1). Use 0.0.0.0 for network access"
356        )]
357        listen: Option<String>,
358    },
359}
360
361#[cfg(feature = "cli")]
362pub async fn dispatch(
363    cli: Cli,
364    client: &IndodaxClient,
365    config: &mut config::IndodaxConfig,
366) -> Result<CommandOutput, IndodaxError> {
367    let output = match cli.command {
368        // === Legacy Hidden Commands ===
369        Command::Market(ref cmd) => commands::market::execute(client, cmd)
370            .await
371            .map_err(map_anyhow_error)?,
372        Command::Account(ref cmd) => commands::account::execute(client, cmd)
373            .await
374            .map_err(map_anyhow_error)?,
375        Command::Trade(ref cmd) => commands::trade::execute(client, cmd, cli.yes)
376            .await
377            .map_err(map_anyhow_error)?,
378        Command::Funding(ref cmd) => commands::funding::execute(client, config, cmd, cli.output)
379            .await
380            .map_err(map_anyhow_error)?,
381
382        // === Public Market Commands ===
383        Command::ServerTime => {
384            commands::market::execute(client, &commands::market::MarketCommand::ServerTime)
385                .await
386                .map_err(map_anyhow_error)?
387        }
388        Command::Pairs => {
389            commands::market::execute(client, &commands::market::MarketCommand::Pairs)
390                .await
391                .map_err(map_anyhow_error)?
392        }
393        Command::Ticker { pair } => {
394            commands::market::execute(client, &commands::market::MarketCommand::Ticker { pair })
395                .await
396                .map_err(map_anyhow_error)?
397        }
398        Command::History {
399            pair,
400            timeframe,
401            from,
402            to,
403        } => commands::market::execute(
404            client,
405            &commands::market::MarketCommand::Ohlc {
406                symbol: pair,
407                timeframe,
408                from,
409                to,
410            },
411        )
412        .await
413        .map_err(map_anyhow_error)?,
414        Command::TickerAll => {
415            commands::market::execute(client, &commands::market::MarketCommand::TickerAll)
416                .await
417                .map_err(map_anyhow_error)?
418        }
419        Command::Summaries => {
420            commands::market::execute(client, &commands::market::MarketCommand::Summaries)
421                .await
422                .map_err(map_anyhow_error)?
423        }
424        Command::Orderbook { pair, count } => commands::market::execute(
425            client,
426            &commands::market::MarketCommand::Orderbook {
427                pair,
428                levels: count,
429            },
430        )
431        .await
432        .map_err(map_anyhow_error)?,
433        Command::Trades { pair } => {
434            commands::market::execute(client, &commands::market::MarketCommand::Trades { pair })
435                .await
436                .map_err(map_anyhow_error)?
437        }
438        Command::Ohlc {
439            pair,
440            interval,
441            since,
442            to,
443        } => commands::market::execute(
444            client,
445            &commands::market::MarketCommand::Ohlc {
446                symbol: pair,
447                timeframe: interval,
448                from: since,
449                to,
450            },
451        )
452        .await
453        .map_err(map_anyhow_error)?,
454        Command::Webdata { pair } => {
455            commands::market::execute(client, &commands::market::MarketCommand::WebData { pair })
456                .await
457                .map_err(map_anyhow_error)?
458        }
459        Command::ChatHistory => {
460            commands::market::execute(client, &commands::market::MarketCommand::ChatHistory)
461                .await
462                .map_err(map_anyhow_error)?
463        }
464        Command::PairsV2 { pair } => {
465            commands::market::execute(client, &commands::market::MarketCommand::PairsV2 { pair })
466                .await
467                .map_err(map_anyhow_error)?
468        }
469        Command::SearchV2 => {
470            commands::market::execute(client, &commands::market::MarketCommand::SearchV2)
471                .await
472                .map_err(map_anyhow_error)?
473        }
474        Command::TerminalTrade { pair } => commands::market::execute(
475            client,
476            &commands::market::MarketCommand::TerminalTrade { pair },
477        )
478        .await
479        .map_err(map_anyhow_error)?,
480        Command::TerminalMarket { pair } => commands::market::execute(
481            client,
482            &commands::market::MarketCommand::TerminalMarket { pair },
483        )
484        .await
485        .map_err(map_anyhow_error)?,
486        Command::TerminalCategories => {
487            commands::market::execute(client, &commands::market::MarketCommand::TerminalCategories)
488                .await
489                .map_err(map_anyhow_error)?
490        }
491        Command::OnrampConfig { pair } => commands::market::execute(
492            client,
493            &commands::market::MarketCommand::OnrampConfig { pair },
494        )
495        .await
496        .map_err(map_anyhow_error)?,
497        Command::News { asset, page } => commands::market::execute(
498            client,
499            &commands::market::MarketCommand::News { asset, page },
500        )
501        .await
502        .map_err(map_anyhow_error)?,
503        Command::PriceIncrements => {
504            commands::market::execute(client, &commands::market::MarketCommand::PriceIncrements)
505                .await
506                .map_err(map_anyhow_error)?
507        }
508
509        // === Account & Balances Commands ===
510        Command::AccountInfo => {
511            commands::account::execute(client, &commands::account::AccountCommand::Info)
512                .await
513                .map_err(map_anyhow_error)?
514        }
515        Command::Balance => {
516            commands::account::execute(client, &commands::account::AccountCommand::Balance)
517                .await
518                .map_err(map_anyhow_error)?
519        }
520        Command::Transactions => {
521            commands::account::execute(client, &commands::account::AccountCommand::TransHistory)
522                .await
523                .map_err(map_anyhow_error)?
524        }
525        Command::TradesHistory {
526            pair,
527            limit,
528            from_id: _,
529        } => commands::account::execute(
530            client,
531            &commands::account::AccountCommand::TradeHistory {
532                symbol: pair,
533                limit: limit as u32,
534            },
535        )
536        .await
537        .map_err(map_anyhow_error)?,
538
539        // === Order Execution ===
540        Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes)
541            .await
542            .map_err(map_anyhow_error)?,
543
544        // === Funding / Withdrawal Operations ===
545        Command::Withdraw {
546            asset,
547            volume,
548            address,
549            username,
550            memo,
551            network,
552            callback_url,
553        } => {
554            let funding_cmd = commands::funding::FundingCommand::Withdraw {
555                currency: asset,
556                amount: volume,
557                address,
558                username,
559                memo,
560                network,
561                callback_url,
562            };
563            commands::funding::execute(client, config, &funding_cmd, cli.output)
564                .await
565                .map_err(map_anyhow_error)?
566        }
567        Command::Withdrawal(ref sub) => {
568            let funding_cmd = match sub {
569                WithdrawalSubcommand::Fee { asset, network } => {
570                    commands::funding::FundingCommand::WithdrawFee {
571                        currency: asset.clone(),
572                        network: network.clone(),
573                    }
574                }
575                #[cfg(feature = "server")]
576                WithdrawalSubcommand::ServeCallback {
577                    port,
578                    auto_ok,
579                    listen,
580                } => commands::funding::FundingCommand::ServeCallback {
581                    port: *port,
582                    auto_ok: *auto_ok,
583                    listen: listen.clone(),
584                },
585            };
586            commands::funding::execute(client, config, &funding_cmd, cli.output)
587                .await
588                .map_err(map_anyhow_error)?
589        }
590
591        // === WS, Paper, Auth, Alert ===
592        Command::Ws(ref cmd) => commands::websocket::execute(client, cmd, cli.output)
593            .await
594            .map_err(map_anyhow_error)?,
595        Command::Paper(ref cmd) => commands::paper::execute(client, config, cmd)
596            .await
597            .map_err(map_anyhow_error)?,
598        Command::Auth(ref cmd) => commands::auth::execute(client, config, cmd)
599            .await
600            .map_err(map_anyhow_error)?,
601        Command::Alert(ref cmd) => commands::alert::execute(client, &None, cmd)
602            .await
603            .map_err(map_anyhow_error)?,
604
605        Command::Setup | Command::Shell => {
606            return Err(IndodaxError::Other(
607                "This command is handled separately".into(),
608            ));
609        }
610        #[cfg(feature = "mcp")]
611        Command::Mcp { .. } => {
612            return Err(IndodaxError::Other(
613                "This command is handled separately".into(),
614            ));
615        }
616    };
617
618    Ok(output.with_format(cli.output))
619}
620
621#[cfg(feature = "cli")]
622pub fn map_anyhow_error(e: anyhow::Error) -> IndodaxError {
623    e.downcast::<IndodaxError>()
624        .unwrap_or_else(|e| IndodaxError::Other(e.to_string()))
625}
626
627#[cfg(all(test, feature = "cli"))]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_cli_parse_ticker() {
633        let args = vec!["indodax", "ticker", "btc_idr"];
634        let cli = Cli::try_parse_from(args).unwrap();
635        match cli.command {
636            Command::Ticker { pair: _ } => {
637                // Just verify it parsed
638            }
639            _ => panic!("Expected Ticker command, got {:?}", cli.command),
640        }
641    }
642
643    #[test]
644    fn test_cli_parse_output_json() {
645        let args = vec!["indodax", "-o", "json", "ticker"];
646        let cli = Cli::try_parse_from(args).unwrap();
647        assert_eq!(cli.output, OutputFormat::Json);
648    }
649
650    #[test]
651    fn test_cli_parse_api_key() {
652        let args = vec!["indodax", "--api-key", "mykey", "ticker"];
653        let cli = Cli::try_parse_from(args).unwrap();
654        assert_eq!(cli.api_key, Some("mykey".into()));
655    }
656
657    #[test]
658    fn test_cli_parse_api_secret() {
659        let args = vec!["indodax", "--api-secret", "mysecret", "ticker"];
660        let cli = Cli::try_parse_from(args).unwrap();
661        assert_eq!(cli.api_secret, Some("mysecret".into()));
662    }
663
664    #[test]
665    fn test_cli_parse_verbose() {
666        let args = vec!["indodax", "-v", "ticker"];
667        let cli = Cli::try_parse_from(args).unwrap();
668        assert!(cli.verbose);
669    }
670
671    #[test]
672    fn test_command_variants() {
673        let _cmd1 = Command::ServerTime;
674        let _cmd2 = Command::AccountInfo;
675        let _cmd3 = Command::Order(crate::commands::trade::TradeCommand::Buy {
676            pair: "btc_idr".into(),
677            idr: 100_000.0,
678            price: None,
679            order_type: None,
680        });
681        let _cmd4 = Command::Withdrawal(WithdrawalSubcommand::Fee {
682            asset: "btc".into(),
683            network: None,
684        });
685        let _cmd5 = Command::Ws(crate::commands::websocket::WebSocketCommand::Ticker {
686            pair: "btc_idr".into(),
687        });
688        let _cmd6 = Command::Paper(crate::commands::paper::PaperCommand::Balance);
689        let _cmd7 = Command::Auth(crate::commands::auth::AuthCommand::Show);
690        let _cmd8 = Command::Setup;
691        let _cmd9 = Command::Shell;
692        #[cfg(feature = "mcp")]
693        let _cmd10 = Command::Mcp {
694            groups: "market,paper".into(),
695            allow_dangerous: false,
696            port: 8000,
697            http: false,
698        };
699    }
700
701    #[test]
702    fn test_output_format_clap() {
703        let args = vec!["indodax", "-o", "table", "ticker"];
704        let cli = Cli::try_parse_from(args).unwrap();
705        assert_eq!(cli.output, OutputFormat::Table);
706    }
707
708    #[test]
709    fn test_cli_parse_default_output() {
710        let args = vec!["indodax", "ticker"];
711        let cli = Cli::try_parse_from(args).unwrap();
712        assert_eq!(cli.output, OutputFormat::Table);
713    }
714
715    #[test]
716    fn test_command_display() {
717        let cli = Cli::try_parse_from(vec!["indodax", "ticker"]).unwrap();
718        let _ = format!("{:?}", cli);
719    }
720}