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