1use clap::{Parser, Subcommand};
2use output::{CommandOutput, OutputFormat};
3
4pub mod auth;
5pub mod client;
6pub mod commands;
7pub mod config;
8pub mod errors;
9pub mod mcp;
10pub mod output;
11
12use client::IndodaxClient;
13use errors::IndodaxError;
14
15#[derive(Debug, Parser)]
16#[command(
17 name = "indodax",
18 version,
19 about = "Command-line interface for the Indodax cryptocurrency exchange",
20 long_about = None
21)]
22pub struct Cli {
23 #[command(subcommand)]
24 pub command: Command,
25
26 #[arg(short = 'o', long = "output", default_value = "table", help = "Output format: table or json", global = true)]
27 pub output: OutputFormat,
28
29 #[arg(long = "api-key", help = "API key (overrides config file and env var)", global = true)]
30 pub api_key: Option<String>,
31
32 #[arg(long = "api-secret", help = "API secret (overrides config file and env var)", global = true)]
33 pub api_secret: Option<String>,
34
35 #[arg(long = "api-secret-stdin", help = "Read API secret from stdin (more secure than --api-secret)", global = true)]
36 pub api_secret_stdin: bool,
37
38 #[arg(short = 'v', long = "verbose", help = "Enable verbose output", global = true)]
39 pub verbose: bool,
40
41 #[arg(long = "yes", alias = "force", help = "Skip confirmation prompts for destructive operations", global = true)]
42 pub yes: bool,
43}
44
45#[derive(Debug, Subcommand)]
46pub enum Command {
47 #[command(hide = true)]
49 #[command(subcommand)]
50 Market(commands::market::MarketCommand),
51 #[command(hide = true)]
52 #[command(subcommand)]
53 Account(commands::account::AccountCommand),
54 #[command(hide = true)]
55 #[command(subcommand)]
56 Trade(commands::trade::TradeCommand),
57 #[command(hide = true)]
58 #[command(subcommand)]
59 Funding(commands::funding::FundingCommand),
60
61 ServerTime,
64
65 Pairs,
67
68 Ticker {
70 #[arg(default_value = "btc_idr")]
71 pair: String,
72 },
73
74 TickerAll,
76
77 Summaries,
79
80 Orderbook {
82 #[arg(default_value = "btc_idr")]
83 pair: String,
84 #[arg(long, default_value = "20", help = "Number of bid/ask levels to show")]
85 count: usize,
86 },
87
88 Trades {
90 #[arg(default_value = "btc_idr")]
91 pair: String,
92 },
93
94 Ohlc {
96 #[arg(short, long, default_value = "btc_idr")]
97 pair: String,
98 #[arg(long, default_value = "60")]
99 interval: String,
100 #[arg(short, long, help = "Start timestamp in seconds (default: 24h ago)")]
101 since: Option<u64>,
102 #[arg(long, help = "End timestamp in seconds (default: now)")]
103 to: Option<u64>,
104 },
105
106 PriceIncrements,
108
109 AccountInfo,
112
113 Balance,
115
116 Transactions,
118
119 TradesHistory {
121 pair: String,
123
124 #[arg(short, long, default_value = "500")]
126 limit: usize,
127
128 #[arg(long)]
130 from_id: Option<u64>,
131 },
132
133 #[command(subcommand)]
136 Order(commands::trade::TradeCommand),
137
138 Withdraw {
141 #[arg(short, long)]
142 asset: String,
143 #[arg(short = 'v', long, help = "Amount to withdraw")]
144 volume: f64,
145 #[arg(long, help = "Crypto destination address (or Indodax username if --username is set)")]
146 address: String,
147 #[arg(long, help = "Withdraw to Indodax username instead of blockchain")]
148 username: bool,
149 #[arg(long, help = "Memo/tag (for currencies that require it)")]
150 memo: Option<String>,
151 #[arg(long, help = "Blockchain network")]
152 network: Option<String>,
153 #[arg(long, help = "Callback URL for withdrawal confirmation")]
154 callback_url: Option<String>,
155 },
156
157 #[command(subcommand)]
159 Withdrawal(WithdrawalSubcommand),
160
161 #[command(subcommand)]
164 Ws(commands::websocket::WebSocketCommand),
165
166 #[command(subcommand)]
169 Paper(commands::paper::PaperCommand),
170
171 #[command(subcommand)]
174 Auth(commands::auth::AuthCommand),
175
176 #[command(subcommand)]
179 Alert(commands::alert::AlertCommand),
180
181 Setup,
184
185 Shell,
187
188 Mcp {
190 #[arg(short = 's', long = "groups", default_value = "market,account,paper,auth", help = "Comma-separated service groups: market, account, trade, funding, paper, auth")]
191 groups: String,
192 #[arg(long, help = "Allow dangerous operations (trade, funding) without acknowledged flag")]
193 allow_dangerous: bool,
194 },
195}
196
197#[derive(Debug, Subcommand)]
198pub enum WithdrawalSubcommand {
199 Fee {
201 #[arg(short, long)]
202 asset: String,
203 #[arg(short, long, help = "Blockchain network (optional)")]
204 network: Option<String>,
205 },
206
207 ServeCallback {
209 #[arg(short, long, default_value = "8080")]
210 port: u16,
211 #[arg(short, long, help = "When true, auto-confirms all callback requests. When false, prompts for each request.", default_value = "false")]
212 auto_ok: bool,
213 #[arg(long, help = "Listen address (default: 127.0.0.1). Use 0.0.0.0 for network access")]
214 listen: Option<String>,
215 },
216}
217
218pub async fn dispatch(
219 cli: Cli,
220 client: &IndodaxClient,
221 config: &mut config::IndodaxConfig,
222) -> Result<CommandOutput, IndodaxError> {
223 let output = match cli.command {
224 Command::Market(ref cmd) => commands::market::execute(client, cmd).await
226 .map_err(map_anyhow_error)?,
227 Command::Account(ref cmd) => commands::account::execute(client, cmd).await
228 .map_err(map_anyhow_error)?,
229 Command::Trade(ref cmd) => commands::trade::execute(client, cmd, cli.yes).await
230 .map_err(map_anyhow_error)?,
231 Command::Funding(ref cmd) => commands::funding::execute(client, config, cmd, cli.output).await
232 .map_err(map_anyhow_error)?,
233
234 Command::ServerTime => commands::market::execute(client, &commands::market::MarketCommand::ServerTime).await
236 .map_err(map_anyhow_error)?,
237 Command::Pairs => commands::market::execute(client, &commands::market::MarketCommand::Pairs).await
238 .map_err(map_anyhow_error)?,
239 Command::Ticker { pair } => commands::market::execute(client, &commands::market::MarketCommand::Ticker { pair }).await
240 .map_err(map_anyhow_error)?,
241 Command::TickerAll => commands::market::execute(client, &commands::market::MarketCommand::TickerAll).await
242 .map_err(map_anyhow_error)?,
243 Command::Summaries => commands::market::execute(client, &commands::market::MarketCommand::Summaries).await
244 .map_err(map_anyhow_error)?,
245 Command::Orderbook { pair, count } => commands::market::execute(client, &commands::market::MarketCommand::Orderbook { pair, levels: count }).await
246 .map_err(map_anyhow_error)?,
247 Command::Trades { pair } => commands::market::execute(client, &commands::market::MarketCommand::Trades { pair }).await
248 .map_err(map_anyhow_error)?,
249 Command::Ohlc { pair, interval, since, to } => commands::market::execute(client, &commands::market::MarketCommand::Ohlc {
250 symbol: pair,
251 timeframe: interval,
252 from: since,
253 to,
254 }).await
255 .map_err(map_anyhow_error)?,
256 Command::PriceIncrements => commands::market::execute(client, &commands::market::MarketCommand::PriceIncrements).await
257 .map_err(map_anyhow_error)?,
258
259 Command::AccountInfo => commands::account::execute(client, &commands::account::AccountCommand::Info).await
261 .map_err(map_anyhow_error)?,
262 Command::Balance => commands::account::execute(client, &commands::account::AccountCommand::Balance).await
263 .map_err(map_anyhow_error)?,
264 Command::Transactions => commands::account::execute(client, &commands::account::AccountCommand::TransHistory).await
265 .map_err(map_anyhow_error)?,
266 Command::TradesHistory { pair, limit, from_id: _ } => commands::account::execute(client, &commands::account::AccountCommand::TradeHistory {
267 symbol: pair,
268 limit: limit as u32,
269 }).await
270 .map_err(map_anyhow_error)?,
271
272 Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes).await
274 .map_err(map_anyhow_error)?,
275
276 Command::Withdraw { asset, volume, address, username, memo, network, callback_url } => {
278 let funding_cmd = commands::funding::FundingCommand::Withdraw {
279 currency: asset,
280 amount: volume,
281 address,
282 username,
283 memo,
284 network,
285 callback_url,
286 };
287 commands::funding::execute(client, config, &funding_cmd, cli.output).await
288 .map_err(map_anyhow_error)?
289 }
290 Command::Withdrawal(ref sub) => {
291 let funding_cmd = match sub {
292 WithdrawalSubcommand::Fee { asset, network } => {
293 commands::funding::FundingCommand::WithdrawFee {
294 currency: asset.clone(),
295 network: network.clone(),
296 }
297 }
298 WithdrawalSubcommand::ServeCallback { port, auto_ok, listen } => {
299 commands::funding::FundingCommand::ServeCallback {
300 port: *port,
301 auto_ok: *auto_ok,
302 listen: listen.clone(),
303 }
304 }
305 };
306 commands::funding::execute(client, config, &funding_cmd, cli.output).await
307 .map_err(map_anyhow_error)?
308 }
309
310 Command::Ws(ref cmd) => commands::websocket::execute(client, cmd, cli.output).await
312 .map_err(map_anyhow_error)?,
313 Command::Paper(ref cmd) => commands::paper::execute(client, config, cmd).await
314 .map_err(map_anyhow_error)?,
315 Command::Auth(ref cmd) => commands::auth::execute(client, config, cmd).await
316 .map_err(map_anyhow_error)?,
317 Command::Alert(ref cmd) => commands::alert::execute(client, &None, cmd).await
318 .map_err(map_anyhow_error)?,
319
320 Command::Setup | Command::Shell | Command::Mcp { .. } => {
321 return Err(IndodaxError::Other("This command is handled separately".into()));
322 }
323 };
324
325 Ok(output.with_format(cli.output))
326}
327
328pub fn map_anyhow_error(e: anyhow::Error) -> IndodaxError {
329 e.downcast::<IndodaxError>()
330 .unwrap_or_else(|e| IndodaxError::Other(e.to_string()))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn test_cli_parse_ticker() {
339 let args = vec!["indodax", "ticker", "btc_idr"];
340 let cli = Cli::try_parse_from(args).unwrap();
341 match cli.command {
342 Command::Ticker { pair: _ } => {
343 }
345 _ => panic!("Expected Ticker command, got {:?}", cli.command),
346 }
347 }
348
349 #[test]
350 fn test_cli_parse_output_json() {
351 let args = vec!["indodax", "-o", "json", "ticker"];
352 let cli = Cli::try_parse_from(args).unwrap();
353 assert_eq!(cli.output, OutputFormat::Json);
354 }
355
356 #[test]
357 fn test_cli_parse_api_key() {
358 let args = vec!["indodax", "--api-key", "mykey", "ticker"];
359 let cli = Cli::try_parse_from(args).unwrap();
360 assert_eq!(cli.api_key, Some("mykey".into()));
361 }
362
363 #[test]
364 fn test_cli_parse_api_secret() {
365 let args = vec!["indodax", "--api-secret", "mysecret", "ticker"];
366 let cli = Cli::try_parse_from(args).unwrap();
367 assert_eq!(cli.api_secret, Some("mysecret".into()));
368 }
369
370 #[test]
371 fn test_cli_parse_verbose() {
372 let args = vec!["indodax", "-v", "ticker"];
373 let cli = Cli::try_parse_from(args).unwrap();
374 assert!(cli.verbose);
375 }
376
377 #[test]
378 fn test_command_variants() {
379 let _cmd1 = Command::ServerTime;
380 let _cmd2 = Command::AccountInfo;
381 let _cmd3 = Command::Order(crate::commands::trade::TradeCommand::Buy {
382 pair: "btc_idr".into(),
383 idr: 100_000.0,
384 price: None,
385 order_type: None,
386 });
387 let _cmd4 = Command::Withdrawal(WithdrawalSubcommand::Fee {
388 asset: "btc".into(),
389 network: None
390 });
391 let _cmd5 = Command::Ws(crate::commands::websocket::WebSocketCommand::Ticker {
392 pair: "btc_idr".into()
393 });
394 let _cmd6 = Command::Paper(crate::commands::paper::PaperCommand::Balance);
395 let _cmd7 = Command::Auth(crate::commands::auth::AuthCommand::Show);
396 let _cmd8 = Command::Setup;
397 let _cmd9 = Command::Shell;
398 let _cmd10 = Command::Mcp { groups: "market,paper".into(), allow_dangerous: false };
399 }
400
401 #[test]
402 fn test_output_format_clap() {
403 let args = vec!["indodax", "-o", "table", "ticker"];
404 let cli = Cli::try_parse_from(args).unwrap();
405 assert_eq!(cli.output, OutputFormat::Table);
406 }
407
408 #[test]
409 fn test_cli_parse_default_output() {
410 let args = vec!["indodax", "ticker"];
411 let cli = Cli::try_parse_from(args).unwrap();
412 assert_eq!(cli.output, OutputFormat::Table);
413 }
414
415 #[test]
416 fn test_command_display() {
417 let cli = Cli::try_parse_from(vec!["indodax", "ticker"]).unwrap();
418 let _ = format!("{:?}", cli);
419 }
420}