1use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, anyhow};
9use chrono::{DateTime, Duration, Utc};
10use futu_auth::{KeyRecord, Limits, Scope, machine, store};
11
12use crate::output::OutputFormat;
13
14fn default_keys_path() -> Result<PathBuf> {
16 let base =
17 dirs::config_dir().ok_or_else(|| anyhow!("cannot resolve config dir (set --keys-file)"))?;
18 Ok(base.join("futu").join("keys.json"))
19}
20
21fn detect_futu_mcp_path() -> Option<PathBuf> {
33 let exe_name = if cfg!(windows) {
34 "futu-mcp.exe"
35 } else {
36 "futu-mcp"
37 };
38
39 if let Ok(cli) = std::env::current_exe()
41 && let Some(dir) = cli.parent()
42 {
43 let candidate = dir.join(exe_name);
44 if candidate.is_file() {
45 return Some(candidate);
46 }
47 }
48
49 if let Some(path_env) = std::env::var_os("PATH") {
51 for dir in std::env::split_paths(&path_env) {
52 let candidate = dir.join(exe_name);
53 if candidate.is_file() {
54 return Some(candidate);
55 }
56 }
57 }
58
59 None
60}
61
62fn parse_expires(s: &str) -> Result<DateTime<Utc>> {
64 let s = s.trim();
65 if let Ok(t) = DateTime::parse_from_rfc3339(s) {
66 return Ok(t.with_timezone(&Utc));
67 }
68 let (num_part, unit) = s
70 .chars()
71 .position(|c| c.is_alphabetic())
72 .map(|i| (&s[..i], &s[i..]))
73 .ok_or_else(|| anyhow!("invalid expires {s:?}: expect Nd / Nh / Nm / RFC3339"))?;
74 let n: i64 = num_part
75 .parse()
76 .map_err(|e| anyhow!("invalid number in expires {s:?}: {e}"))?;
77 let dur = match unit {
78 "d" => Duration::days(n),
79 "h" => Duration::hours(n),
80 "m" => Duration::minutes(n),
81 other => return Err(anyhow!("unknown expires unit {other:?} (d|h|m)")),
82 };
83 Ok(Utc::now() + dur)
84}
85
86fn parse_scopes(s: &str) -> Result<HashSet<Scope>> {
87 let mut out = HashSet::new();
88 for part in s.split(',') {
89 let part = part.trim();
90 if part.is_empty() {
91 continue;
92 }
93 let sc: Scope = part
94 .parse()
95 .map_err(|e| anyhow!("parse scope {part:?}: {e}"))?;
96 out.insert(sc);
97 }
98 if out.is_empty() {
99 return Err(anyhow!("--scopes cannot be empty"));
100 }
101 Ok(out)
102}
103
104fn parse_acc_ids_csv(s: &str) -> Result<HashSet<u64>> {
114 let mut out = HashSet::new();
115 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
116 let id: u64 = token.parse().map_err(|e| {
117 anyhow::anyhow!(
118 "invalid acc_id {token:?}: expected positive integer, got {e}. \
119 Usage: --allowed-acc-ids 10001,10002,10003"
120 )
121 })?;
122 out.insert(id);
123 }
124 Ok(out)
125}
126
127pub struct GenKeyCommand {
128 pub id: String,
129 pub scopes: String,
130 pub keys_file: Option<PathBuf>,
131 pub expires: Option<String>,
132 pub note: Option<String>,
133 pub allowed_markets: Option<String>,
134 pub allowed_symbols: Option<String>,
135 pub max_order_value: Option<f64>,
136 pub max_daily_value: Option<f64>,
137 pub hours_window: Option<String>,
138 pub max_orders_per_minute: Option<u32>,
139 pub allowed_trd_sides: Option<String>,
140 pub allowed_acc_ids: Option<String>,
143 pub allowed_card_nums: Option<String>,
148 pub bind_this_machine: bool,
149 pub bind_machines: Option<String>,
150 pub output: OutputFormat,
151}
152
153pub async fn run(input: GenKeyCommand) -> Result<()> {
154 let GenKeyCommand {
155 id,
156 scopes,
157 keys_file,
158 expires,
159 note,
160 allowed_markets,
161 allowed_symbols,
162 max_order_value,
163 max_daily_value,
164 hours_window,
165 max_orders_per_minute,
166 allowed_trd_sides,
167 allowed_acc_ids,
168 allowed_card_nums,
169 bind_this_machine,
170 bind_machines,
171 output,
172 } = input;
173
174 let path = match keys_file {
175 Some(p) => p,
176 None => default_keys_path()?,
177 };
178 let scopes = parse_scopes(&scopes)?;
179 let expires_at = match expires {
180 Some(s) => Some(parse_expires(&s)?),
181 None => None,
182 };
183
184 let allowed_trd_sides = match allowed_trd_sides {
188 Some(s) => Some(crate::cmd::key_enums::parse_trd_sides_csv(&s)?),
189 None => None,
190 };
191
192 let allowed_acc_ids = match allowed_acc_ids {
194 Some(s) => Some(parse_acc_ids_csv(&s)?),
195 None => None,
196 };
197
198 let allowed_card_nums: Option<Vec<String>> = match allowed_card_nums {
206 None => None,
207 Some(s) => {
208 let parsed: Vec<String> = s
209 .split(',')
210 .map(|p| p.trim().to_string())
211 .filter(|p| !p.is_empty())
212 .collect();
213 if parsed.is_empty() {
214 return Err(anyhow!(
215 "v1.4.104 external report P2-008 (P2) fix: --allowed-card-nums {s:?} parsed to empty \
216 list. daemon 会把空 list 当 \"无限制\" sentinel — 与你的意图相反. \
217 如要 \"不限制\" 请**不传** --allowed-card-nums; 如要 \"完全限制\" 至少 \
218 传 1 个真实 4/16 位 card_num (即使是 dummy 0000)."
219 ));
220 }
221 Some(parsed)
222 }
223 };
224 if let Some(ref nums) = allowed_card_nums {
226 for cn in nums {
227 if !cn.chars().all(|c| c.is_ascii_digit()) || (cn.len() != 4 && cn.len() != 16) {
228 return Err(anyhow!(
229 "invalid card_num {cn:?}: expected 4-digit suffix \
230 or 16-digit full card number (synthetic example: 4-digit \
231 `<card-suffix>` or 16-digit `<full-card-num>`). \
232 Got len={}, all-digits={}",
233 cn.len(),
234 cn.chars().all(|c| c.is_ascii_digit())
235 ));
236 }
237 }
238 }
239
240 let allowed_markets = match allowed_markets {
246 Some(s) => Some(crate::cmd::key_enums::parse_markets_csv(&s)?),
247 None => None,
248 };
249 let allowed_symbols = match allowed_symbols {
250 Some(s) => Some(crate::cmd::key_enums::parse_symbols_csv(&s)?),
251 None => None,
252 };
253
254 let limits = Limits {
255 allowed_markets,
256 allowed_symbols,
257 max_order_value,
258 max_daily_value,
259 hours_window,
260 max_orders_per_minute,
261 allowed_trd_sides,
262 allowed_acc_ids,
263 allowed_card_nums,
264 };
265 let limits_for_output = limits.clone();
266
267 let allowed_machines =
269 build_allowed_machines(&id, bind_this_machine, bind_machines.as_deref())?;
270
271 let (plaintext, record) = KeyRecord::generate_with_machines(
272 id.clone(),
273 scopes.clone(),
274 Some(limits),
275 expires_at,
276 note,
277 allowed_machines.clone(),
278 );
279
280 store::append_key(&path, record).with_context(|| format!("append to {}", path.display()))?;
281
282 print_result(
283 KeyPrintView {
284 path: &path,
285 id: &id,
286 plaintext: &plaintext,
287 scopes: &scopes,
288 allowed_machines: allowed_machines.as_deref(),
289 limits: &limits_for_output,
290 },
291 output,
292 )?;
293 Ok(())
294}
295
296fn build_allowed_machines(
311 id: &str,
312 bind_this: bool,
313 bind_others: Option<&str>,
314) -> Result<Option<Vec<String>>> {
315 let mut list: Vec<String> = Vec::new();
316 if bind_this {
317 let fp = machine::fingerprint_for(id)
318 .map_err(|e| anyhow!("cannot compute this machine's fingerprint: {e}"))?;
319 list.push(fp);
320 }
321 if let Some(raw) = bind_others {
322 let parsed = crate::cmd::key_enums::parse_fingerprints_csv(raw)?;
323 if parsed.is_empty() && !bind_this {
324 return Err(anyhow!(
328 "v1.4.106 F4: --bind-machines {raw:?} parsed to empty list \
329 (no --bind-this-machine either). 这会让 allowed_machines = None \
330 (无机器绑定限制) — 与你的意图相反. 如要 \"不启用绑定\" 请不传 \
331 --bind-machines; 如要至少 1 个机器, 传至少 1 个 64-hex 指纹 \
332 (`futucli machine-id --for-key <id>`)."
333 ));
334 }
335 list.extend(parsed);
336 }
337 if list.is_empty() {
338 return Ok(None);
339 }
340 let mut seen = HashSet::new();
342 list.retain(|x| seen.insert(x.clone()));
343 Ok(Some(list))
344}
345
346struct KeyPrintView<'a> {
347 path: &'a Path,
348 id: &'a str,
349 plaintext: &'a str,
350 scopes: &'a HashSet<Scope>,
351 allowed_machines: Option<&'a [String]>,
352 limits: &'a Limits,
353}
354
355fn print_result(view: KeyPrintView<'_>, output: OutputFormat) -> Result<()> {
356 let rendered = render_result(view, output, detect_futu_mcp_path().as_deref())?;
357 println!("{rendered}");
358 Ok(())
359}
360
361fn render_result(
362 view: KeyPrintView<'_>,
363 output: OutputFormat,
364 mcp_path: Option<&Path>,
365) -> Result<String> {
366 use std::fmt::Write as _;
367
368 let mut scope_list: Vec<&str> = view.scopes.iter().map(|s| s.as_str()).collect();
369 scope_list.sort_unstable();
370 let mcp_command = mcp_path
371 .map(|path| path.display().to_string())
372 .unwrap_or_else(|| "REPLACE_WITH_ABSOLUTE_PATH_RUN_which_futu_mcp".to_string());
373
374 if matches!(output, OutputFormat::Json | OutputFormat::Jsonl) {
375 let limits = serde_json::json!({
376 "allowed_markets": view.limits.allowed_markets,
377 "allowed_symbols": view.limits.allowed_symbols,
378 "max_order_value": view.limits.max_order_value,
379 "max_daily_value": view.limits.max_daily_value,
380 "hours_window": view.limits.hours_window,
381 "max_orders_per_minute": view.limits.max_orders_per_minute,
382 "allowed_trd_sides": view.limits.allowed_trd_sides,
383 "allowed_acc_ids": view.limits.allowed_acc_ids,
384 "allowed_card_nums": view.limits.allowed_card_nums,
385 });
386 let value = serde_json::json!({
387 "id": view.id,
388 "scopes": scope_list,
389 "keys_file": view.path.display().to_string(),
390 "plaintext": view.plaintext,
391 "limits": limits,
392 "allowed_machines": view.allowed_machines,
393 "mcp_config": {
394 "mcpServers": {
395 "futu": {
396 "command": mcp_command,
397 "args": ["--keys-file", view.path.display().to_string()],
398 "env": {"FUTU_MCP_API_KEY": view.plaintext}
399 }
400 }
401 }
402 });
403 return match output {
404 OutputFormat::Json => serde_json::to_string_pretty(&value).map_err(Into::into),
405 OutputFormat::Jsonl => serde_json::to_string(&value).map_err(Into::into),
406 _ => unreachable!(),
407 };
408 }
409
410 let mut rendered = String::new();
411 writeln!(rendered)?;
412 writeln!(rendered, "=== FutuOpenD-rs API Key ===")?;
413 writeln!(rendered)?;
414 writeln!(rendered, " id : {}", view.id)?;
415 writeln!(rendered, " scopes : {}", scope_list.join(", "))?;
416 writeln!(rendered, " path : {}", view.path.display())?;
417 if let Some(r) = view.limits.max_orders_per_minute {
418 writeln!(rendered, " rate : {r} orders/min")?;
419 }
420 if let Some(sides) = view.limits.allowed_trd_sides.as_ref()
421 && !sides.is_empty()
422 {
423 let mut v: Vec<String> = sides.iter().cloned().collect();
424 v.sort();
425 writeln!(rendered, " sides : {}", v.join(","))?;
426 }
427 if let Some(ms) = view.allowed_machines {
428 writeln!(rendered, " bound : {} machine(s)", ms.len())?;
429 for fp in ms {
430 writeln!(rendered, " {}", &fp[..16])?;
431 }
432 }
433 writeln!(rendered)?;
434 writeln!(
435 rendered,
436 "Plaintext (shown once, SAVE IT NOW — file only stores SHA-256 hash):"
437 )?;
438 writeln!(rendered)?;
439 writeln!(rendered, " FUTU_MCP_API_KEY={}", view.plaintext)?;
440 writeln!(rendered)?;
441 writeln!(
442 rendered,
443 "Add to your MCP client config, e.g. Claude Desktop claude_desktop_config.json:"
444 )?;
445 writeln!(rendered)?;
446 let (mcp_command_json, post_note) = match mcp_path {
451 Some(path) => (
452 serde_json::to_string(&path.display().to_string())?,
453 format!("(auto-detected: {})", path.display()),
454 ),
455 None => (
456 r#""REPLACE_WITH_ABSOLUTE_PATH_RUN_which_futu_mcp""#.to_string(),
457 "⚠️ futu-mcp not found in PATH or next to futucli — replace the \
458 command field above with the absolute path (run: `which futu-mcp`)."
459 .to_string(),
460 ),
461 };
462 writeln!(rendered, " {{")?;
463 writeln!(rendered, " \"mcpServers\": {{")?;
464 writeln!(rendered, " \"futu\": {{")?;
465 writeln!(rendered, " \"command\": {mcp_command_json},")?;
466 writeln!(
467 rendered,
468 " \"args\": [\"--keys-file\", \"{}\"],",
469 view.path.display()
470 )?;
471 writeln!(
472 rendered,
473 " \"env\": {{ \"FUTU_MCP_API_KEY\": \"{}\" }}",
474 view.plaintext
475 )?;
476 writeln!(rendered, " }}")?;
477 writeln!(rendered, " }}")?;
478 writeln!(rendered, " }}")?;
479 writeln!(rendered)?;
480 writeln!(rendered, " command path: {post_note}")?;
481 Ok(rendered)
482}
483
484#[cfg(test)]
485mod tests;