1use anyhow::{bail, Context as _, Result};
2use clap::{Parser, Subcommand};
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::config::{config_path, config_template, credentials_path, log_path, pid_path, CredentialsStore};
7use crate::oauth::{claude_credentials_path, read_claude_credentials, refresh_token, revoke_token, run_oauth_flow};
8use crate::term::{self, bold, bold_white, brand_green, cyan, dark_green, dim, green, green_bold, red, yellow, CHECK, CROSS, DIAMOND, DOT, EMPTY};
9
10#[derive(Parser)]
11#[command(name = "shunt", about = "Local Claude Code account-pooling proxy", version)]
12struct Cli {
13 #[command(subcommand)]
14 command: Command,
15}
16
17#[derive(Subcommand)]
18enum Command {
19 Setup {
21 #[arg(long)]
22 config: Option<PathBuf>,
23 },
24 Start {
26 #[arg(long)]
27 config: Option<PathBuf>,
28 #[arg(long)]
29 host: Option<String>,
30 #[arg(long)]
31 port: Option<u16>,
32 #[arg(long)]
34 foreground: bool,
35 #[arg(long)]
37 verbose: bool,
38 #[arg(long, hide = true)]
40 daemon: bool,
41 },
42 Stop,
44 Restart {
46 #[arg(long)]
47 config: Option<PathBuf>,
48 },
49 Status {
51 #[arg(long)]
52 config: Option<PathBuf>,
53 },
54 Logs {
61 #[arg(long)]
62 config: Option<PathBuf>,
63 #[arg(short, long)]
65 follow: bool,
66 #[arg(short = 'n', long, default_value = "50")]
68 lines: usize,
69 },
70 AddAccount {
72 #[arg(long)]
73 config: Option<PathBuf>,
74 name: Option<String>,
76 #[arg(long)]
78 provider: Option<String>,
79 },
80 RemoveAccount {
82 #[arg(long)]
83 config: Option<PathBuf>,
84 name: Option<String>,
86 },
87 Share {
89 #[arg(long)]
90 config: Option<PathBuf>,
91 #[arg(long)]
93 tunnel: bool,
94 #[arg(long)]
96 stop: bool,
97 },
98 Logout {
105 #[arg(long)]
106 config: Option<PathBuf>,
107 name: Option<String>,
109 #[arg(long)]
111 all: bool,
112 },
113 Monitor {
115 #[arg(long)]
116 config: Option<PathBuf>,
117 },
118 Remote {
127 code: Option<String>,
129 },
130 Update,
132 Use {
139 #[arg(long)]
140 config: Option<PathBuf>,
141 account: Option<String>,
143 },
144 Push {
149 #[arg(long)]
150 config: Option<PathBuf>,
151 },
152 Login {
157 code: String,
159 },
160 Completions {
167 shell: clap_complete::Shell,
169 },
170}
171
172pub async fn run() -> Result<()> {
173 let cli = Cli::parse();
174 match cli.command {
175 Command::Setup { config } => cmd_setup(config).await,
176 Command::Start { config, host, port, foreground, verbose, daemon } => cmd_start(config, host, port, foreground, verbose, daemon).await,
177 Command::Stop => cmd_stop().await,
178 Command::Restart { config } => cmd_restart(config).await,
179 Command::Status { config } => cmd_status(config).await,
180 Command::Logs { config, follow, lines } => cmd_logs(config, follow, lines).await,
181 Command::AddAccount { config, name, provider } => cmd_add_account(config, name, provider.as_deref()).await,
182 Command::RemoveAccount { config, name } => cmd_remove_account(config, name).await,
183 Command::Logout { config, name, all } => cmd_logout(config, name, all).await,
184 Command::Monitor { config } => cmd_monitor(config).await,
185 Command::Remote { code } => cmd_remote(code).await,
186 Command::Update => cmd_update().await,
187 Command::Share { config, tunnel, stop } => cmd_share(config, tunnel, stop).await,
188 Command::Use { config, account } => cmd_use(config, account).await,
189 Command::Push { config } => cmd_push(config).await,
190 Command::Login { code } => cmd_login(code).await,
191 Command::Completions { shell } => { cmd_completions(shell); Ok(()) }
192 }
193}
194
195pub async fn cmd_setup(config_override: Option<PathBuf>) -> Result<()> {
200 let config_p = config_override.clone().unwrap_or_else(config_path);
201
202 print_splash(&[
203 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
204 dim("Setup"),
205 String::new(),
206 ]);
207
208 if config_p.exists() {
209 println!(" {} Already configured.", green(CHECK));
210 println!(" {} Use {} to add more accounts.", dim("·"), cyan("shunt add-account"));
211 println!();
212 return Ok(());
213 }
214
215 let cred = match read_claude_credentials() {
217 Some(mut c) => {
218 if c.needs_refresh() {
219 print!(" {} Token expired, refreshing… ", yellow("↻"));
220 use std::io::Write;
221 std::io::stdout().flush().ok();
222 match refresh_token(&c).await {
223 Ok(fresh) => { println!("{}", green("done")); c = fresh; }
224 Err(e) => println!("{} ({})", yellow("failed"), dim(&e.to_string())),
225 }
226 } else {
227 println!(" {} Claude Code session found", green(CHECK));
228 }
229 c
230 }
231 None => {
232 println!(" {} No Claude Code session at {}", red(CROSS), dim(&claude_credentials_path().display().to_string()));
233 println!(" {} Run {} first, then re-run setup.", dim("·"), cyan("claude"));
234 println!();
235 bail!("No Claude Code credentials found.");
236 }
237 };
238
239 let plan = crate::oauth::read_claude_session_info()
240 .map(|s| s.plan)
241 .unwrap_or_else(|| "pro".to_string());
242 println!(" {} Plan: {}", green(CHECK), bold(&plan));
243
244 let email = crate::oauth::fetch_account_email(&cred.access_token).await;
246 if let Some(ref e) = email {
247 println!(" {} Account: {}", green(CHECK), bold(e));
248 }
249 let mut cred = cred;
250 cred.email = email;
251
252 if let Some(parent) = config_p.parent() {
254 std::fs::create_dir_all(parent)?;
255 }
256 std::fs::write(&config_p, config_template(&[("main", &plan)]))?;
257 #[cfg(unix)]
258 {
259 use std::os::unix::fs::PermissionsExt;
260 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
261 }
262
263 let mut store = CredentialsStore::default();
265 store.accounts.insert("main".into(), cred);
266 store.save()?;
267
268 println!();
269 println!(" {} Config {}", green("→"), dim(&config_p.display().to_string()));
270 println!(" {} Credentials {}", green("→"), dim(&credentials_path().display().to_string()));
271
272 offer_shell_export()?;
273
274 println!();
275 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
276
277 Ok(())
278}
279
280async fn cmd_add_account(
285 config_override: Option<PathBuf>,
286 name_arg: Option<String>,
287 provider_arg: Option<&str>,
288) -> Result<()> {
289 use crate::provider::Provider;
290
291 let config_p = config_override.clone().unwrap_or_else(config_path);
292 if !config_p.exists() {
293 bail!("No config found. Run `shunt setup` first.");
294 }
295
296 print_splash(&[
297 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
298 "Add account".to_string(),
299 String::new(),
300 ]);
301
302 let provider = if let Some(p) = provider_arg {
304 Provider::from_str(p)
305 } else {
306 let items = vec![
307 term::SelectItem {
308 label: format!("{} {}",
309 bold("Claude Code"),
310 dim("(claude.ai — Anthropic)")),
311 value: "anthropic".into(),
312 },
313 term::SelectItem {
314 label: format!("{} {}",
315 bold("Codex"),
316 dim("(chatgpt.com — OpenAI)")),
317 value: "openai".into(),
318 },
319 ];
320 match term::select("Which provider?", &items, 0) {
321 Some(v) => Provider::from_str(&v),
322 None => return Ok(()),
323 }
324 };
325
326 println!();
327
328 let existing_config = std::fs::read_to_string(&config_p)?;
330 let store = CredentialsStore::load();
331
332 let (name, already_in_config) = if let Some(n) = name_arg {
333 let in_config = existing_config.contains(&format!("name = \"{n}\""));
334 let has_cred = store.accounts.contains_key(&n);
335 let is_expired = store.accounts.get(&n).map(|c| c.needs_refresh()).unwrap_or(false);
336 let is_auth_failed = crate::state::StateStore::load(&crate::config::state_path())
337 .account_states().get(&n).map(|s| s.auth_failed).unwrap_or(false);
338 if in_config && has_cred && !is_expired && !is_auth_failed {
339 bail!("Account '{}' already has a valid credential.", n);
340 }
341 (n, in_config)
342 } else {
343 let config = crate::config::load_config(config_override.as_deref())?;
345 let missing: Vec<_> = config.accounts.iter()
346 .filter(|a| a.provider == provider && a.credential.is_none())
347 .collect();
348
349 match missing.len() {
350 1 => {
351 println!(" {} Authorizing account {}", yellow("↻"), bold(&format!("'{}'", missing[0].name)));
352 println!();
353 (missing[0].name.clone(), true)
354 }
355 n if n > 1 => {
356 let items: Vec<term::SelectItem> = missing.iter().map(|a| term::SelectItem {
357 label: bold(&a.name).to_string(),
358 value: a.name.clone(),
359 }).collect();
360 match term::select("Which account to authorize?", &items, 0) {
361 Some(v) => (v, true),
362 None => return Ok(()),
363 }
364 }
365 _ => {
366 print!(" {} Account name: ", dim("·"));
368 use std::io::Write;
369 std::io::stdout().flush().ok();
370 let mut input = String::new();
371 std::io::stdin().read_line(&mut input)?;
372 let n = input.trim().to_string();
373 if n.is_empty() { bail!("Account name cannot be empty."); }
374 (n, false)
375 }
376 }
377 };
378
379 let mut cred = match provider {
381 Provider::Anthropic => run_oauth_flow().await?,
382 Provider::OpenAI => crate::oauth::run_openai_oauth_flow().await?,
383 };
384
385 let email = match provider {
387 Provider::Anthropic => crate::oauth::fetch_account_email(&cred.access_token).await,
388 Provider::OpenAI => crate::oauth::fetch_openai_account_email(&cred.access_token).await,
389 };
390 if let Some(ref e) = email {
391 println!(" {} Signed in as {}", green(CHECK), bold(e));
392 }
393 cred.email = email;
394
395 if !already_in_config {
397 let mut config_text = existing_config;
398 match provider {
399 Provider::Anthropic => config_text.push_str(&format!(
400 "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\n"
401 )),
402 Provider::OpenAI => config_text.push_str(&format!(
403 "\n[[accounts]]\nname = \"{name}\"\nplan_type = \"pro\"\nprovider = \"openai\"\n"
404 )),
405 }
406 std::fs::write(&config_p, &config_text)?;
407 }
408
409 let mut store = CredentialsStore::load();
410 store.accounts.insert(name.clone(), cred.clone());
411 store.save()?;
412
413 if cred.id_token.is_some() {
415 crate::oauth::write_codex_auth_file(&cred);
416 }
417
418 println!();
419 println!(" {} Account {} added.", green(CHECK), bold(&format!("'{name}'")));
420 offer_restart(config_override).await;
421 println!();
422 Ok(())
423}
424
425async fn cmd_remove_account(config_override: Option<PathBuf>, name: Option<String>) -> Result<()> {
430 let config_p = config_override.clone().unwrap_or_else(config_path);
431 if !config_p.exists() {
432 bail!("No config found. Run `shunt setup` first.");
433 }
434
435 let name = if let Some(n) = name {
437 n
438 } else {
439 let config = crate::config::load_config(config_override.as_deref())?;
440 let removable: Vec<_> = config.accounts.iter().collect();
441 if removable.is_empty() {
442 bail!("No accounts to remove.");
443 }
444 let items: Vec<term::SelectItem> = removable.iter().map(|a| {
445 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
446 term::SelectItem {
447 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
448 value: a.name.clone(),
449 }
450 }).collect();
451 match term::select("Remove account:", &items, 0) {
452 Some(v) => v,
453 None => return Ok(()),
454 }
455 };
456
457 let config_text = std::fs::read_to_string(&config_p)?;
458 if !config_text.contains(&format!("name = \"{name}\"")) {
459 bail!("Account '{name}' not found.");
460 }
461
462 print_splash(&[
463 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
464 format!("Removing account {}", bold(&format!("'{name}'"))),
465 String::new(),
466 ]);
467
468 let new_config = remove_account_block(&config_text, &name);
470 std::fs::write(&config_p, &new_config)?;
471 println!(" {} Removed from config", green(CHECK));
472
473 let mut store = CredentialsStore::load();
475 if store.accounts.remove(&name).is_some() {
476 store.save()?;
477 println!(" {} Credential removed", green(CHECK));
478 }
479
480 println!();
481 println!(" {} Account {} removed.", green(CHECK), bold(&format!("'{name}'")));
482 offer_restart(config_override).await;
483 println!();
484 Ok(())
485}
486
487async fn cmd_logout(config_override: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
492 let config_p = config_override.clone().unwrap_or_else(config_path);
493 if !config_p.exists() {
494 bail!("No config found. Run `shunt setup` first.");
495 }
496
497 let config = crate::config::load_config(config_override.as_deref())?;
498
499 let names: Vec<String> = if all {
501 config.accounts.iter()
502 .filter(|a| a.credential.is_some())
503 .map(|a| a.name.clone())
504 .collect()
505 } else if let Some(n) = name {
506 if !config.accounts.iter().any(|a| a.name == n) {
507 bail!("Account '{n}' not found.");
508 }
509 vec![n]
510 } else {
511 let with_cred: Vec<_> = config.accounts.iter()
513 .filter(|a| a.credential.is_some())
514 .collect();
515 if with_cred.is_empty() {
516 println!(" {} No logged-in accounts.", dim("·"));
517 println!();
518 return Ok(());
519 }
520 let items: Vec<term::SelectItem> = with_cred.iter().map(|a| {
521 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
522 term::SelectItem {
523 label: format!("{} {}", bold(&pad(&a.name, 12)), dim(&pad(email, 32))),
524 value: a.name.clone(),
525 }
526 }).collect();
527 match term::select("Log out account:", &items, 0) {
528 Some(v) => vec![v],
529 None => return Ok(()),
530 }
531 };
532
533 if names.is_empty() {
534 println!(" {} No logged-in accounts.", dim("·"));
535 println!();
536 return Ok(());
537 }
538
539 let label = if names.len() == 1 {
540 format!("account {}", bold(&format!("'{}'", names[0])))
541 } else {
542 format!("{} accounts", bold(&names.len().to_string()))
543 };
544
545 print_splash(&[
546 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
547 format!("Logging out {label}"),
548 String::new(),
549 ]);
550
551 let mut store = CredentialsStore::load();
552
553 for name in &names {
554 if let Some(cred) = store.accounts.get(name) {
556 print!(" {} Revoking '{}' token… ", dim("↻"), name);
557 use std::io::Write;
558 std::io::stdout().flush().ok();
559 if revoke_token(&cred.access_token).await {
560 println!("{}", green("done"));
561 } else {
562 println!("{}", dim("(server did not confirm — cleared locally)"));
563 }
564 }
565
566 store.accounts.remove(name);
568 println!(" {} Credential for '{}' removed", green(CHECK), name);
569 }
570
571 store.save()?;
572
573 println!();
574 println!(" {} Logged out {}.", green(CHECK), label);
575 println!(" {} To re-authorize: {}", dim("·"), cyan("shunt add-account"));
576 println!();
577 Ok(())
578}
579
580fn remove_account_block(config: &str, name: &str) -> String {
583 let mut doc = match config.parse::<toml_edit::DocumentMut>() {
584 Ok(d) => d,
585 Err(_) => return config.to_owned(), };
587
588 if let Some(item) = doc.get_mut("accounts") {
589 if let Some(arr) = item.as_array_of_tables_mut() {
590 let to_remove: Vec<usize> = arr.iter()
592 .enumerate()
593 .filter(|(_, t)| t.get("name").and_then(|v| v.as_str()) == Some(name))
594 .map(|(i, _)| i)
595 .collect();
596 for i in to_remove.into_iter().rev() {
597 arr.remove(i);
598 }
599 }
600 }
601
602 doc.to_string()
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 const SAMPLE_CONFIG: &str = r#"
610[server]
611port = 8082
612
613[[accounts]]
614name = "alice"
615plan_type = "pro"
616
617[[accounts]]
618name = "bob"
619plan_type = "max"
620
621[[accounts]]
622name = "charlie"
623plan_type = "pro"
624"#;
625
626 #[test]
627 fn test_remove_account_block_removes_target() {
628 let result = remove_account_block(SAMPLE_CONFIG, "bob");
629 assert!(!result.contains("\"bob\"") && !result.contains("'bob'") && !result.contains("bob"),
631 "removed account must not appear: {result}");
632 assert!(result.contains("alice"));
634 assert!(result.contains("charlie"));
635 }
636
637 #[test]
638 fn test_remove_account_block_preserves_others() {
639 let result = remove_account_block(SAMPLE_CONFIG, "alice");
640 assert!(!result.contains("alice"), "alice must be removed");
641 assert!(result.contains("bob"), "bob must remain");
642 assert!(result.contains("charlie"), "charlie must remain");
643 }
644
645 #[test]
646 fn test_remove_account_block_noop_when_not_found() {
647 let result = remove_account_block(SAMPLE_CONFIG, "dave");
648 assert!(result.contains("alice"));
650 assert!(result.contains("bob"));
651 assert!(result.contains("charlie"));
652 }
653
654 #[test]
655 fn test_remove_account_block_last_account() {
656 let cfg = "[[accounts]]\nname = \"only\"\nplan_type = \"pro\"\n";
657 let result = remove_account_block(cfg, "only");
658 assert!(!result.contains("only"), "sole account must be removed");
659 }
660
661 #[test]
662 fn test_remove_account_block_handles_unparseable_input() {
663 let bad = "not valid [[toml{{ garbage";
664 let result = remove_account_block(bad, "anything");
665 assert_eq!(result, bad);
667 }
668
669 #[test]
670 fn test_remove_account_block_with_inline_comment() {
671 let cfg = "[[accounts]]\nname = \"alice\" # main account\nplan_type = \"pro\"\n\n[[accounts]]\nname = \"bob\"\nplan_type = \"max\"\n";
672 let result = remove_account_block(cfg, "alice");
673 assert!(!result.contains("alice"));
674 assert!(result.contains("bob"));
675 }
676}
677
678async fn cmd_start(
683 config_override: Option<PathBuf>,
684 host_override: Option<String>,
685 port_override: Option<u16>,
686 foreground: bool,
687 verbose: bool,
688 daemon: bool,
689) -> Result<()> {
690 let config_p = config_override.clone().unwrap_or_else(config_path);
691
692 if daemon {
694 if !config_p.exists() { return Ok(()); }
695 let mut config = crate::config::load_config(config_override.as_deref())?;
696 let host = host_override.unwrap_or_else(|| config.server.host.clone());
697 let port = port_override.unwrap_or(config.server.port);
698
699 for account in &mut config.accounts {
700 if let Some(cred) = &account.credential {
701 if cred.needs_refresh() {
702 if let Ok(Ok(fresh)) = tokio::time::timeout(
703 std::time::Duration::from_secs(10),
704 account.provider.refresh_token(cred),
705 ).await {
706 let mut store = CredentialsStore::load();
707 store.accounts.insert(account.name.clone(), fresh.clone());
708 store.save().ok();
709 account.credential = Some(fresh);
710 }
711 }
712 }
713 }
714
715 let lp = log_path();
716 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
717 crate::logging::prune_old_logs(&lp, 7);
718 let _log_guard = crate::logging::setup(&lp, log_level)?;
719 let state = crate::state::StateStore::load(&crate::config::state_path());
720 write_pid();
721 serve_all_providers(config, state, &host, port).await?;
722 return Ok(());
723 }
724
725 if !config_p.exists() {
727 cmd_setup_auto(config_override.clone()).await?;
728 }
729
730 let config = crate::config::load_config(config_override.as_deref())?;
731 let host = host_override.clone().unwrap_or_else(|| config.server.host.clone());
732 let port = port_override.unwrap_or(config.server.port);
733
734 for pid in port_pids(port) {
736 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
737 }
738 if !port_pids(port).is_empty() {
739 std::thread::sleep(std::time::Duration::from_millis(400));
740 }
741
742 if foreground {
744 use std::io::Write as _;
745 let mut config = config;
746 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
747 print_routing_header(&account_names, &[
748 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
749 dim("foreground").to_string(),
750 ]);
751 for account in &mut config.accounts {
752 if let Some(cred) = &account.credential {
753 if cred.needs_refresh() {
754 print!(" {} Refreshing '{}'… ", yellow("↻"), account.name);
755 std::io::stdout().flush().ok();
756 match tokio::time::timeout(
757 std::time::Duration::from_secs(10),
758 account.provider.refresh_token(cred),
759 ).await {
760 Ok(Ok(fresh)) => {
761 println!("{}", green("done"));
762 let mut store = CredentialsStore::load();
763 store.accounts.insert(account.name.clone(), fresh.clone());
764 store.save().ok();
765 account.credential = Some(fresh);
766 }
767 Ok(Err(e)) => println!("{}", yellow(&format!("failed ({})", e))),
768 Err(_) => println!("{}", yellow("timed out")),
769 }
770 }
771 }
772 }
773 let lp = log_path();
774 let log_level = if verbose { "debug" } else { config.server.log_level.as_str() };
775 crate::logging::prune_old_logs(&lp, 7);
776 let _log_guard = crate::logging::setup(&lp, log_level)?;
777 let col = 13usize;
778 for (p, addr) in listener_addrs(&config.accounts, &host, port) {
779 println!(" {} {} {}", dim(&pad("listening", col)), dim(&format!("[{p}]")), green_bold(&addr));
780 }
781 println!(" {} {}", dim(&pad("logs", col)), dim(&lp.display().to_string()));
782 println!();
783 let state = crate::state::StateStore::load(&crate::config::state_path());
784 write_pid();
785 serve_all_providers(config, state, &host, port).await?;
786 return Ok(());
787 }
788
789 let exe = std::env::current_exe().context("cannot locate current executable")?;
791 let mut cmd = std::process::Command::new(&exe);
792 cmd.arg("start").arg("--daemon");
793 if let Some(ref p) = config_override { cmd.args(["--config", &p.display().to_string()]); }
794 if let Some(ref h) = host_override { cmd.args(["--host", h]); }
795 if let Some(p) = port_override { cmd.args(["--port", &p.to_string()]); }
796 if verbose { cmd.arg("--verbose"); }
797 cmd.stdin(std::process::Stdio::null())
798 .stdout(std::process::Stdio::null())
799 .stderr(std::process::Stdio::null())
800 .spawn()
801 .context("failed to start proxy in background")?;
802
803 let ready = wait_for_health(&host, port, 8).await;
805
806 auto_write_shell_export(port);
808
809 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
810 let status_line = if ready {
811 format!("{} {} {}", green(DOT), green_bold("running"), cyan(&format!("http://{host}:{port}")))
812 } else {
813 format!("{} {} {}", yellow(DOT), yellow("starting"), dim(&format!("http://{host}:{port}")))
814 };
815 print_routing_header(&account_names, &[
816 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
817 status_line,
818 ]);
819
820 Ok(())
821}
822
823async fn cmd_stop() -> Result<()> {
828 let pid_p = pid_path();
829 let content = match std::fs::read_to_string(&pid_p) {
830 Ok(c) => c,
831 Err(_) => {
832 println!(" {} Proxy is not running.", dim("·"));
833 println!();
834 return Ok(());
835 }
836 };
837 let pid = match content.trim().parse::<u32>() {
838 Ok(p) => p,
839 Err(_) => {
840 let _ = std::fs::remove_file(&pid_p);
841 println!(" {} Proxy is not running.", dim("·"));
842 println!();
843 return Ok(());
844 }
845 };
846 if !is_shunt_pid(pid) {
847 let _ = std::fs::remove_file(&pid_p);
848 println!(" {} Proxy is not running.", dim("·"));
849 println!();
850 return Ok(());
851 }
852
853 unsafe { libc::kill(pid as i32, libc::SIGTERM) };
855
856 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
858 while std::time::Instant::now() < deadline {
859 std::thread::sleep(std::time::Duration::from_millis(100));
860 if !is_shunt_pid(pid) { break; }
861 }
862 if is_shunt_pid(pid) {
863 unsafe { libc::kill(pid as i32, libc::SIGKILL) };
864 std::thread::sleep(std::time::Duration::from_millis(200));
865 }
866
867 let _ = std::fs::remove_file(&pid_p);
868 println!(" {} Proxy stopped.", green(CHECK));
869 println!();
870 Ok(())
871}
872
873fn is_shunt_pid(pid: u32) -> bool {
874 let Ok(out) = std::process::Command::new("ps")
875 .args(["-p", &pid.to_string(), "-o", "comm="])
876 .output()
877 else { return false };
878 String::from_utf8_lossy(&out.stdout).trim().contains("shunt")
879}
880
881async fn cmd_restart(config_override: Option<PathBuf>) -> Result<()> {
886 cmd_stop().await?;
887 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
888 cmd_start(config_override, None, None, false, false, false).await
889}
890
891async fn cmd_logs(_config_override: Option<PathBuf>, follow: bool, lines: usize) -> Result<()> {
896 use std::io::{BufRead, BufReader, Write};
897
898 let log = log_path();
899 if !log.exists() {
900 println!(" {} No log file found.", dim("·"));
901 println!(" {} Start the proxy first: {}", dim("·"), cyan("shunt start"));
902 println!();
903 return Ok(());
904 }
905
906 let file = std::fs::File::open(&log)?;
907 let mut reader = BufReader::new(file);
908
909 let mut ring: std::collections::VecDeque<String> = std::collections::VecDeque::with_capacity(lines + 1);
912 let mut line = String::new();
913 while reader.read_line(&mut line)? > 0 {
914 if ring.len() >= lines {
915 ring.pop_front();
916 }
917 ring.push_back(std::mem::take(&mut line));
918 }
919 for l in &ring {
920 print!("{l}");
921 }
922 std::io::stdout().flush().ok();
923
924 if !follow {
925 return Ok(());
926 }
927
928 eprintln!("{}", dim("--- following (Ctrl+C to stop) ---"));
930 loop {
931 line.clear();
932 if reader.read_line(&mut line)? > 0 {
933 print!("{line}");
934 std::io::stdout().flush().ok();
935 } else {
936 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
937 }
938 }
939}
940
941async fn cmd_push(config_override: Option<PathBuf>) -> Result<()> {
946 use crate::sync::{encrypt_bundle, generate_code, push_to_relay, SyncBundle};
947
948 let config_p = config_override.clone().unwrap_or_else(config_path);
949 if !config_p.exists() {
950 bail!("No config found. Run `shunt setup` first.");
951 }
952
953 print_splash(&[
954 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
955 dim("Push credentials to relay").to_string(),
956 String::new(),
957 ]);
958
959 let config = crate::config::load_config(config_override.as_deref())?;
960 let relay_url = &config.server.relay_url;
961
962 let config_toml = std::fs::read_to_string(&config_p)?;
964 let store = crate::config::CredentialsStore::load();
965
966 if store.accounts.is_empty() {
967 bail!("No credentials found. Run `shunt setup` or `shunt add-account` first.");
968 }
969
970 let n = store.accounts.len();
971 let names: Vec<_> = store.accounts.keys().cloned().collect();
972 println!(" {} Encrypting {} account{}…",
973 dim("·"), bold(&n.to_string()),
974 if n == 1 { "" } else { "s" });
975
976 let bundle = SyncBundle { config_toml, accounts: store.accounts };
977 let code = generate_code();
978 let payload = encrypt_bundle(&bundle, &code)?;
979
980 print!(" {} Uploading to relay… ", dim("↑"));
981 use std::io::Write as _;
982 std::io::stdout().flush().ok();
983
984 push_to_relay(&code, &payload, relay_url).await?;
985 println!("{}", green("done"));
986
987 println!();
988 println!(" {} Transfer code:", green(CHECK));
989 println!();
990 println!(" {}", bold_white(&code));
991 println!();
992 println!(" {} Accounts: {}", dim("·"), dim(&names.join(", ")));
993 println!(" {} Expires in 24h — one-time use", dim("·"));
994 println!();
995 println!(" On the new device, run:");
996 println!(" {}", cyan(&format!("shunt login {code}")));
997 println!();
998
999 Ok(())
1000}
1001
1002async fn cmd_login(code: String) -> Result<()> {
1007 use crate::sync::{decrypt_bundle, pull_from_relay, validate_code};
1008
1009 validate_code(&code)?;
1010
1011 print_splash(&[
1012 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1013 dim("Login — applying credentials from relay").to_string(),
1014 String::new(),
1015 ]);
1016
1017 let relay_url = crate::config::load_config(None)
1019 .map(|c| c.server.relay_url.clone())
1020 .unwrap_or_else(|_| {
1021 std::env::var("SHUNT_RELAY_URL")
1022 .unwrap_or_else(|_| "https://relay.ramcharan.shop".into())
1023 });
1024
1025 print!(" {} Downloading from relay… ", dim("↓"));
1026 use std::io::Write as _;
1027 std::io::stdout().flush().ok();
1028
1029 let payload = pull_from_relay(&code, &relay_url).await?;
1030 println!("{}", green("done"));
1031
1032 print!(" {} Decrypting… ", dim("·"));
1033 std::io::stdout().flush().ok();
1034 let bundle = decrypt_bundle(&payload, &code)?;
1035 println!("{}", green("done"));
1036
1037 let config_p = config_path();
1038 let account_names: Vec<_> = bundle.accounts.keys().cloned().collect();
1039
1040 let config_toml: String = bundle.config_toml
1043 .lines()
1044 .filter(|l| !l.trim_start().starts_with("remote_key"))
1045 .map(|l| if l.trim() == "host = \"0.0.0.0\"" { "host = \"127.0.0.1\"" } else { l })
1046 .collect::<Vec<_>>()
1047 .join("\n") + "\n";
1048
1049 if config_p.exists() {
1051 use std::io::{self, Write};
1052 print!(" {} Config already exists — overwrite? [y/N]: ", yellow("!"));
1053 io::stdout().flush()?;
1054 let mut buf = String::new();
1055 io::stdin().read_line(&mut buf)?;
1056 if !matches!(buf.trim().to_lowercase().as_str(), "y" | "yes") {
1057 println!(" {} Cancelled.", dim("·"));
1058 println!();
1059 return Ok(());
1060 }
1061 }
1062
1063 if let Some(parent) = config_p.parent() {
1065 std::fs::create_dir_all(parent)?;
1066 }
1067 std::fs::write(&config_p, &config_toml)?;
1068 #[cfg(unix)]
1069 {
1070 use std::os::unix::fs::PermissionsExt;
1071 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1072 }
1073 println!(" {} Config written", green(CHECK));
1074
1075 let mut store = crate::config::CredentialsStore::load();
1077 for (name, cred) in bundle.accounts {
1078 store.accounts.insert(name, cred);
1079 }
1080 store.save()?;
1081 println!(" {} Credentials saved ({} accounts: {})",
1082 green(CHECK),
1083 account_names.len(),
1084 account_names.join(", "));
1085
1086 offer_shell_export()?;
1087
1088 println!();
1089 println!(" {} Run {} to start.", green(CHECK), cyan("shunt start"));
1090 println!();
1091
1092 Ok(())
1093}
1094
1095fn cmd_completions(shell: clap_complete::Shell) {
1100 use clap::CommandFactory;
1101 clap_complete::generate(shell, &mut Cli::command(), "shunt", &mut std::io::stdout());
1102}
1103
1104async fn cmd_setup_auto(config_override: Option<PathBuf>) -> Result<()> {
1108 let config_p = config_override.clone().unwrap_or_else(config_path);
1109
1110 let mut cred = match crate::oauth::read_claude_credentials() {
1111 Some(mut c) => {
1112 if c.needs_refresh() {
1113 if let Ok(fresh) = refresh_token(&c).await { c = fresh; }
1114 }
1115 c
1116 }
1117 None => {
1118 println!(" {} No Claude Code session found — opening browser for login…", yellow("·"));
1120 crate::oauth::run_oauth_flow().await?
1121 }
1122 };
1123
1124 let plan = crate::oauth::read_claude_session_info()
1125 .map(|s| s.plan)
1126 .unwrap_or_else(|| "pro".to_string());
1127
1128 cred.email = crate::oauth::fetch_account_email(&cred.access_token).await;
1129
1130 if let Some(parent) = config_p.parent() { std::fs::create_dir_all(parent)?; }
1131 std::fs::write(&config_p, crate::config::config_template(&[("main", &plan)]))?;
1132 #[cfg(unix)] {
1133 use std::os::unix::fs::PermissionsExt;
1134 std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))?;
1135 }
1136
1137 let mut store = CredentialsStore::default();
1138 store.accounts.insert("main".into(), cred);
1139 store.save()?;
1140
1141 Ok(())
1142}
1143
1144async fn wait_for_health(host: &str, port: u16, timeout_secs: u64) -> bool {
1145 let url = format!("http://{host}:{port}/health");
1146 let deadline = tokio::time::Instant::now()
1147 + std::time::Duration::from_secs(timeout_secs);
1148 while tokio::time::Instant::now() < deadline {
1149 if reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false) {
1150 return true;
1151 }
1152 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1153 }
1154 false
1155}
1156
1157fn auto_write_shell_export(port: u16) {
1158 use std::io::Write;
1159 let line = format!("export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
1160 let Some(profile) = detect_shell_profile() else { return };
1161
1162 if profile.exists() {
1163 if let Ok(contents) = std::fs::read_to_string(&profile) {
1164 if contents.contains(&line) {
1165 return;
1167 }
1168 if contents.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1169 let updated: String = contents
1171 .lines()
1172 .map(|l| {
1173 if l.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:") {
1174 line.as_str()
1175 } else {
1176 l
1177 }
1178 })
1179 .collect::<Vec<_>>()
1180 .join("\n")
1181 + "\n";
1182 if std::fs::write(&profile, updated).is_ok() {
1183 println!(" {} {} updated to port {} → {}",
1184 green(CHECK), cyan("ANTHROPIC_BASE_URL"), port,
1185 dim(&profile.display().to_string()));
1186 }
1187 return;
1188 }
1189 if contents.contains("ANTHROPIC_BASE_URL") {
1190 return;
1192 }
1193 }
1194 }
1195
1196 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&profile) {
1197 writeln!(f, "\n# Added by shunt").ok();
1198 writeln!(f, "{line}").ok();
1199 println!(" {} {} → {}",
1200 green(CHECK), cyan("ANTHROPIC_BASE_URL"),
1201 dim(&profile.display().to_string()));
1202 }
1203}
1204
1205async fn cmd_status(config_override: Option<PathBuf>) -> Result<()> {
1210 let mut config = crate::config::load_config(config_override.as_deref())?;
1211 let _primary_url = format!("http://{}:{}", config.server.host, config.server.port);
1212
1213 let provider_urls = listener_addrs(&config.accounts, &config.server.host, config.server.port);
1216 let mut live_by_provider: std::collections::HashMap<String, serde_json::Value> =
1217 std::collections::HashMap::new();
1218 for (label, url) in &provider_urls {
1219 if let Some(v) = reqwest::get(format!("{url}/status")).await.ok()
1220 .and_then(|r| futures_executor_hack(r))
1221 {
1222 live_by_provider.insert(label.clone(), v);
1223 }
1224 }
1225
1226 let live: Option<&serde_json::Value> = live_by_provider
1228 .get(&crate::provider::Provider::Anthropic.to_string())
1229 .or_else(|| live_by_provider.values().next());
1230
1231 let mut store_dirty = false;
1234 let mut store = CredentialsStore::load();
1235 for acc in &mut config.accounts {
1236 if acc.credential.as_ref().map(|c| c.email.is_none()).unwrap_or(false) {
1237 let token = acc.credential.as_ref().map(|c| c.access_token.clone()).unwrap_or_default();
1238 if let Some(email) = crate::oauth::fetch_account_email(&token).await {
1239 if let Some(c) = acc.credential.as_mut() { c.email = Some(email.clone()); }
1240 if let Some(stored) = store.accounts.get_mut(&acc.name) {
1241 stored.email = Some(email);
1242 store_dirty = true;
1243 }
1244 }
1245 }
1246 }
1247 if store_dirty {
1248 store.save().ok();
1249 }
1250
1251 let addr_str = if !live_by_provider.is_empty() {
1253 let parts: Vec<String> = provider_urls.iter()
1254 .filter(|(label, _)| live_by_provider.contains_key(label.as_str()))
1255 .map(|(_, url)| {
1256 let port = url.rsplit(':').next().unwrap_or("?");
1257 cyan(&format!(":{port}"))
1258 })
1259 .collect();
1260 parts.join(&dim(" · "))
1261 } else {
1262 String::new()
1263 };
1264
1265 let proxy_line = if live.is_some() {
1266 format!("{} {} {}", green(DOT), green_bold("running"), addr_str)
1267 } else {
1268 let log_hint = if log_path().exists() {
1269 format!(" {} {}", dim("·"), dim("shunt logs for details"))
1270 } else {
1271 String::new()
1272 };
1273 format!("{} {} {}{}", dim(EMPTY), dim("stopped"), dim("shunt start"), log_hint)
1274 };
1275
1276 let account_names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1277 let savings_line: Option<String> = live.and_then(|v| {
1279 let s = v.get("savings")?;
1280 let today_in = s["today_input"].as_u64().unwrap_or(0);
1281 let today_out = s["today_output"].as_u64().unwrap_or(0);
1282 let today_cost = s["today_cost_usd"].as_f64().unwrap_or(0.0);
1283 let all_cost = s["all_time_cost_usd"].as_f64().unwrap_or(0.0);
1284 if today_in + today_out == 0 && all_cost == 0.0 { return None; }
1285 let today_tok = crate::term::fmt_tokens(today_in + today_out);
1286 let cost_str = crate::pricing::fmt_cost(today_cost);
1287 let all_str = crate::pricing::fmt_cost(all_cost);
1288 Some(format!("{} today {} {} {} all time {}",
1289 dim("·"), dim(&today_tok), dim(&cost_str), dim("·"), dim(&all_str)))
1290 });
1291
1292 print_routing_header(&account_names, &[
1293 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
1294 proxy_line,
1295 ]);
1296
1297 if let Some(ref line) = savings_line {
1298 println!(" {line}");
1299 println!();
1300 }
1301
1302 let pinned_account = live.and_then(|v| v["pinned"].as_str()).map(|s| s.to_owned());
1303 let last_used_account = live.and_then(|v| v["last_used"].as_str()).map(|s| s.to_owned());
1304
1305 if let Some(ref pinned) = pinned_account {
1307 println!(" {} pinned to {}",
1308 yellow(DIAMOND), bold(pinned));
1309 println!(" {} run {} to restore auto routing",
1310 dim("·"), cyan("shunt use auto"));
1311 println!();
1312 }
1313
1314 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()).unwrap_or(0);
1315
1316 for acc in &config.accounts {
1317 let live_acc = live_by_provider.get(&acc.provider.to_string())
1318 .and_then(|v| v["accounts"].as_array())
1319 .and_then(|arr| arr.iter().find(|a| a["name"] == acc.name));
1320
1321 let status = live_acc.and_then(|a| a["status"].as_str()).unwrap_or("offline");
1322
1323 let (status_icon, status_text): (String, String) = match status {
1324 "available" => (green(CHECK), green("available")),
1325 "cooling" => (yellow("↻"), yellow("cooling")),
1326 "disabled" => (red(CROSS), red("disabled")),
1327 "reauth_required" => (red(CROSS), red("session expired")),
1328 _ => match &acc.credential {
1329 None => (red(CROSS), red("no credential")),
1330 Some(c) if c.needs_refresh() => (yellow(CROSS), yellow("token expired")),
1331 _ => (dim(EMPTY), dim("offline")),
1332 },
1333 };
1334
1335 let plan_label = if acc.provider == crate::provider::Provider::OpenAI {
1336 match acc.plan_type.to_lowercase().as_str() {
1337 "plus" => "ChatGPT Plus",
1338 "pro" => "ChatGPT Pro",
1339 "team" => "ChatGPT Team",
1340 _ => "ChatGPT",
1341 }
1342 } else {
1343 match acc.plan_type.to_lowercase().as_str() {
1344 "max" | "claude_max" => "Claude Max",
1345 "team" => "Claude Team",
1346 _ => "Claude Pro",
1347 }
1348 };
1349 let email_str = acc.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1350
1351 let is_pinned = pinned_account.as_deref() == Some(&acc.name);
1353 let is_last = !is_pinned && last_used_account.as_deref() == Some(&acc.name);
1354 let (routing_tag, tag_vis_len): (String, usize) = if is_pinned {
1355 (format!(" {}", yellow("pinned")), 8)
1356 } else if is_last {
1357 (format!(" {}", green("active")), 8)
1358 } else {
1359 (String::new(), 0)
1360 };
1361
1362 println!("{}", card_header(&acc.name, &green_bold(&acc.name), &routing_tag, tag_vis_len, plan_label));
1364
1365 let is_openai = acc.provider == crate::provider::Provider::OpenAI;
1367 let provider_badge = if is_openai { format!(" {} {}", dim("·"), dim("openai")) } else { String::new() };
1368 if !email_str.is_empty() {
1369 println!("{}", card_row(&format!("{}{}", dim(email_str), provider_badge)));
1370 } else if is_openai {
1371 println!("{}", card_row(&dim("openai")));
1372 }
1373
1374 println!();
1375
1376 println!("{}", card_row(&format!("{} {}", status_icon, status_text)));
1378
1379 if let Some(rl) = live_acc.and_then(|a| a["rate_limit"].as_object()) {
1381 let util_5h = rl.get("utilization_5h").and_then(|v| v.as_f64());
1382 let reset_5h = rl.get("reset_5h").and_then(|v| v.as_u64());
1383 let status_5h = rl.get("status_5h").and_then(|v| v.as_str()).unwrap_or("allowed");
1384 let util_7d = rl.get("utilization_7d").and_then(|v| v.as_f64());
1385 let reset_7d = rl.get("reset_7d").and_then(|v| v.as_u64());
1386 let status_7d = rl.get("status_7d").and_then(|v| v.as_str()).unwrap_or("allowed");
1387
1388 let window_row = |label: &str, util: Option<f64>, reset: Option<u64>, wstatus: &str| {
1389 if reset.map(|t| t <= now_secs).unwrap_or(false) {
1390 let ago = reset.map(|t| format!(
1391 " {} ago", term::fmt_duration_ms(now_secs.saturating_sub(t) * 1000)
1392 )).unwrap_or_default();
1393 println!("{}", card_row(&format!(
1394 "{} {} {}{}",
1395 dim(label), green(&"─".repeat(20)), green("fresh"), dim(&ago)
1396 )));
1397 } else if let Some(u) = util {
1398 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1399 let bar = util_bar(u, 20);
1400 let reset_str = reset.and_then(|t| secs_until(t))
1401 .map(|s| format!(" · resets in {}", term::fmt_duration_ms(s * 1000)))
1402 .unwrap_or_default();
1403 let pct = if wstatus == "exhausted" {
1404 red("exhausted")
1405 } else {
1406 format!("{}% left", bold(&rem.to_string()))
1407 };
1408 println!("{}", card_row(&format!(
1409 "{} {} {}{}",
1410 dim(label), bar, pct, dim(&reset_str)
1411 )));
1412 }
1413 };
1414
1415 if util_5h.is_some() || reset_5h.is_some() {
1416 window_row("5h", util_5h, reset_5h, status_5h);
1417 }
1418 if util_7d.is_some() || reset_7d.is_some() {
1419 window_row("7d", util_7d, reset_7d, status_7d);
1420 }
1421 } else if acc.credential.is_none() {
1422 println!("{}", card_row(&format!("{} run {}",
1423 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1424 } else if status == "reauth_required" {
1425 println!("{}", card_row(&format!("{} run {}",
1426 dim("·"), cyan(&format!("shunt add-account {}", acc.name)))));
1427 } else if live.is_some() && live_acc.is_some() {
1428 if acc.provider == crate::provider::Provider::Anthropic {
1429 println!("{}", card_row(&dim("· quota data will appear after first request")));
1430 } else {
1431 println!("{}", card_row(&dim("· quota tracking unavailable (OpenAI doesn't report utilization)")));
1432 }
1433 }
1434
1435 println!();
1437 println!("{}", card_sep());
1438 println!();
1439 }
1440
1441 Ok(())
1442}
1443
1444async fn cmd_use(config_override: Option<PathBuf>, account: Option<String>) -> Result<()> {
1449 let config = crate::config::load_config(config_override.as_deref())?;
1450 let use_url = format!("http://{}:{}/use", config.server.host, config.server.port);
1451
1452 let live: Option<serde_json::Value> = reqwest::get(
1454 &format!("http://{}:{}/status", config.server.host, config.server.port)
1455 ).await.ok().and_then(|r| futures_executor_hack(r));
1456
1457 let current_pinned = live.as_ref()
1458 .and_then(|v| v["pinned"].as_str())
1459 .map(|s| s.to_owned());
1460
1461 let mut items: Vec<term::SelectItem> = config.accounts.iter().map(|a| {
1463 let live_acc = live.as_ref()
1464 .and_then(|v| v["accounts"].as_array())
1465 .and_then(|arr| arr.iter().find(|x| x["name"] == a.name));
1466
1467 let status = live_acc.and_then(|x| x["status"].as_str()).unwrap_or("offline");
1468 let util = live_acc.and_then(|x| x["rate_limit"]["utilization_5h"].as_f64());
1469 let is_pinned = current_pinned.as_deref() == Some(&a.name);
1470
1471 let status_str = match status {
1472 "reauth_required" => red("session expired"),
1473 "disabled" => red("disabled"),
1474 "cooling" => yellow("cooling"),
1475 "available" => {
1476 match util {
1477 Some(u) => {
1478 let rem = 100u64.saturating_sub((u * 100.0) as u64);
1479 green(&format!("{}% remaining", rem))
1480 }
1481 None => dim("fresh").to_string(),
1482 }
1483 }
1484 _ => dim("offline").to_string(),
1485 };
1486
1487 let email = a.credential.as_ref().and_then(|c| c.email.as_deref()).unwrap_or("");
1488 let pin = if is_pinned { format!(" {}", yellow("pinned")) } else { String::new() };
1489
1490 term::SelectItem {
1491 label: format!("{} {} {}{}", bold(&pad(&a.name, 12)), dim(&pad(email, 32)), status_str, pin),
1492 value: a.name.clone(),
1493 }
1494 }).collect();
1495
1496 let auto_marker = if current_pinned.is_none() { format!(" {}", yellow("active")) } else { String::new() };
1497 items.push(term::SelectItem {
1498 label: format!("{} {}{}", bold(&pad("auto", 12)), dim("least-utilization routing"), auto_marker),
1499 value: "auto".to_owned(),
1500 });
1501
1502 let initial = current_pinned.as_ref()
1504 .and_then(|p| items.iter().position(|it| &it.value == p))
1505 .unwrap_or(items.len() - 1);
1506
1507 let chosen = if let Some(name) = account {
1509 name
1510 } else {
1511 match term::select("Route traffic to:", &items, initial) {
1512 Some(v) => v,
1513 None => return Ok(()), }
1515 };
1516
1517 let is_auto = chosen == "auto";
1519 if !is_auto && !config.accounts.iter().any(|a| a.name == chosen) {
1520 let names: Vec<_> = config.accounts.iter().map(|a| a.name.as_str()).collect();
1521 anyhow::bail!("Unknown account '{}'. Available: {}", chosen, names.join(", "));
1522 }
1523
1524 let client = reqwest::Client::new();
1525 let resp = client
1526 .post(&use_url)
1527 .json(&serde_json::json!({ "account": chosen }))
1528 .send()
1529 .await;
1530
1531 match resp {
1532 Ok(r) if r.status().is_success() => {
1533 if is_auto {
1534 println!(" {} Automatic routing restored", green(CHECK));
1535 } else {
1536 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen), dim("shunt use auto to restore"));
1537 }
1538 println!();
1539 }
1540 Ok(r) => {
1541 let body = r.text().await.unwrap_or_default();
1542 anyhow::bail!("Proxy returned error: {body}");
1543 }
1544 Err(_) => {
1545 write_pinned_to_state(if is_auto { None } else { Some(chosen.clone()) });
1548 if is_auto {
1549 println!(" {} Automatic routing saved · {}", green(CHECK),
1550 dim("applies on next shunt start"));
1551 } else {
1552 println!(" {} Pinned to {} · {}", green(CHECK), bold(&chosen),
1553 dim("applies on next shunt start"));
1554 }
1555 println!();
1556 }
1557 }
1558 Ok(())
1559}
1560
1561fn write_pinned_to_state(account: Option<String>) {
1563 let path = crate::config::state_path();
1564 let mut data: serde_json::Value = path.exists()
1565 .then(|| std::fs::read_to_string(&path).ok())
1566 .flatten()
1567 .and_then(|t| serde_json::from_str(&t).ok())
1568 .unwrap_or_else(|| serde_json::json!({}));
1569 data["pinned_account"] = match account {
1570 Some(a) => serde_json::Value::String(a),
1571 None => serde_json::Value::Null,
1572 };
1573 if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); }
1574 let tmp = path.with_extension("tmp");
1575 if let Ok(text) = serde_json::to_string_pretty(&data) {
1576 let _ = std::fs::write(&tmp, text);
1577 let _ = std::fs::rename(&tmp, &path);
1578 }
1579}
1580
1581fn futures_executor_hack(resp: reqwest::Response) -> Option<serde_json::Value> {
1583 tokio::task::block_in_place(|| {
1584 tokio::runtime::Handle::current().block_on(async {
1585 resp.json::<serde_json::Value>().await.ok()
1586 })
1587 })
1588}
1589
1590fn print_splash(info: &[String]) {
1596 println!();
1597 let title = info.get(0).map(|s| s.as_str()).unwrap_or("");
1598 let subtitle = info.get(1).map(|s| s.as_str()).unwrap_or("");
1599
1600 println!(" {} {}", brand_green(DIAMOND), title);
1601 if !subtitle.is_empty() {
1602 println!(" {}", subtitle);
1603 }
1604 let w = strip_ansi(title).chars().count()
1605 .max(strip_ansi(subtitle).chars().count())
1606 .max(18) + 3;
1607 println!(" {}", dim(&"─".repeat(w)));
1608 println!();
1609}
1610
1611const CARD_W: usize = 58;
1617
1618fn card_header(name: &str, name_c: &str, routing_tag: &str, tag_vis: usize, plan: &str) -> String {
1620 let left_vis = 5 + name.len() + tag_vis;
1622 let gap = CARD_W.saturating_sub(left_vis + plan.len());
1623 format!(" {} {}{}{}{}", brand_green(DIAMOND), name_c, routing_tag, " ".repeat(gap), dim(plan))
1624}
1625
1626fn card_row(content: &str) -> String {
1628 format!(" {content}")
1629}
1630
1631fn card_sep() -> String {
1633 format!(" {}", dim(&"─".repeat(CARD_W - 2)))
1634}
1635
1636fn print_routing_header(account_names: &[&str], info: &[String]) {
1643 println!();
1644 let n = account_names.len();
1645 let name_w = account_names.iter().map(|s| s.len()).max().unwrap_or(4);
1646 let info0 = info.get(0).map(|s| s.as_str()).unwrap_or("");
1647 let info1 = info.get(1).map(|s| s.as_str()).unwrap_or("");
1648
1649 match n {
1650 0 => {
1651 println!(" {} {}", brand_green(DIAMOND), info0);
1653 if !info1.is_empty() {
1654 println!(" {}", info1);
1655 }
1656 }
1657 1 => {
1658 let indent = name_w + 8; println!(" {} {} {}", green_bold(account_names[0]), dark_green("─→"), info0);
1661 if !info1.is_empty() {
1662 println!(" {}{}", " ".repeat(indent), info1);
1663 }
1664 }
1665 2 => {
1666 println!(" {} {} {} {}",
1669 green_bold(&pad(account_names[0], name_w)),
1670 dark_green("─┐"), dark_green("→"), info0);
1671 println!(" {} {} {}",
1672 green_bold(&pad(account_names[1], name_w)),
1673 dark_green("─┘"), info1);
1674 }
1675 3 => {
1676 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1680 println!(" {} {} {}",
1681 green_bold(&pad(account_names[1], name_w)),
1682 dark_green("─┼─→"), info0);
1683 println!(" {} {} {}",
1684 green_bold(&pad(account_names[2], name_w)),
1685 dark_green("─┘"), info1);
1686 }
1687 _ => {
1688 let more = dim(&pad(&format!("+ {} more", n - 2), name_w));
1692 println!(" {} {}", green_bold(&pad(account_names[0], name_w)), dark_green("─┐"));
1693 println!(" {} {} {}", more, dark_green("─┼─→"), info0);
1694 println!(" {} {} {}",
1695 green_bold(&pad(account_names[n - 1], name_w)),
1696 dark_green("─┘"), info1);
1697 }
1698 }
1699
1700 println!();
1701}
1702
1703fn util_bar(util: f64, width: usize) -> String {
1706 let used = (util.clamp(0.0, 1.0) * width as f64).round() as usize;
1707 let free = width.saturating_sub(used);
1708 let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
1710 let pct = (util * 100.0) as u64;
1711 if pct < 50 { green(&bar) } else if pct < 80 { yellow(&bar) } else { red(&bar) }
1712}
1713
1714fn secs_until(epoch_secs: u64) -> Option<u64> {
1716 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
1717 epoch_secs.checked_sub(now).filter(|&s| s > 0)
1718}
1719
1720fn listener_addrs(
1727 accounts: &[crate::config::AccountConfig],
1728 host: &str,
1729 primary_port: u16,
1730) -> Vec<(String, String)> {
1731 use crate::provider::Provider;
1732 use std::collections::BTreeSet;
1733
1734 let providers: BTreeSet<String> = accounts.iter()
1735 .map(|a| a.provider.to_string())
1736 .collect();
1737
1738 providers.into_iter().map(|p| {
1739 let port = match Provider::from_str(&p) {
1740 Provider::Anthropic => primary_port,
1741 other => other.default_port(),
1742 };
1743 (p.clone(), format!("http://{host}:{port}"))
1744 }).collect()
1745}
1746
1747async fn serve_all_providers(
1751 config: crate::config::Config,
1752 state: crate::state::StateStore,
1753 host: &str,
1754 primary_port: u16,
1755) -> anyhow::Result<()> {
1756 use crate::config::{Config, ServerConfig};
1757 use crate::provider::Provider;
1758 use std::collections::HashMap;
1759
1760 let mut by_provider: HashMap<String, Vec<crate::config::AccountConfig>> = HashMap::new();
1762 for account in config.accounts {
1763 by_provider.entry(account.provider.to_string()).or_default().push(account);
1764 }
1765
1766 let mut handles = Vec::new();
1767
1768 for (provider_str, accounts) in by_provider {
1769 let provider = Provider::from_str(&provider_str);
1770 let port = match provider {
1771 Provider::Anthropic => primary_port,
1772 ref other => other.default_port(),
1773 };
1774
1775 let provider_config = Config {
1776 accounts,
1777 server: ServerConfig {
1778 host: host.to_owned(),
1779 port,
1780 upstream_url: provider.default_upstream_url().to_owned(),
1781 ..config.server.clone()
1782 },
1783 config_file: config.config_file.clone(),
1784 };
1785
1786 let anthropic_url = if provider == Provider::OpenAI {
1787 Some(format!("http://{}:{}", host, primary_port))
1788 } else {
1789 None
1790 };
1791 let (app, live_creds) = crate::proxy::create_app_with_state(provider_config.clone(), state.clone(), anthropic_url)?;
1792 let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
1793 .await
1794 .with_context(|| format!("cannot bind {host}:{port} for {provider_str} proxy"))?;
1795
1796 let cfg_arc = std::sync::Arc::new(provider_config);
1797 tokio::spawn(crate::proxy::prefetch_rate_limits(cfg_arc.clone(), state.clone()));
1798 tokio::spawn(crate::proxy::openai_token_refresh_loop(cfg_arc.clone(), state.clone(), live_creds.clone()));
1799 tokio::spawn(crate::proxy::cooldown_watcher(cfg_arc.clone(), state.clone(), live_creds.clone()));
1800 tokio::spawn(crate::proxy::recovery_watcher(cfg_arc, state.clone(), live_creds));
1801 handles.push(tokio::spawn(async move {
1802 axum::serve(listener, app).await
1803 }));
1804 }
1805
1806 if handles.is_empty() {
1807 return Ok(());
1808 }
1809
1810 let (result, _idx, _rest) = futures_util::future::select_all(handles).await;
1812 result??;
1813 Ok(())
1814}
1815
1816fn write_pid() {
1817 let p = pid_path();
1818 if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); }
1819 let _ = std::fs::write(&p, std::process::id().to_string());
1820}
1821
1822fn port_pids(port: u16) -> Vec<u32> {
1824 let out = std::process::Command::new("lsof")
1825 .args(["-ti", &format!(":{port}")])
1826 .output();
1827 let Ok(out) = out else { return vec![] };
1828 String::from_utf8_lossy(&out.stdout)
1829 .split_whitespace()
1830 .filter_map(|s| s.parse().ok())
1831 .collect()
1832}
1833
1834#[allow(dead_code)]
1835fn kill_port(port: u16) -> bool {
1836 let pids = port_pids(port);
1837 let mut any = false;
1838 for pid in pids {
1839 if std::process::Command::new("kill").arg(pid.to_string()).status().map(|s| s.success()).unwrap_or(false) {
1840 any = true;
1841 }
1842 }
1843 any
1844}
1845
1846fn pad(s: &str, width: usize) -> String {
1848 use unicode_width::UnicodeWidthStr;
1849 let visible_width = UnicodeWidthStr::width(strip_ansi(s).as_str());
1850 if visible_width >= width {
1851 s.to_owned()
1852 } else {
1853 format!("{s}{}", " ".repeat(width - visible_width))
1854 }
1855}
1856
1857fn strip_ansi(s: &str) -> String {
1858 let mut out = String::with_capacity(s.len());
1859 let mut chars = s.chars().peekable();
1860 while let Some(c) = chars.next() {
1861 if c == '\x1b' {
1862 if chars.peek() == Some(&'[') {
1863 chars.next();
1864 while let Some(&next) = chars.peek() {
1865 chars.next();
1866 if next.is_ascii_alphabetic() { break; }
1867 }
1868 }
1869 } else {
1870 out.push(c);
1871 }
1872 }
1873 out
1874}
1875
1876async fn cmd_monitor(config_override: Option<PathBuf>) -> Result<()> {
1881 let config = crate::config::load_config(config_override.as_deref())?;
1882 let base_url = format!("http://{}:{}", config.server.host, config.server.port);
1883
1884 if reqwest::get(format!("{base_url}/health")).await.is_err() {
1886 println!();
1887 println!(" {} Proxy is not running.", red(CROSS));
1888 println!(" {} Start it first with {}.", dim("·"), cyan("shunt start"));
1889 println!();
1890 return Ok(());
1891 }
1892
1893 crate::monitor::run_monitor(&base_url).await
1894}
1895
1896async fn cmd_remote(code: Option<String>) -> Result<()> {
1901 let (relay_url, local_url) = if code.is_none() {
1903 let config = crate::config::load_config(None)?;
1904 let local = format!("http://{}:{}", config.server.host, config.server.port);
1905 let relay = config.server.relay_url.clone();
1906 (Some(relay), local)
1907 } else {
1908 let relay_url = std::env::var("SHUNT_RELAY_URL").ok();
1909 (relay_url, String::new())
1910 };
1911 crate::remote::run_remote(code, relay_url, local_url).await
1912}
1913
1914async fn cmd_update() -> Result<()> {
1918 const REPO: &str = "ramc10/shunt";
1919 let current = env!("CARGO_PKG_VERSION");
1920
1921 print_splash(&[
1922 format!("{} {}", brand_green("shunt"), dim(&format!("v{current}"))),
1923 dim("Checking for updates…").to_string(),
1924 String::new(),
1925 ]);
1926
1927 let client = reqwest::Client::builder()
1929 .user_agent("shunt-updater")
1930 .connect_timeout(std::time::Duration::from_secs(10))
1931 .timeout(std::time::Duration::from_secs(120))
1932 .build()?;
1933
1934 let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest");
1935 let resp = client.get(&api_url).send().await
1936 .context("Failed to reach GitHub API")?;
1937
1938 if !resp.status().is_success() {
1939 bail!("GitHub API returned {}", resp.status());
1940 }
1941
1942 let json: serde_json::Value = resp.json().await?;
1943 let latest_tag = json["tag_name"].as_str().context("Missing tag_name in release")?;
1944 let latest = latest_tag.trim_start_matches('v');
1945
1946 if latest == current {
1947 println!(" {} Already up to date ({})", green(CHECK), bold(&format!("v{current}")));
1948 println!();
1949 return Ok(());
1950 }
1951
1952 println!(" {} Update available: {} → {}", green("↑"),
1953 dim(&format!("v{current}")), bold_white(&format!("v{latest}")));
1954 println!();
1955
1956 let target = detect_update_target()?;
1958 let archive_name = format!("shunt-v{latest}-{target}.tar.gz");
1959 let url = format!(
1960 "https://github.com/{REPO}/releases/download/v{latest}/{archive_name}"
1961 );
1962
1963 print!(" {} Downloading {}… ", dim("↓"), dim(&archive_name));
1964 use std::io::Write as _;
1965 std::io::stdout().flush().ok();
1966
1967 let resp = client.get(&url).send().await
1968 .context("Download request failed")?;
1969
1970 if !resp.status().is_success() {
1971 bail!("Download failed: HTTP {} for {url}", resp.status());
1972 }
1973
1974 let bytes = resp.bytes().await
1975 .context("Failed to read download")?;
1976
1977 if bytes.len() < 2 || bytes[0] != 0x1f || bytes[1] != 0x8b {
1979 bail!(
1980 "Downloaded file does not look like a gzip archive ({} bytes, first bytes: {:02x?})",
1981 bytes.len(), &bytes[..bytes.len().min(4)]
1982 );
1983 }
1984
1985 println!("{}", green("done"));
1986
1987 let exe_path = std::env::current_exe().context("Cannot locate current executable")?;
1989 let tmp_path = exe_path.with_extension("tmp");
1990
1991 extract_binary_from_tarball(&bytes, &tmp_path)
1992 .context("Failed to extract binary from archive")?;
1993
1994 #[cfg(unix)]
1996 {
1997 use std::os::unix::fs::PermissionsExt;
1998 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
1999 }
2000 std::fs::rename(&tmp_path, &exe_path)
2001 .context("Failed to replace binary (try running with sudo?)")?;
2002
2003 #[cfg(target_os = "macos")]
2005 {
2006 let p = exe_path.display().to_string();
2007 std::process::Command::new("xattr").args(["-d", "com.apple.quarantine", &p]).status().ok();
2008 std::process::Command::new("codesign").args(["--force", "--deep", "--sign", "-", &p]).status().ok();
2009 }
2010
2011 println!(" {} Updated to {}", green(CHECK), bold_white(&format!("v{latest}")));
2012 println!();
2013 Ok(())
2014}
2015
2016fn detect_update_target() -> Result<&'static str> {
2017 match (std::env::consts::OS, std::env::consts::ARCH) {
2018 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
2019 ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
2020 ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"),
2021 (os, arch) => bail!("No pre-built binary for {os}/{arch}. Build from source: cargo install shunt-proxy"),
2022 }
2023}
2024
2025fn extract_binary_from_tarball(data: &[u8], dest: &std::path::Path) -> Result<()> {
2026 let gz = flate2::read::GzDecoder::new(data);
2027 let mut archive = tar::Archive::new(gz);
2028 for entry in archive.entries()? {
2029 let mut entry = entry?;
2030 let path = entry.path()?;
2031 if path.file_name().and_then(|n| n.to_str()) == Some("shunt") {
2032 let mut out = std::fs::File::create(dest)?;
2033 std::io::copy(&mut entry, &mut out)?;
2034 return Ok(());
2035 }
2036 }
2037 bail!("Binary 'shunt' not found in archive")
2038}
2039
2040async fn cmd_share(config_override: Option<PathBuf>, tunnel: bool, stop: bool) -> Result<()> {
2045 let config_p = config_override.unwrap_or_else(config_path);
2046 if !config_p.exists() {
2047 bail!("No config found. Run `shunt setup` first.");
2048 }
2049
2050 let mut text = std::fs::read_to_string(&config_p)?;
2051
2052 if stop {
2053 text = text.lines()
2054 .filter(|l| !l.trim_start().starts_with("remote_key"))
2055 .collect::<Vec<_>>()
2056 .join("\n");
2057 if !text.ends_with('\n') { text.push('\n'); }
2058 text = text.replace("host = \"0.0.0.0\"", "host = \"127.0.0.1\"");
2059 std::fs::write(&config_p, &text)?;
2060
2061 print_splash(&[
2062 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2063 dim("Remote sharing disabled").to_string(),
2064 String::new(),
2065 ]);
2066 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2067 println!();
2068 return Ok(());
2069 }
2070
2071 let key = match extract_remote_key(&text) {
2073 Some(k) => k,
2074 None => {
2075 let k = generate_remote_key();
2076 text = insert_into_server_section(&text, &format!("remote_key = \"{k}\""));
2077 k
2078 }
2079 };
2080
2081 if text.contains("host = \"127.0.0.1\"") {
2083 text = text.replace("host = \"127.0.0.1\"", "host = \"0.0.0.0\"");
2084 }
2085
2086 std::fs::write(&config_p, &text)?;
2087
2088 let port = crate::config::load_config(Some(&config_p))
2089 .map(|c| c.server.port)
2090 .unwrap_or(8082);
2091
2092 if tunnel {
2093 print_splash(&[
2095 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2096 dim("Starting Cloudflare tunnel…").to_string(),
2097 String::new(),
2098 ]);
2099
2100 println!(" {} Make sure the proxy is running: {}", dim("·"), cyan("shunt start"));
2101 println!();
2102
2103 let url = start_cloudflare_tunnel(port)?;
2104
2105 println!(" {} Set on the remote device:\n", green(CHECK));
2106 println!(" {}{}",
2107 dim("export ANTHROPIC_BASE_URL="),
2108 cyan(&url),
2109 );
2110 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
2111 println!();
2112 println!(" {} Tunnel is active — keep this terminal open.", dim("·"));
2113 println!(" {} Press Ctrl+C to stop.", dim("·"));
2114 println!();
2115
2116 tokio::signal::ctrl_c().await.ok();
2118 println!("\n {} Tunnel closed.", dim("·"));
2119 } else {
2120 let ip = local_ip().unwrap_or_else(|| "<your-ip>".to_string());
2121
2122 print_splash(&[
2123 format!("{} {}", brand_green("shunt"), dim(&format!("v{}", env!("CARGO_PKG_VERSION")))),
2124 dim("Remote sharing enabled (LAN)").to_string(),
2125 String::new(),
2126 ]);
2127
2128 println!(" Set on the remote device:\n");
2129 println!(" {}{}",
2130 dim("export ANTHROPIC_BASE_URL="),
2131 cyan(&format!("http://{ip}:{port}")),
2132 );
2133 println!(" {}{}", dim("export ANTHROPIC_API_KEY="), cyan(&key));
2134 println!();
2135 println!(" {} Both devices must be on the same network.", dim("·"));
2136 println!(" {} For any network: {}", dim("·"), cyan("shunt share --tunnel"));
2137 println!(" {} Restart to apply: {}", dim("·"), cyan("shunt start"));
2138 println!(" {} To stop sharing: {}", dim("·"), cyan("shunt share --stop"));
2139 println!();
2140 }
2141
2142 Ok(())
2143}
2144
2145fn start_cloudflare_tunnel(port: u16) -> Result<String> {
2148 use std::io::{BufRead, BufReader};
2149 use std::process::{Command, Stdio};
2150
2151 let mut child = Command::new("cloudflared")
2152 .args(["tunnel", "--url", &format!("http://localhost:{port}")])
2153 .stderr(Stdio::piped())
2154 .stdout(Stdio::null())
2155 .spawn()
2156 .map_err(|e| {
2157 if e.kind() == std::io::ErrorKind::NotFound {
2158 anyhow::anyhow!(
2159 "cloudflared not found.\n\n Install it:\n brew install cloudflared\n or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
2160 )
2161 } else {
2162 anyhow::anyhow!("Failed to start cloudflared: {e}")
2163 }
2164 })?;
2165
2166 let stderr = child.stderr.take().expect("stderr was piped");
2167 let reader = BufReader::new(stderr);
2168
2169 for line in reader.lines() {
2170 let line = line?;
2171 if let Some(url) = extract_cloudflare_url(&line) {
2172 std::mem::forget(child);
2174 return Ok(url);
2175 }
2176 }
2177
2178 bail!("cloudflared exited before providing a tunnel URL")
2179}
2180
2181fn extract_cloudflare_url(line: &str) -> Option<String> {
2182 let lower = line.to_lowercase();
2186 if lower.contains("trycloudflare.com") || lower.contains("cfargotunnel.com") {
2187 if let Some(start) = line.find("https://") {
2189 let rest = &line[start..];
2190 let end = rest.find(|c: char| c.is_whitespace() || c == '|' || c == '"')
2191 .unwrap_or(rest.len());
2192 return Some(rest[..end].trim_end_matches('/').to_owned());
2193 }
2194 }
2195 None
2196}
2197
2198fn generate_remote_key() -> String {
2199 hex::encode(crate::oauth::rand_bytes::<16>())
2200}
2201
2202fn extract_remote_key(config: &str) -> Option<String> {
2203 for line in config.lines() {
2204 let line = line.trim();
2205 if line.starts_with("remote_key") {
2206 return line.split('=')
2207 .nth(1)
2208 .map(|s| s.trim().trim_matches('"').to_owned());
2209 }
2210 }
2211 None
2212}
2213
2214fn insert_into_server_section(config: &str, line: &str) -> String {
2215 if let Some(pos) = config.find("\n[[accounts]]") {
2217 let (before, after) = config.split_at(pos);
2218 format!("{before}\n{line}{after}")
2219 } else {
2220 format!("{config}\n{line}\n")
2221 }
2222}
2223
2224fn local_ip() -> Option<String> {
2225 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
2226 socket.connect("8.8.8.8:80").ok()?;
2227 Some(socket.local_addr().ok()?.ip().to_string())
2228}
2229
2230async fn offer_restart(config_override: Option<PathBuf>) {
2232 use std::io::Write;
2233 let Ok(cfg) = crate::config::load_config(config_override.as_deref()) else { return };
2234 let health_url = format!("http://{}:{}/health", cfg.server.host, cfg.server.port);
2235 let running = reqwest::get(&health_url).await
2236 .map(|r| r.status().is_success())
2237 .unwrap_or(false);
2238 if !running { return; }
2239
2240 print!(" {} Proxy is running — restart now? [Y/n]: ", dim("·"));
2241 std::io::stdout().flush().ok();
2242 let mut buf = String::new();
2243 std::io::stdin().read_line(&mut buf).ok();
2244 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2245 println!(" {} Run {} when ready.", dim("·"), cyan("shunt restart"));
2246 return;
2247 }
2248 if let Err(e) = cmd_restart(config_override).await {
2249 println!(" {} Restart failed: {e}", red(CROSS));
2250 }
2251}
2252
2253fn offer_shell_export() -> Result<()> {
2254 use std::io::{self, Write};
2255
2256 let line = "export ANTHROPIC_BASE_URL=http://127.0.0.1:8082";
2257 println!();
2258 println!(" To use with Claude Code, set:");
2259 println!(" {}", cyan(line));
2260
2261 let profile = detect_shell_profile();
2262 let prompt = match &profile {
2263 Some(p) => format!(" Add to {}? [Y/n]: ", dim(&p.display().to_string())),
2264 None => " Add to your shell profile? [Y/n]: ".into(),
2265 };
2266
2267 print!("{prompt}");
2268 io::stdout().flush()?;
2269 let mut buf = String::new();
2270 io::stdin().read_line(&mut buf)?;
2271
2272 if matches!(buf.trim().to_lowercase().as_str(), "n" | "no") {
2273 return Ok(());
2274 }
2275
2276 let path = match profile {
2277 Some(p) => p,
2278 None => {
2279 println!(" {} Could not detect shell profile. Add manually.", dim("·"));
2280 return Ok(());
2281 }
2282 };
2283
2284 if path.exists() {
2285 let contents = std::fs::read_to_string(&path)?;
2286 if contents.contains("ANTHROPIC_BASE_URL") {
2287 println!(" {} Already set in {}", CHECK, dim(&path.display().to_string()));
2288 return Ok(());
2289 }
2290 }
2291
2292 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
2293 #[allow(unused_imports)]
2294 use std::io::Write as _;
2295 writeln!(f, "\n# Added by shunt")?;
2296 writeln!(f, "{line}")?;
2297 println!(" {} Added to {} — restart shell or: {}", green(CHECK),
2298 dim(&path.display().to_string()),
2299 cyan(&format!("source {}", path.display())));
2300
2301 Ok(())
2302}
2303
2304fn detect_shell_profile() -> Option<PathBuf> {
2305 let home = dirs::home_dir()?;
2306 if let Ok(shell) = std::env::var("SHELL") {
2307 if shell.contains("zsh") { return Some(home.join(".zshrc")); }
2308 if shell.contains("fish") { return Some(home.join(".config/fish/config.fish")); }
2309 if shell.contains("bash") {
2310 let p = home.join(".bash_profile");
2311 return Some(if p.exists() { p } else { home.join(".bashrc") });
2312 }
2313 }
2314 for f in &[".zshrc", ".bashrc", ".bash_profile"] {
2315 let p = home.join(f);
2316 if p.exists() { return Some(p); }
2317 }
2318 None
2319}