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