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 },
324}
325
326#[cfg(feature = "cli")]
327#[derive(Debug, Subcommand)]
328pub enum WithdrawalSubcommand {
329 Fee {
331 #[arg(short, long)]
332 asset: String,
333 #[arg(short, long, help = "Blockchain network (optional)")]
334 network: Option<String>,
335 },
336
337 #[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 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 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 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 Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes)
537 .await
538 .map_err(map_anyhow_error)?,
539
540 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 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 }
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}