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 ServerTime,
50
51 Pairs,
53
54 Ticker {
56 #[arg(default_value = "btc_idr")]
57 pair: String,
58 },
59
60 TickerAll,
62
63 Summaries,
65
66 Orderbook {
68 #[arg(default_value = "btc_idr")]
69 pair: String,
70 #[arg(long, default_value = "20", help = "Number of bid/ask levels to show")]
71 count: usize,
72 },
73
74 Trades {
76 #[arg(default_value = "btc_idr")]
77 pair: String,
78 },
79
80 Ohlc {
82 #[arg(short, long, default_value = "btc_idr")]
83 pair: String,
84 #[arg(long, default_value = "60")]
85 interval: String,
86 #[arg(short, long, help = "Start timestamp in seconds (default: 24h ago)")]
87 since: Option<u64>,
88 #[arg(long, help = "End timestamp in seconds (default: now)")]
89 to: Option<u64>,
90 },
91
92 PriceIncrements,
94
95 AccountInfo,
98
99 Balance,
101
102 Transactions,
104
105 TradesHistory {
107 pair: String,
109
110 #[arg(short, long, default_value = "500")]
112 limit: usize,
113
114 #[arg(long)]
116 from_id: Option<u64>,
117 },
118
119 #[command(subcommand)]
122 Order(commands::trade::TradeCommand),
123
124 Withdraw {
127 #[arg(short, long)]
128 asset: String,
129 #[arg(short = 'v', long, help = "Amount to withdraw")]
130 volume: f64,
131 #[arg(long, help = "Crypto destination address (or Indodax username if --username is set)")]
132 address: String,
133 #[arg(long, help = "Withdraw to Indodax username instead of blockchain")]
134 username: bool,
135 #[arg(long, help = "Memo/tag (for currencies that require it)")]
136 memo: Option<String>,
137 #[arg(long, help = "Blockchain network")]
138 network: Option<String>,
139 #[arg(long, help = "Callback URL for withdrawal confirmation")]
140 callback_url: Option<String>,
141 },
142
143 #[command(subcommand)]
145 Withdrawal(WithdrawalSubcommand),
146
147 #[command(subcommand)]
150 Ws(commands::websocket::WebSocketCommand),
151
152 #[command(subcommand)]
155 Paper(commands::paper::PaperCommand),
156
157 #[command(subcommand)]
160 Auth(commands::auth::AuthCommand),
161
162 #[command(subcommand)]
165 Alert(commands::alert::AlertCommand),
166
167 Setup,
170
171 Shell,
173
174 Mcp {
176 #[arg(short = 's', long = "groups", default_value = "market,account,paper,auth", help = "Comma-separated service groups: market, account, trade, funding, paper, auth")]
177 groups: String,
178 #[arg(long, help = "Allow dangerous operations (trade, funding) without acknowledged flag")]
179 allow_dangerous: bool,
180 },
181}
182
183#[derive(Debug, Subcommand)]
184pub enum WithdrawalSubcommand {
185 Fee {
187 #[arg(short, long)]
188 asset: String,
189 #[arg(short, long, help = "Blockchain network (optional)")]
190 network: Option<String>,
191 },
192
193 ServeCallback {
195 #[arg(short, long, default_value = "8080")]
196 port: u16,
197 #[arg(short, long, help = "When true, auto-confirms all callback requests. When false, prompts for each request.", default_value = "false")]
198 auto_ok: bool,
199 #[arg(long, help = "Listen address (default: 127.0.0.1). Use 0.0.0.0 for network access")]
200 listen: Option<String>,
201 },
202}
203
204pub async fn dispatch(
205 cli: Cli,
206 client: &IndodaxClient,
207 config: &mut config::IndodaxConfig,
208) -> Result<CommandOutput, IndodaxError> {
209 let output = match cli.command {
210 Command::ServerTime => commands::market::execute(client, &commands::market::MarketCommand::ServerTime).await
212 .map_err(map_anyhow_error)?,
213 Command::Pairs => commands::market::execute(client, &commands::market::MarketCommand::Pairs).await
214 .map_err(map_anyhow_error)?,
215 Command::Ticker { pair } => commands::market::execute(client, &commands::market::MarketCommand::Ticker { pair }).await
216 .map_err(map_anyhow_error)?,
217 Command::TickerAll => commands::market::execute(client, &commands::market::MarketCommand::TickerAll).await
218 .map_err(map_anyhow_error)?,
219 Command::Summaries => commands::market::execute(client, &commands::market::MarketCommand::Summaries).await
220 .map_err(map_anyhow_error)?,
221 Command::Orderbook { pair, count } => commands::market::execute(client, &commands::market::MarketCommand::Orderbook { pair, levels: count }).await
222 .map_err(map_anyhow_error)?,
223 Command::Trades { pair } => commands::market::execute(client, &commands::market::MarketCommand::Trades { pair }).await
224 .map_err(map_anyhow_error)?,
225 Command::Ohlc { pair, interval, since, to } => commands::market::execute(client, &commands::market::MarketCommand::Ohlc {
226 symbol: pair,
227 timeframe: interval,
228 from: since,
229 to,
230 }).await
231 .map_err(map_anyhow_error)?,
232 Command::PriceIncrements => commands::market::execute(client, &commands::market::MarketCommand::PriceIncrements).await
233 .map_err(map_anyhow_error)?,
234
235 Command::AccountInfo => commands::account::execute(client, &commands::account::AccountCommand::Info).await
237 .map_err(map_anyhow_error)?,
238 Command::Balance => commands::account::execute(client, &commands::account::AccountCommand::Balance).await
239 .map_err(map_anyhow_error)?,
240 Command::Transactions => commands::account::execute(client, &commands::account::AccountCommand::TransHistory).await
241 .map_err(map_anyhow_error)?,
242 Command::TradesHistory { pair, limit, from_id: _ } => commands::account::execute(client, &commands::account::AccountCommand::TradeHistory {
243 symbol: pair,
244 limit: limit as u32,
245 }).await
246 .map_err(map_anyhow_error)?,
247
248 Command::Order(ref cmd) => commands::trade::execute(client, cmd, cli.yes).await
250 .map_err(map_anyhow_error)?,
251
252 Command::Withdraw { asset, volume, address, username, memo, network, callback_url } => {
254 let funding_cmd = commands::funding::FundingCommand::Withdraw {
255 currency: asset,
256 amount: volume,
257 address,
258 username,
259 memo,
260 network,
261 callback_url,
262 };
263 commands::funding::execute(client, config, &funding_cmd, cli.output).await
264 .map_err(map_anyhow_error)?
265 }
266 Command::Withdrawal(ref sub) => {
267 let funding_cmd = match sub {
268 WithdrawalSubcommand::Fee { asset, network } => {
269 commands::funding::FundingCommand::WithdrawFee {
270 currency: asset.clone(),
271 network: network.clone(),
272 }
273 }
274 WithdrawalSubcommand::ServeCallback { port, auto_ok, listen } => {
275 commands::funding::FundingCommand::ServeCallback {
276 port: *port,
277 auto_ok: *auto_ok,
278 listen: listen.clone(),
279 }
280 }
281 };
282 commands::funding::execute(client, config, &funding_cmd, cli.output).await
283 .map_err(map_anyhow_error)?
284 }
285
286 Command::Ws(ref cmd) => commands::websocket::execute(client, cmd, cli.output).await
288 .map_err(map_anyhow_error)?,
289 Command::Paper(ref cmd) => commands::paper::execute(client, config, cmd).await
290 .map_err(map_anyhow_error)?,
291 Command::Auth(ref cmd) => commands::auth::execute(client, config, cmd).await
292 .map_err(map_anyhow_error)?,
293 Command::Alert(ref cmd) => commands::alert::execute(client, &None, cmd).await
294 .map_err(map_anyhow_error)?,
295
296 Command::Setup | Command::Shell | Command::Mcp { .. } => {
297 return Err(IndodaxError::Other("This command is handled separately".into()));
298 }
299 };
300
301 Ok(output.with_format(cli.output))
302}
303
304pub fn map_anyhow_error(e: anyhow::Error) -> IndodaxError {
305 e.downcast::<IndodaxError>()
306 .unwrap_or_else(|e| IndodaxError::Other(e.to_string()))
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_cli_parse_ticker() {
315 let args = vec!["indodax", "ticker", "btc_idr"];
316 let cli = Cli::try_parse_from(args).unwrap();
317 match cli.command {
318 Command::Ticker { pair: _ } => {
319 }
321 _ => panic!("Expected Ticker command, got {:?}", cli.command),
322 }
323 }
324
325 #[test]
326 fn test_cli_parse_output_json() {
327 let args = vec!["indodax", "-o", "json", "ticker"];
328 let cli = Cli::try_parse_from(args).unwrap();
329 assert_eq!(cli.output, OutputFormat::Json);
330 }
331
332 #[test]
333 fn test_cli_parse_api_key() {
334 let args = vec!["indodax", "--api-key", "mykey", "ticker"];
335 let cli = Cli::try_parse_from(args).unwrap();
336 assert_eq!(cli.api_key, Some("mykey".into()));
337 }
338
339 #[test]
340 fn test_cli_parse_api_secret() {
341 let args = vec!["indodax", "--api-secret", "mysecret", "ticker"];
342 let cli = Cli::try_parse_from(args).unwrap();
343 assert_eq!(cli.api_secret, Some("mysecret".into()));
344 }
345
346 #[test]
347 fn test_cli_parse_verbose() {
348 let args = vec!["indodax", "-v", "ticker"];
349 let cli = Cli::try_parse_from(args).unwrap();
350 assert!(cli.verbose);
351 }
352
353 #[test]
354 fn test_command_variants() {
355 let _cmd1 = Command::ServerTime;
356 let _cmd2 = Command::AccountInfo;
357 let _cmd3 = Command::Order(crate::commands::trade::TradeCommand::Buy {
358 pair: "btc_idr".into(),
359 idr: 100_000.0,
360 price: None,
361 order_type: None,
362 });
363 let _cmd4 = Command::Withdrawal(WithdrawalSubcommand::Fee {
364 asset: "btc".into(),
365 network: None
366 });
367 let _cmd5 = Command::Ws(crate::commands::websocket::WebSocketCommand::Ticker {
368 pair: "btc_idr".into()
369 });
370 let _cmd6 = Command::Paper(crate::commands::paper::PaperCommand::Balance);
371 let _cmd7 = Command::Auth(crate::commands::auth::AuthCommand::Show);
372 let _cmd8 = Command::Setup;
373 let _cmd9 = Command::Shell;
374 let _cmd10 = Command::Mcp { groups: "market,paper".into(), allow_dangerous: false };
375 }
376
377 #[test]
378 fn test_output_format_clap() {
379 let args = vec!["indodax", "-o", "table", "ticker"];
380 let cli = Cli::try_parse_from(args).unwrap();
381 assert_eq!(cli.output, OutputFormat::Table);
382 }
383
384 #[test]
385 fn test_cli_parse_default_output() {
386 let args = vec!["indodax", "ticker"];
387 let cli = Cli::try_parse_from(args).unwrap();
388 assert_eq!(cli.output, OutputFormat::Table);
389 }
390
391 #[test]
392 fn test_command_display() {
393 let cli = Cli::try_parse_from(vec!["indodax", "ticker"]).unwrap();
394 let _ = format!("{:?}", cli);
395 }
396}