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