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 #[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 ServerTime,
93
94 Pairs,
96
97 Ticker {
99 #[arg(default_value = "btc_idr")]
100 pair: String,
101 },
102
103 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 TickerAll,
117
118 Summaries,
120
121 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 Trades {
131 #[arg(default_value = "btc_idr")]
132 pair: String,
133 },
134
135 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 Webdata {
149 #[arg(default_value = "btc_idr")]
150 pair: String,
151 },
152
153 ChatHistory,
155
156 PairsV2 {
158 #[arg(short, long)]
159 pair: Option<String>,
160 },
161
162 SearchV2,
164
165 TerminalTrade {
167 #[arg(default_value = "btc_idr")]
168 pair: String,
169 },
170
171 TerminalMarket {
173 #[arg(default_value = "btc_idr")]
174 pair: String,
175 },
176
177 TerminalCategories,
179
180 OnrampConfig {
182 #[arg(default_value = "usdt_idr")]
183 pair: String,
184 },
185
186 News {
188 #[arg(default_value = "btc")]
189 asset: String,
190 #[arg(short, long, default_value = "1")]
191 page: u32,
192 },
193
194 PriceIncrements,
196
197 AccountInfo,
200
201 Balance,
203
204 Transactions,
206
207 TradesHistory {
209 pair: String,
211
212 #[arg(short, long, default_value = "500")]
214 limit: usize,
215
216 #[arg(long)]
218 from_id: Option<u64>,
219 },
220
221 #[command(subcommand)]
224 Order(commands::trade::TradeCommand),
225
226 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 #[command(subcommand)]
247 Withdrawal(WithdrawalSubcommand),
248
249 #[command(subcommand)]
252 Ws(commands::websocket::WebSocketCommand),
253
254 #[command(subcommand)]
257 Paper(commands::paper::PaperCommand),
258
259 #[command(subcommand)]
262 Auth(commands::auth::AuthCommand),
263
264 #[command(subcommand)]
267 Alert(commands::alert::AlertCommand),
268
269 Setup,
272
273 Shell,
275
276 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 Fee {
290 #[arg(short, long)]
291 asset: String,
292 #[arg(short, long, help = "Blockchain network (optional)")]
293 network: Option<String>,
294 },
295
296 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 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 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 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 Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes).await
389 .map_err(map_anyhow_error)?,
390
391 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 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 }
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}