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 #[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 ServerTime,
122
123 Pairs,
125
126 Ticker {
128 #[arg(default_value = "btc_idr")]
129 pair: String,
130 },
131
132 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 TickerAll,
146
147 Summaries,
149
150 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 Trades {
160 #[arg(default_value = "btc_idr")]
161 pair: String,
162 },
163
164 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 Webdata {
178 #[arg(default_value = "btc_idr")]
179 pair: String,
180 },
181
182 ChatHistory,
184
185 PairsV2 {
187 #[arg(short, long)]
188 pair: Option<String>,
189 },
190
191 SearchV2,
193
194 TerminalTrade {
196 #[arg(default_value = "btc_idr")]
197 pair: String,
198 },
199
200 TerminalMarket {
202 #[arg(default_value = "btc_idr")]
203 pair: String,
204 },
205
206 TerminalCategories,
208
209 OnrampConfig {
211 #[arg(default_value = "usdt_idr")]
212 pair: String,
213 },
214
215 News {
217 #[arg(default_value = "btc")]
218 asset: String,
219 #[arg(short, long, default_value = "1")]
220 page: u32,
221 },
222
223 PriceIncrements,
225
226 AccountInfo,
229
230 Balance,
232
233 Transactions,
235
236 TradesHistory {
238 pair: String,
240
241 #[arg(short, long, default_value = "500")]
243 limit: usize,
244
245 #[arg(long)]
247 from_id: Option<u64>,
248 },
249
250 #[command(subcommand)]
253 Order(commands::trade::TradeCommand),
254
255 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 #[command(subcommand)]
279 Withdrawal(WithdrawalSubcommand),
280
281 #[command(subcommand)]
284 Ws(commands::websocket::WebSocketCommand),
285
286 #[command(subcommand)]
289 Paper(commands::paper::PaperCommand),
290
291 #[command(subcommand)]
294 Auth(commands::auth::AuthCommand),
295
296 #[command(subcommand)]
299 Alert(commands::alert::AlertCommand),
300
301 Setup,
304
305 Shell,
307
308 #[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 Fee {
335 #[arg(short, long)]
336 asset: String,
337 #[arg(short, long, help = "Blockchain network (optional)")]
338 network: Option<String>,
339 },
340
341 #[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 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 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 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 Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes)
541 .await
542 .map_err(map_anyhow_error)?,
543
544 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 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 }
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}